fix(doctor): split restricted cron prompt advisory

This commit is contained in:
Altay
2026-06-20 01:17:48 +03:00
parent a8b5afc4b7
commit 0d71970a16
6 changed files with 119 additions and 16 deletions

View File

@@ -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, [

View File

@@ -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,
);

View File

@@ -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;
}

View File

@@ -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 "<shell>"`) 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;

View File

@@ -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<string, unknown>;
expect(payload.kind).toBe("agentTurn");
expect(payload.message).toContain(command);

View File

@@ -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<Record<CronStoreIssueKey, number>>;
type NormalizeCronStoreJobsResult = {
issues: CronStoreIssues;
unresolvedAgentTurnCommandPromptJobs: string[];
unresolvedAgentTurnShellToolPromptJobs: string[];
jobs: Array<Record<string, unknown>>;
mutated: boolean;
@@ -246,7 +247,12 @@ export function normalizeStoredCronJobs(
jobs: Array<Record<string, unknown>>,
): NormalizeCronStoreJobsResult {
const issues: CronStoreIssues = {};
const unresolvedAgentTurnCommandPromptJobs: string[] = [];
const unresolvedAgentTurnShellToolPromptJobs: string[] = [];
const unresolvedAgentTurnPromptJobsByKind = {
commandPromptWithoutShellAccess: unresolvedAgentTurnCommandPromptJobs,
shellToolPrompt: unresolvedAgentTurnShellToolPromptJobs,
};
let mutated = false;
const keptJobs: Array<Record<string, unknown>> = [];
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,
};
}