mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 18:42:25 +00:00
fix(doctor): split restricted cron prompt advisory
This commit is contained in:
@@ -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, [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user