Skip to content

Commit e996553

Browse files
committed
fix(cron): preserve action-critical command output
1 parent f65aca6 commit e996553

5 files changed

Lines changed: 286 additions & 9 deletions

File tree

src/cron/action-critical-output.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const DEVICE_AUTH_URL_RE =
2+
/\bhttps?:\/\/[^\s<>"']*(?:login\.microsoft\.com\/device|microsoft\.com\/devicelogin|aka\.ms\/devicelogin|\/oauth2?\/device|\/device(?:code|login)?\b|device[-_]?code|device[-_]?login)[^\s<>"']*/iu;
3+
const LOCAL_CALLBACK_URL_RE =
4+
/\bhttps?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d{2,5})?(?:\/[^\s<>"']*)?\b/iu;
5+
const SETUP_CODE_LINE_RE =
6+
/\b(?:user|device|verification|setup|one[-_\s]?time)?[-_\s]?(?:code|token)\b.*\b[A-Z0-9]{4,12}(?:-[A-Z0-9]{2,12}){0,4}\b/iu;
7+
const NEXT_ACTION_INSTRUCTION_RE =
8+
/\b(?:enter|use|copy|paste|open|visit)\b.{0,120}\b(?:code|token|url|link|browser|callback|device|verification|setup)\b/iu;
9+
10+
/** Matches short command-output lines users need in order to complete setup/auth flows. */
11+
export function isActionCriticalOutputLine(line: string): boolean {
12+
const text = line.trim();
13+
if (!text) {
14+
return false;
15+
}
16+
return (
17+
DEVICE_AUTH_URL_RE.test(text) ||
18+
LOCAL_CALLBACK_URL_RE.test(text) ||
19+
SETUP_CODE_LINE_RE.test(text) ||
20+
NEXT_ACTION_INSTRUCTION_RE.test(text)
21+
);
22+
}

src/cron/command-runner.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,40 @@ describe("runCronCommandJob", () => {
5656
expect(result.summary).toBe("NO_REPLY");
5757
});
5858

59+
it("preserves action-critical setup lines when command stdout is truncated", async () => {
60+
const result = await runCronCommandJob({
61+
job: makeCommandJob({
62+
kind: "command",
63+
argv: [
64+
process.execPath,
65+
"-e",
66+
[
67+
"process.stdout.write('To sign in, use a web browser to open https://login.microsoft.com/device and enter the code FAKE-CODE-270 to authenticate.\\n')",
68+
"for (let i = 0; i < 40; i += 1) process.stdout.write(`CRON-FILLER-270-line-${i}\\n`)",
69+
"process.stdout.write('DONE')",
70+
].join(";"),
71+
],
72+
timeoutSeconds: 5,
73+
outputMaxBytes: 80,
74+
}),
75+
nowMs: () => 789,
76+
});
77+
78+
expect(result.status).toBe("ok");
79+
expect(result.summary).toContain("https://login.microsoft.com/device");
80+
expect(result.summary).toContain("FAKE-CODE-270");
81+
expect(result.summary).toContain("[openclaw: stdout tail]");
82+
expect(result.summary).toMatch(
83+
/\[openclaw: stdout omitted \d+ earlier bytes; Recovery: openclaw cron runs --id "command-job"\]/u,
84+
);
85+
expect(result.diagnostics?.entries[0]).toMatchObject({
86+
ts: 789,
87+
source: "exec",
88+
severity: "info",
89+
truncated: true,
90+
});
91+
});
92+
5993
it("marks non-zero exit codes as cron errors and keeps stderr as summary", async () => {
6094
const result = await runCronCommandJob({
6195
job: makeCommandJob({

src/cron/command-runner.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-core/number-coercion";
22
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
33
import { runCommandWithTimeout } from "../process/exec.js";
4+
import { isActionCriticalOutputLine } from "./action-critical-output.js";
45
import type { CronRunDiagnostics, CronRunOutcome, CronRunStatus, CronJob } from "./types.js";
56

67
const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60_000;
@@ -24,9 +25,83 @@ function trimOutput(value: string): string | undefined {
2425
return normalizeOptionalString(value);
2526
}
2627

27-
function buildCommandSummary(params: { stdout: string; stderr: string }): string | undefined {
28-
const stdout = trimOutput(params.stdout);
29-
const stderr = trimOutput(params.stderr);
28+
function formatRecoveryHint(jobId: string): string {
29+
return `Recovery: openclaw cron runs --id ${JSON.stringify(jobId)}`;
30+
}
31+
32+
function uniquePreservedLines(params: { output?: string; lines?: string[] }): string[] {
33+
const output = params.output ?? "";
34+
const seen = new Set<string>();
35+
const lines: string[] = [];
36+
for (const line of params.lines ?? []) {
37+
const normalized = line.trimEnd();
38+
if (!normalized || seen.has(normalized) || output.includes(normalized)) {
39+
continue;
40+
}
41+
seen.add(normalized);
42+
lines.push(normalized);
43+
}
44+
return lines;
45+
}
46+
47+
function formatCommandStreamOutput(params: {
48+
stream: "stdout" | "stderr";
49+
output: string;
50+
preservedLines?: string[];
51+
truncatedBytes?: number;
52+
jobId: string;
53+
}): string | undefined {
54+
const output = trimOutput(params.output);
55+
const preservedLines = params.truncatedBytes
56+
? uniquePreservedLines({ output, lines: params.preservedLines })
57+
: [];
58+
if (!output && preservedLines.length === 0) {
59+
return undefined;
60+
}
61+
if (!params.truncatedBytes) {
62+
return output;
63+
}
64+
65+
const sections: string[] = [];
66+
if (preservedLines.length > 0) {
67+
sections.push(`[openclaw: preserved earlier ${params.stream} lines omitted by output cap]`);
68+
sections.push(...preservedLines);
69+
if (output) {
70+
sections.push(`[openclaw: ${params.stream} tail]`);
71+
}
72+
}
73+
if (output) {
74+
sections.push(output);
75+
}
76+
sections.push(
77+
`[openclaw: ${params.stream} omitted ${params.truncatedBytes} earlier bytes; ${formatRecoveryHint(params.jobId)}]`,
78+
);
79+
return sections.join("\n");
80+
}
81+
82+
function buildCommandSummary(params: {
83+
jobId: string;
84+
stdout: string;
85+
stderr: string;
86+
stdoutPreservedLines?: string[];
87+
stderrPreservedLines?: string[];
88+
stdoutTruncatedBytes?: number;
89+
stderrTruncatedBytes?: number;
90+
}): string | undefined {
91+
const stdout = formatCommandStreamOutput({
92+
stream: "stdout",
93+
output: params.stdout,
94+
preservedLines: params.stdoutPreservedLines,
95+
truncatedBytes: params.stdoutTruncatedBytes,
96+
jobId: params.jobId,
97+
});
98+
const stderr = formatCommandStreamOutput({
99+
stream: "stderr",
100+
output: params.stderr,
101+
preservedLines: params.stderrPreservedLines,
102+
truncatedBytes: params.stderrTruncatedBytes,
103+
jobId: params.jobId,
104+
});
30105
if (stdout && stderr) {
31106
return `stdout:\n${stdout}\n\nstderr:\n${stderr}`;
32107
}
@@ -115,6 +190,7 @@ export async function runCronCommandJob(params: {
115190
...(payload.env ? { env: payload.env } : {}),
116191
...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}),
117192
...(payload.outputMaxBytes !== undefined ? { maxOutputBytes: payload.outputMaxBytes } : {}),
193+
preserveOutputLine: ({ line }) => isActionCriticalOutputLine(line),
118194
...(params.abortSignal ? { signal: params.abortSignal } : {}),
119195
killProcessTree: true,
120196
});
@@ -125,7 +201,15 @@ export async function runCronCommandJob(params: {
125201
result.termination !== "no-output-timeout" &&
126202
result.termination !== "signal";
127203
const status: CronRunStatus = ok ? "ok" : "error";
128-
const summary = buildCommandSummary({ stdout: result.stdout, stderr: result.stderr });
204+
const summary = buildCommandSummary({
205+
jobId: params.job.id,
206+
stdout: result.stdout,
207+
stderr: result.stderr,
208+
stdoutPreservedLines: result.stdoutPreservedLines,
209+
stderrPreservedLines: result.stderrPreservedLines,
210+
stdoutTruncatedBytes: result.stdoutTruncatedBytes,
211+
stderrTruncatedBytes: result.stderrTruncatedBytes,
212+
});
129213
const error = ok
130214
? undefined
131215
: commandErrorMessage({

src/process/exec.no-output-timer.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,29 @@ describe("runCommandWithTimeout no-output timer", () => {
121121
expect(result.termination).toBe("exit");
122122
});
123123

124+
it("preserves selected early lines without changing bounded tail output", async () => {
125+
vi.useFakeTimers();
126+
const fake = createFakeSpawnedChild();
127+
spawnMock.mockReturnValue(fake.child);
128+
129+
const runPromise = runCommandWithTimeout(["node", "-e", "ignored"], {
130+
timeoutMs: 1_000,
131+
maxOutputBytes: 5,
132+
preserveOutputLine: ({ line }) => line.includes("code"),
133+
});
134+
135+
fake.stdout.emit("data", Buffer.from("enter code ABCD\n"));
136+
fake.stdout.emit("data", Buffer.from("abcdef"));
137+
fake.stdout.emit("data", Buffer.from("gh"));
138+
139+
emitProcessExit(fake, { code: 0 });
140+
const result = await runPromise;
141+
142+
expect(result.stdout).toBe("defgh");
143+
expect(result.stdoutPreservedLines).toEqual(["enter code ABCD"]);
144+
expect(result.stdoutTruncatedBytes).toBe(19);
145+
});
146+
124147
it("marks no-output timeout when the spawned child goes silent", async () => {
125148
vi.useFakeTimers();
126149
const fake = createFakeSpawnedChild();

0 commit comments

Comments
 (0)