From 0d71970a16e27cee368d9e310d92ec2badcd86dc Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 20 Jun 2026 01:17:48 +0300 Subject: [PATCH] fix(doctor): split restricted cron prompt advisory --- src/commands/doctor/cron/index.test.ts | 46 +++++++++++++++++++ src/commands/doctor/cron/index.ts | 11 ++++- src/commands/doctor/cron/payload-migration.ts | 28 ++++++++--- src/commands/doctor/cron/repair-plan.ts | 18 ++++++++ .../doctor/cron/store-migration.test.ts | 2 + src/commands/doctor/cron/store-migration.ts | 30 +++++++++--- 6 files changed, 119 insertions(+), 16 deletions(-) diff --git a/src/commands/doctor/cron/index.test.ts b/src/commands/doctor/cron/index.test.ts index 0a6f40793fc9..55d69967b4f9 100644 --- a/src/commands/doctor/cron/index.test.ts +++ b/src/commands/doctor/cron/index.test.ts @@ -692,6 +692,52 @@ describe("maybeRepairLegacyCronStore", () => { expect(payload.message).toContain("python3 scripts/check_mail.py"); }); + it("keeps restricted command prompts actionable without a --fix repair note", async () => { + const storePath = await makeTempStorePath(); + const commandPromptJob = createCurrentCronJob({ + id: "restricted-command-prompt", + name: "Restricted command prompt", + schedule: { kind: "cron", expr: "*/30 * * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { + kind: "agentTurn", + message: [ + "Command to run:", + "- command: python3 scripts/check_mail.py", + "- workdir: /home/openclaw/.razor/clawd", + ].join("\n"), + toolsAllow: ["read", "message"], + }, + delivery: { mode: "announce" }, + }); + await writeCurrentCronStore(storePath, [commandPromptJob]); + + const prompter = makePrompter(true); + await maybeRepairLegacyCronStore({ + cfg: createCronConfig(storePath), + options: {}, + prompter, + }); + + expectNoNoteContaining("Cron store issues detected", "Cron"); + expectNoteContaining( + "1 isolated cron job describes a shell command in the agent prompt but lacks shell/process tool access: `Restricted command prompt`.", + "Cron", + ); + expectNoteContaining("not the supported shell-tool prompt shape", "Cron"); + expectNoteContaining("Recreate the job as a command cron job", "Cron"); + expectNoNoteContaining("informational only", "Cron"); + expectNoNoteContaining("keep running as-is", "Cron"); + expectNoNoteContaining("openclaw doctor --fix", "Cron"); + expect(prompter.confirm).not.toHaveBeenCalled(); + + const job = requirePersistedJob(await readPersistedJobs(storePath), 0); + const payload = requireRecord(job.payload, "cron payload"); + expect(payload.kind).toBe("agentTurn"); + expect(payload.message).toContain("python3 scripts/check_mail.py"); + expect(payload.toolsAllow).toEqual(["read", "message"]); + }); + it("repairs malformed persisted cron ids before list rendering sees them", async () => { const storePath = await makeTempStorePath(); await writeCronStore(storePath, [ diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index d38fc8f071aa..40bf680d6494 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -30,6 +30,7 @@ import { } from "./legacy-store-migration.js"; import { formatLegacyIssuePreview, + formatUnresolvedCommandPromptAdvisory, formatUnresolvedShellPromptAdvisory, mergeLegacyCronJobs, mergeRuntimeEntryIntoConfigJob, @@ -337,8 +338,14 @@ export async function maybeRepairLegacyCronStore(params: { const normalized = normalizeStoredCronJobs(rawJobs); const notifyCount = rawJobs.filter((job) => job.notify === true).length; const dreamingStaleCount = countStaleDreamingJobs(rawJobs); - // Shell-prompt jobs are not auto-fixable; keep them out of the --fix preview so the - // repair note does not promise a fix that never lands (#94655). + // Unresolved agentTurn command prompts are not auto-fixable; keep them out of the + // --fix preview so the repair note does not promise a fix that never lands (#94655). + const commandPromptAdvisory = formatUnresolvedCommandPromptAdvisory( + normalized.unresolvedAgentTurnCommandPromptJobs, + ); + if (commandPromptAdvisory) { + note(commandPromptAdvisory, "Cron"); + } const shellPromptAdvisory = formatUnresolvedShellPromptAdvisory( normalized.unresolvedAgentTurnShellToolPromptJobs, ); diff --git a/src/commands/doctor/cron/payload-migration.ts b/src/commands/doctor/cron/payload-migration.ts index a1ae5d798480..ee8a469fef86 100644 --- a/src/commands/doctor/cron/payload-migration.ts +++ b/src/commands/doctor/cron/payload-migration.ts @@ -12,6 +12,10 @@ type LegacyAgentTurnCommandPayload = { timeoutSeconds?: number; }; +export type UnresolvedAgentTurnShellToolPromptKind = + | "commandPromptWithoutShellAccess" + | "shellToolPrompt"; + const LEGACY_AGENT_TURN_COMMAND_MARKER_RE = /\bCommand to run\s*:/iu; const LEGACY_AGENT_TURN_COMMAND_FIELD_RE = /^\s*-\s*(command|workdir|timeout)\s*:\s*(.*?)\s*$/iu; const SHELL_TOOL_NAMES = new Set(["bash", "command", "exec", "process", "shell", "sh"]); @@ -217,17 +221,27 @@ export function migrateLegacyAgentTurnCommandPayload(payload: UnknownRecord): bo return true; } -export function hasUnresolvedAgentTurnShellToolPrompt(payload: UnknownRecord): boolean { +export function classifyUnresolvedAgentTurnShellToolPrompt( + payload: UnknownRecord, +): UnresolvedAgentTurnShellToolPromptKind | null { if (payload.kind !== "agentTurn") { - return false; + return null; } const message = readString(payload.message); if (typeof message !== "string") { - return false; + return null; } const parsed = parseLegacyAgentTurnCommandMessage(message); - return ( - Boolean(parsed) || - (hasShellToolAccess(payload.toolsAllow) && SHELL_COMMAND_MESSAGE_RE.test(message)) - ); + const shellToolAccess = hasShellToolAccess(payload.toolsAllow); + if (parsed && !shellToolAccess) { + return "commandPromptWithoutShellAccess"; + } + if (shellToolAccess && SHELL_COMMAND_MESSAGE_RE.test(message)) { + return "shellToolPrompt"; + } + return null; +} + +export function hasUnresolvedAgentTurnShellToolPrompt(payload: UnknownRecord): boolean { + return classifyUnresolvedAgentTurnShellToolPrompt(payload) !== null; } diff --git a/src/commands/doctor/cron/repair-plan.ts b/src/commands/doctor/cron/repair-plan.ts index 4dd9a54181d5..75063637782e 100644 --- a/src/commands/doctor/cron/repair-plan.ts +++ b/src/commands/doctor/cron/repair-plan.ts @@ -16,6 +16,24 @@ function formatJobNameList(names: string[]): string { return remaining > 0 ? `: ${preview.join(", ")} (+${remaining} more)` : `: ${preview.join(", ")}`; } +/** + * Advisory for isolated agentTurn cron jobs that describe a command but cannot access shell tools. + * These need operator attention, but `doctor --fix` cannot safely infer whether to grant tool + * access or recreate them as command cron jobs. + */ +export function formatUnresolvedCommandPromptAdvisory(names: string[]): string | null { + if (names.length === 0) { + return null; + } + const describeVerb = names.length === 1 ? "describes" : "describe"; + const accessVerb = names.length === 1 ? "lacks" : "lack"; + return [ + `${pluralize(names.length, "isolated cron job")} ${describeVerb} a shell command in the agent prompt but ${accessVerb} shell/process tool access${formatJobNameList(names)}.`, + "- This is not the supported shell-tool prompt shape, so doctor cannot prove the job will execute the requested command.", + '- Recreate the job as a command cron job (`openclaw cron add ... --command ""`) or grant explicit shell/process tool access before relying on it.', + ].join("\n"); +} + /** * Advisory for isolated agentTurn cron jobs that drive shell/process tools from the prompt. * These keep running and are not a legacy store row, so `doctor --fix` cannot rewrite them; diff --git a/src/commands/doctor/cron/store-migration.test.ts b/src/commands/doctor/cron/store-migration.test.ts index 2c784191eca2..6cade9a7f652 100644 --- a/src/commands/doctor/cron/store-migration.test.ts +++ b/src/commands/doctor/cron/store-migration.test.ts @@ -190,6 +190,8 @@ describe("normalizeStoredCronJobs", () => { expect(result.issues.legacyAgentTurnCommandPayload).toBeUndefined(); expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBe(1); + expect(result.unresolvedAgentTurnCommandPromptJobs).toEqual(["Legacy job"]); + expect(result.unresolvedAgentTurnShellToolPromptJobs).toEqual([]); const payload = job.payload as Record; expect(payload.kind).toBe("agentTurn"); expect(payload.message).toContain(command); diff --git a/src/commands/doctor/cron/store-migration.ts b/src/commands/doctor/cron/store-migration.ts index f1878d031041..a136d75a5744 100644 --- a/src/commands/doctor/cron/store-migration.ts +++ b/src/commands/doctor/cron/store-migration.ts @@ -14,7 +14,7 @@ import { inferCronJobName } from "../../../cron/service/normalize.js"; import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../../../cron/stagger.js"; import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js"; import { - hasUnresolvedAgentTurnShellToolPrompt, + classifyUnresolvedAgentTurnShellToolPrompt, hasLegacyOpenAICodexCronModelRef, migrateLegacyAgentTurnCommandPayload, migrateLegacyCronPayload, @@ -41,6 +41,7 @@ type CronStoreIssues = Partial>; type NormalizeCronStoreJobsResult = { issues: CronStoreIssues; + unresolvedAgentTurnCommandPromptJobs: string[]; unresolvedAgentTurnShellToolPromptJobs: string[]; jobs: Array>; mutated: boolean; @@ -246,7 +247,12 @@ export function normalizeStoredCronJobs( jobs: Array>, ): NormalizeCronStoreJobsResult { const issues: CronStoreIssues = {}; + const unresolvedAgentTurnCommandPromptJobs: string[] = []; const unresolvedAgentTurnShellToolPromptJobs: string[] = []; + const unresolvedAgentTurnPromptJobsByKind = { + commandPromptWithoutShellAccess: unresolvedAgentTurnCommandPromptJobs, + shellToolPrompt: unresolvedAgentTurnShellToolPromptJobs, + }; let mutated = false; const keptJobs: Array> = []; const removedJobs: NormalizeCronStoreJobsResult["removedJobs"] = []; @@ -421,11 +427,14 @@ export function normalizeStoredCronJobs( if (migrateLegacyAgentTurnCommandPayload(payloadRecord)) { mutated = true; trackIssue("legacyAgentTurnCommandPayload"); - } else if (hasUnresolvedAgentTurnShellToolPrompt(payloadRecord)) { - trackIssue("unresolvedAgentTurnShellToolPrompt"); - const name = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); - if (name) { - unresolvedAgentTurnShellToolPromptJobs.push(name); + } else { + const unresolvedPromptKind = classifyUnresolvedAgentTurnShellToolPrompt(payloadRecord); + if (unresolvedPromptKind) { + trackIssue("unresolvedAgentTurnShellToolPrompt"); + const name = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); + if (name) { + unresolvedAgentTurnPromptJobsByKind[unresolvedPromptKind].push(name); + } } } } @@ -626,5 +635,12 @@ export function normalizeStoredCronJobs( jobs.splice(0, jobs.length, ...keptJobs); } - return { issues, unresolvedAgentTurnShellToolPromptJobs, jobs, mutated, removedJobs }; + return { + issues, + unresolvedAgentTurnCommandPromptJobs, + unresolvedAgentTurnShellToolPromptJobs, + jobs, + mutated, + removedJobs, + }; }