Skip to content

Commit c169cdd

Browse files
committed
fix(exec): preserve action-critical auth/setup prompt lines during output truncation
When runCommandWithTimeout output exceeds maxOutputBytes, appendCapturedOutput keeps only the tail of captured stdout/stderr. Device-code login prompts (login URL, device code, next-action instruction) at the beginning are silently dropped, making auth prompts unactionable. This adds: - src/process/action-critical-output.ts: shared classifier with 13 regex patterns for device-login URLs, verification codes, callback URLs, and explicit next-action instructions - CapturedOutputBuffers.headChunks: bounded head buffer (maxOutputBytes) to recover action-critical lines from early output - CapturedOutputBuffers.preservedActionCritical: stores lines extracted from chunks dropped during truncation - buildCaptureResult(): integrates preserved lines + truncation notice into final stdout/stderr output The classifier is designed for shared use with broadcast redaction (PR #95809). Closes #96346
1 parent f65aca6 commit c169cdd

3 files changed

Lines changed: 321 additions & 10 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Tests for action-critical output classifier.
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
extractActionCriticalLines,
5+
hasActionCriticalContent,
6+
isActionCriticalLine,
7+
} from "./action-critical-output.js";
8+
9+
describe("isActionCriticalLine", () => {
10+
it("detects Microsoft device-login URLs", () => {
11+
expect(isActionCriticalLine("To sign in, use a web browser to open https://login.microsoft.com/device")).toBe(true);
12+
expect(isActionCriticalLine("Open https://microsoft.com/devicelogin in your browser")).toBe(true);
13+
});
14+
15+
it("detects device/verification codes", () => {
16+
expect(isActionCriticalLine("and enter the code FAKE-CODE-270 to authenticate.")).toBe(true);
17+
expect(isActionCriticalLine("Your verification code is: ABCD-1234-EFGH")).toBe(true);
18+
expect(isActionCriticalLine("Setup code: 1234-5678")).toBe(true);
19+
expect(isActionCriticalLine("Device code: WXYZ-9876")).toBe(true);
20+
expect(isActionCriticalLine("enter this code: XXXXX-YYYYY")).toBe(true);
21+
});
22+
23+
it("detects localhost callback URLs", () => {
24+
expect(isActionCriticalLine("Open http://localhost:9876 to complete setup")).toBe(true);
25+
expect(isActionCriticalLine("Callback: http://127.0.0.1:3000/callback")).toBe(true);
26+
});
27+
28+
it("detects explicit next-action instructions", () => {
29+
expect(isActionCriticalLine("To sign in, open a web browser")).toBe(true);
30+
expect(isActionCriticalLine("Go to https://example.com/activate")).toBe(true);
31+
expect(isActionCriticalLine("Copy this code and paste it in the browser")).toBe(true);
32+
expect(isActionCriticalLine("Use this URL to authenticate")).toBe(true);
33+
});
34+
35+
it("returns false for ordinary output lines", () => {
36+
expect(isActionCriticalLine("Starting build process...")).toBe(false);
37+
expect(isActionCriticalLine("CRON-FILLER-270-line-1")).toBe(false);
38+
expect(isActionCriticalLine("DONE")).toBe(false);
39+
expect(isActionCriticalLine("Error: file not found")).toBe(false);
40+
expect(isActionCriticalLine("")).toBe(false);
41+
expect(isActionCriticalLine("[INFO] Task completed successfully")).toBe(false);
42+
});
43+
44+
it("returns false for URLs without auth context", () => {
45+
expect(isActionCriticalLine("Download from https://example.com/file.zip")).toBe(false);
46+
expect(isActionCriticalLine("See https://docs.example.com for more info")).toBe(false);
47+
});
48+
});
49+
50+
describe("hasActionCriticalContent", () => {
51+
it("returns true when multi-line text contains action-critical content", () => {
52+
const text = [
53+
"Starting job...",
54+
"To sign in, use a web browser to open https://login.microsoft.com/device",
55+
"and enter the code FAKE-CODE-270 to authenticate.",
56+
"Job complete.",
57+
].join("\n");
58+
expect(hasActionCriticalContent(text)).toBe(true);
59+
});
60+
61+
it("returns false for ordinary multi-line text", () => {
62+
const text = [
63+
"line 1",
64+
"line 2",
65+
"line 3",
66+
].join("\n");
67+
expect(hasActionCriticalContent(text)).toBe(false);
68+
});
69+
70+
it("returns true for empty-adjacent content (edge)", () => {
71+
expect(hasActionCriticalContent("https://login.microsoft.com/device")).toBe(true);
72+
expect(hasActionCriticalContent("")).toBe(false);
73+
});
74+
});
75+
76+
describe("extractActionCriticalLines", () => {
77+
it("extracts action-critical lines from mixed output", () => {
78+
const text = [
79+
"Filler line 1",
80+
"To sign in, use a web browser to open https://login.microsoft.com/device",
81+
"and enter the code FAKE-CODE-270 to authenticate.",
82+
"Filler line 2",
83+
].join("\n");
84+
85+
const lines = extractActionCriticalLines(text);
86+
expect(lines).toHaveLength(2);
87+
expect(lines[0]).toContain("login.microsoft.com");
88+
expect(lines[1]).toContain("FAKE-CODE-270");
89+
});
90+
91+
it("returns empty array when no action-critical lines present", () => {
92+
const text = [
93+
"line 1",
94+
"line 2",
95+
].join("\n");
96+
expect(extractActionCriticalLines(text)).toEqual([]);
97+
});
98+
99+
it("returns empty array for empty string", () => {
100+
expect(extractActionCriticalLines("")).toEqual([]);
101+
});
102+
103+
it("deduplicates identical lines (via caller, but this is the raw extract)", () => {
104+
const text = [
105+
"To sign in, open https://login.microsoft.com/device",
106+
"Filler",
107+
"To sign in, open https://login.microsoft.com/device",
108+
].join("\n");
109+
const lines = extractActionCriticalLines(text);
110+
// Raw extract does not deduplicate — that's the caller's responsibility.
111+
expect(lines).toHaveLength(2);
112+
});
113+
114+
it("preserves line order", () => {
115+
const text = [
116+
"Filler A",
117+
"To sign in, open https://login.microsoft.com/device",
118+
"Filler B",
119+
"Your verification code is: ABCD-1234",
120+
"Filler C",
121+
].join("\n");
122+
123+
const lines = extractActionCriticalLines(text);
124+
expect(lines).toHaveLength(2);
125+
expect(lines[0]).toContain("login.microsoft.com");
126+
expect(lines[1]).toContain("ABCD-1234");
127+
});
128+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Shared classifier for action-critical output lines.
3+
*
4+
* Identifies lines that must be preserved in interactive command results
5+
* (auth/setup prompts, device codes, callback URLs) and redacted in
6+
* broadcast delivery.
7+
*
8+
* Used by:
9+
* - Interactive/async truncation preservation (issue #96346)
10+
* - Cron broadcast delivery redaction (PR #95809)
11+
*
12+
* Design invariant: classification is stateless and line-based.
13+
* A line is action-critical if it matches any of the patterns below.
14+
*/
15+
16+
// Patterns for action-critical lines.
17+
// Each pattern is tested against individual lines (not multi-line text).
18+
const ACTION_CRITICAL_PATTERNS: readonly RegExp[] = [
19+
// Device-login URLs
20+
/login\.microsoft\.com\/device/i,
21+
/microsoft\.com\/devicelogin/i,
22+
23+
// Device/setup/verification code patterns
24+
/code:\s*\w{4,}(?:[-\s]\w{4,})+/, // code: XXXX-XXXX or code: XXXX XXXX
25+
/enter the code\s/i,
26+
/enter this code/i,
27+
/verification code/i,
28+
/setup code/i,
29+
/device code/i,
30+
31+
// Localhost/device/callback URLs
32+
/https?:\/\/localhost:\d+/,
33+
/https?:\/\/127\.0\.0\.1:\d+/,
34+
35+
// Explicit next-action instructions
36+
/(?:enter|use|copy|paste|open)\s+(?:this|the)\s+(?:code|url|link|page)/i,
37+
/to\s+(?:sign\s+in|authenticate|continue|complete)/i,
38+
/open\s+(?:a\s+)?(?:web\s+)?browser/i,
39+
/go to\s+(?:https?:\/\/)/i,
40+
];
41+
42+
/**
43+
* Returns true if the given line contains action-critical content
44+
* that should be preserved during output truncation.
45+
*/
46+
export function isActionCriticalLine(line: string): boolean {
47+
return ACTION_CRITICAL_PATTERNS.some((pattern) => pattern.test(line));
48+
}
49+
50+
/**
51+
* Extracts all action-critical lines from a multi-line text string.
52+
* Empty lines and lines without action-critical content are excluded.
53+
*/
54+
export function extractActionCriticalLines(text: string): string[] {
55+
const result: string[] = [];
56+
for (const line of text.split("\n")) {
57+
if (isActionCriticalLine(line)) {
58+
result.push(line);
59+
}
60+
}
61+
return result;
62+
}
63+
64+
/**
65+
* Returns true if the text contains any action-critical content.
66+
* Faster than extractActionCriticalLines when you only need a boolean.
67+
*/
68+
export function hasActionCriticalContent(text: string): boolean {
69+
return ACTION_CRITICAL_PATTERNS.some((pattern) => pattern.test(text));
70+
}

src/process/exec.ts

Lines changed: 123 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { promisify } from "node:util";
77
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
88
import { danger, shouldLogVerbose } from "../globals.js";
99
import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js";
10+
import {
11+
extractActionCriticalLines,
12+
hasActionCriticalContent,
13+
} from "./action-critical-output.js";
1014
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
1115
import {
1216
decodeWindowsOutputBuffer,
@@ -243,6 +247,11 @@ type CapturedOutputBuffers = {
243247
chunks: Buffer[];
244248
bytes: number;
245249
truncatedBytes: number;
250+
/** First N bytes of output (bounded by maxOutputBytes). Used to recover action-critical lines when truncation occurs. */
251+
headChunks: Buffer[];
252+
headBytes: number;
253+
/** Action-critical lines extracted from dropped data during truncation. */
254+
preservedActionCritical: string[];
246255
};
247256

248257
function normalizeMaxOutputBytes(value: number | undefined): number {
@@ -258,7 +267,26 @@ function appendCapturedOutput(
258267
maxBytes: number,
259268
): void {
260269
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
270+
271+
// Track head data (bounded by maxBytes) for action-critical line recovery.
272+
if (capture.headBytes < maxBytes) {
273+
const headRoom = maxBytes - capture.headBytes;
274+
const headChunk = buffer.subarray(0, Math.min(headRoom, buffer.byteLength));
275+
capture.headChunks.push(Buffer.from(headChunk));
276+
capture.headBytes += headChunk.byteLength;
277+
}
278+
261279
if (buffer.byteLength >= maxBytes) {
280+
// Single chunk exceeds the limit. Replace the entire tail with the last maxBytes.
281+
// Preserve action-critical lines from all accumulated data before replacement.
282+
preserveActionCriticalFromCapture(capture, maxBytes);
283+
284+
// Also scan the current chunk for action-critical lines.
285+
const chunkText = buffer.toString();
286+
if (hasActionCriticalContent(chunkText)) {
287+
capture.preservedActionCritical.push(...extractActionCriticalLines(chunkText));
288+
}
289+
262290
capture.chunks = [Buffer.from(buffer.subarray(buffer.byteLength - maxBytes))];
263291
capture.truncatedBytes += capture.bytes + buffer.byteLength - maxBytes;
264292
capture.bytes = maxBytes;
@@ -274,13 +302,90 @@ function appendCapturedOutput(
274302
capture.chunks.shift();
275303
capture.bytes -= first.byteLength;
276304
capture.truncatedBytes += first.byteLength;
305+
// Scan dropped chunk for action-critical lines.
306+
const droppedText = first.toString();
307+
if (hasActionCriticalContent(droppedText)) {
308+
capture.preservedActionCritical.push(...extractActionCriticalLines(droppedText));
309+
}
277310
} else {
311+
const droppedText = first.subarray(0, overflow).toString();
278312
capture.chunks[0] = Buffer.from(first.subarray(overflow));
279313
capture.bytes -= overflow;
280314
capture.truncatedBytes += overflow;
315+
// Scan dropped portion for action-critical lines.
316+
if (hasActionCriticalContent(droppedText)) {
317+
capture.preservedActionCritical.push(...extractActionCriticalLines(droppedText));
318+
}
281319
}
282320
}
283321
}
322+
323+
/**
324+
* Extract action-critical lines from head (first maxBytes of output) when
325+
* truncation has occurred. Used before a single oversized chunk replaces
326+
* the entire tail buffer.
327+
*/
328+
function preserveActionCriticalFromCapture(
329+
capture: CapturedOutputBuffers,
330+
maxBytes: number,
331+
): void {
332+
if (capture.chunks.length === 0) {
333+
return;
334+
}
335+
const headText = Buffer.concat(capture.headChunks, capture.headBytes).toString();
336+
if (hasActionCriticalContent(headText)) {
337+
capture.preservedActionCritical.push(...extractActionCriticalLines(headText));
338+
}
339+
}
340+
341+
/**
342+
* Build the final output string from captured buffers.
343+
* When truncation occurred, preserved action-critical lines are prepended
344+
* and a truncation notice is appended. When no truncation occurred,
345+
* the raw tail is returned as-is.
346+
*/
347+
function buildCaptureResult(
348+
capture: CapturedOutputBuffers,
349+
windowsEncoding: string,
350+
): string {
351+
const tail = decodeWindowsOutputBuffer({
352+
buffer: Buffer.concat(capture.chunks, capture.bytes),
353+
windowsEncoding,
354+
});
355+
356+
if (capture.truncatedBytes > 0 || capture.preservedActionCritical.length > 0) {
357+
const recoveryHint =
358+
capture.truncatedBytes > 0
359+
? ' For full output, use "openclaw cron runs --id <job> --run-id <run>"'
360+
: "";
361+
const truncatedNotice = `[output truncated: ${capture.truncatedBytes} bytes]${recoveryHint}`;
362+
363+
if (
364+
capture.preservedActionCritical.length > 0 &&
365+
(capture.truncatedBytes > 0 || capture.chunks.length > 0)
366+
) {
367+
// Deduplicate preserved lines (they may appear in both head and dropped chunks).
368+
const seen = new Set<string>();
369+
const uniqueLines = capture.preservedActionCritical.filter((l) => {
370+
const key = l.trim().toLowerCase();
371+
if (seen.has(key)) return false;
372+
seen.add(key);
373+
return true;
374+
});
375+
376+
if (uniqueLines.length > 0) {
377+
return `${uniqueLines.join("\n")}\n\n${tail}\n${truncatedNotice}`;
378+
}
379+
}
380+
381+
// Truncated but no (unique) action-critical lines preserved.
382+
return tail + (tail ? "\n" : "") + truncatedNotice;
383+
}
384+
385+
// No truncation — return raw tail as-is.
386+
return tail;
387+
}
388+
284389
export function resolveProcessExitCode(params: {
285390
explicitCode: number | null | undefined;
286391
childExitCode: number | null | undefined;
@@ -382,8 +487,22 @@ export async function runCommandWithTimeout(
382487
});
383488
// Spawn with inherited stdin (TTY) so interactive tools stay usable when needed.
384489
return await new Promise((resolve, reject) => {
385-
const stdoutCapture: CapturedOutputBuffers = { chunks: [], bytes: 0, truncatedBytes: 0 };
386-
const stderrCapture: CapturedOutputBuffers = { chunks: [], bytes: 0, truncatedBytes: 0 };
490+
const stdoutCapture: CapturedOutputBuffers = {
491+
chunks: [],
492+
bytes: 0,
493+
truncatedBytes: 0,
494+
headChunks: [],
495+
headBytes: 0,
496+
preservedActionCritical: [],
497+
};
498+
const stderrCapture: CapturedOutputBuffers = {
499+
chunks: [],
500+
bytes: 0,
501+
truncatedBytes: 0,
502+
headChunks: [],
503+
headBytes: 0,
504+
preservedActionCritical: [],
505+
};
387506
const maxOutputBytes = normalizeMaxOutputBytes(options.maxOutputBytes);
388507
const windowsEncoding = resolveWindowsConsoleEncoding();
389508
let settled = false;
@@ -598,14 +717,8 @@ export async function runCommandWithTimeout(
598717
: resolvedCode;
599718
resolve({
600719
pid: child.pid ?? undefined,
601-
stdout: decodeWindowsOutputBuffer({
602-
buffer: Buffer.concat(stdoutCapture.chunks, stdoutCapture.bytes),
603-
windowsEncoding,
604-
}),
605-
stderr: decodeWindowsOutputBuffer({
606-
buffer: Buffer.concat(stderrCapture.chunks, stderrCapture.bytes),
607-
windowsEncoding,
608-
}),
720+
stdout: buildCaptureResult(stdoutCapture, windowsEncoding),
721+
stderr: buildCaptureResult(stderrCapture, windowsEncoding),
609722
stdoutTruncatedBytes: stdoutCapture.truncatedBytes || undefined,
610723
stderrTruncatedBytes: stderrCapture.truncatedBytes || undefined,
611724
code: normalizedCode,

0 commit comments

Comments
 (0)