fix(cron): classify denied isolated runs

This commit is contained in:
Peter Steinberger
2026-04-27 03:01:47 +01:00
parent 22c9e82e83
commit fc3abc139b
5 changed files with 149 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Cron: classify isolated runs as errors when final output narrates known execution-denial markers such as `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, or approval-binding refusal phrases, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui.
- macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius.
- Exec approvals: accept runtime-owned `source: "allow-always"` and `commandText` allowlist metadata in gateway and node approval-set payloads so Control UI round-trips no longer fail with `unexpected property 'source'`. Fixes #60000; carries forward #60064. Thanks @sd1471123, @sharkqwy, and @luoyanglang.
- Exec/node: skip approval-plan preparation for full-trust `host=node` runs so interpreter and script commands no longer fail with `SYSTEM_RUN_DENIED: approval cannot safely bind` when effective policy is `security=full` and `ask=off`. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC.

View File

@@ -47,6 +47,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
- One-shot jobs (`--at`) auto-delete after success by default.
- Isolated cron runs best-effort close tracked browser tabs/processes for their `cron:<jobId>` session when the run completes, so detached browser automation does not leave orphaned processes behind.
- Isolated cron runs also guard against stale acknowledgement replies. If the first result is just an interim status update (`on it`, `pulling everything together`, and similar hints) and no descendant subagent run is still responsible for the final answer, OpenClaw re-prompts once for the actual result before delivery.
- Isolated cron runs classify known execution-denial markers in the final summary/output as failures, including host markers such as `SYSTEM_RUN_DENIED` and `INVALID_REQUEST`, so a blocked command is not reported as a green run.
<a id="maintenance"></a>

View File

@@ -57,6 +57,11 @@ Note: if an isolated cron run returns only the silent token (`NO_REPLY` /
`no_reply`), cron suppresses direct outbound delivery and the fallback queued
summary path as well, so nothing is posted back to chat.
Note: isolated cron runs treat known denial markers in final output, such as
`SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusal phrases, as
errors. `cron list` and run history then surface the matched token in the error
reason instead of reporting a blocked command as `ok`.
Note: `cron add|edit --model ...` uses that selected allowed model for the job.
If the model is not allowed, cron warns and falls back to the job's agent/default
model selection instead. Configured fallback chains still apply, but a plain

View File

@@ -1,5 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveCronPayloadOutcome } from "./isolated-agent/helpers.js";
import { detectCronDenialToken, resolveCronPayloadOutcome } from "./isolated-agent/helpers.js";
describe("detectCronDenialToken", () => {
it("matches host denial markers case-sensitively", () => {
expect(detectCronDenialToken("SYSTEM_RUN_DENIED: approval blocked")).toBe("SYSTEM_RUN_DENIED");
expect(detectCronDenialToken("INVALID_REQUEST: denied")).toBe("INVALID_REQUEST");
expect(detectCronDenialToken("system_run_denied: approval blocked")).toBeUndefined();
expect(detectCronDenialToken("invalid_request: denied")).toBeUndefined();
});
it("matches model-narrated denial phrases case-insensitively", () => {
expect(detectCronDenialToken("Approval Cannot Safely Bind this runtime command")).toBe(
"approval cannot safely bind",
);
expect(detectCronDenialToken("The runtime denied the operation.")).toBe("runtime denied");
expect(detectCronDenialToken("I could not run the script.")).toBe("could not run");
expect(detectCronDenialToken("The command did not run to completion.")).toBe("did not run");
expect(detectCronDenialToken("The request was denied by policy.")).toBe("was denied");
});
it("ignores empty and non-token text", () => {
expect(detectCronDenialToken(undefined)).toBeUndefined();
expect(
detectCronDenialToken("The denied claim was reviewed, then the job succeeded."),
).toBeUndefined();
});
});
describe("resolveCronPayloadOutcome", () => {
it("uses the last non-empty non-error payload as summary and output", () => {
@@ -134,4 +160,47 @@ describe("resolveCronPayloadOutcome", () => {
{ text: "Final weather summary" },
]);
});
it("promotes narrated denial markers in summary text to fatal errors", () => {
const result = resolveCronPayloadOutcome({
payloads: [
{
text: "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command",
},
],
});
expect(result.hasFatalErrorPayload).toBe(true);
expect(result.embeddedRunError).toBe(
'cron classifier: denial token "SYSTEM_RUN_DENIED" detected in summary',
);
});
it("promotes narrated denial markers from final assistant visible text", () => {
const result = resolveCronPayloadOutcome({
payloads: [{ text: "Working on it..." }],
finalAssistantVisibleText: "I could not run the requested script.",
preferFinalAssistantVisibleText: true,
});
expect(result.hasFatalErrorPayload).toBe(true);
expect(result.outputText).toBe("I could not run the requested script.");
expect(result.embeddedRunError).toBe(
'cron classifier: denial token "could not run" detected in summary',
);
});
it("keeps structured error payload reasons ahead of denial-token reasons", () => {
const result = resolveCronPayloadOutcome({
payloads: [
{
text: "Exec failed before SYSTEM_RUN_DENIED could be retried",
isError: true,
},
],
});
expect(result.hasFatalErrorPayload).toBe(true);
expect(result.embeddedRunError).toBe("Exec failed before SYSTEM_RUN_DENIED could be retried");
});
});

View File

@@ -21,6 +21,60 @@ export type CronPayloadOutcome = {
embeddedRunError?: string;
};
type CronDenialSignal = {
token: string;
field: string;
};
const CRON_DENIAL_EXACT_TOKENS = ["SYSTEM_RUN_DENIED", "INVALID_REQUEST"] as const;
const CRON_DENIAL_CASE_INSENSITIVE_TOKENS = [
"approval cannot safely bind",
"runtime denied",
"could not run",
"did not run",
"was denied",
] as const;
export function detectCronDenialToken(text: string | undefined): string | undefined {
const normalized = normalizeOptionalString(text);
if (!normalized) {
return undefined;
}
for (const token of CRON_DENIAL_EXACT_TOKENS) {
if (normalized.includes(token)) {
return token;
}
}
const lowerText = normalized.toLowerCase();
for (const token of CRON_DENIAL_CASE_INSENSITIVE_TOKENS) {
if (lowerText.includes(token)) {
return token;
}
}
return undefined;
}
function resolveCronDenialSignal(
fields: Array<{ field: string; text?: string | undefined }>,
): CronDenialSignal | undefined {
const seen = new Set<string>();
for (const { field, text } of fields) {
if (seen.has(field)) {
continue;
}
seen.add(field);
const token = detectCronDenialToken(text);
if (token) {
return { token, field };
}
}
return undefined;
}
function formatCronDenialSignal(signal: CronDenialSignal): string {
return `cron classifier: denial token "${signal.token}" detected in ${signal.field}`;
}
export function pickSummaryFromOutput(text: string | undefined) {
const clean = (text ?? "").trim();
if (!clean) {
@@ -157,7 +211,7 @@ export function resolveCronPayloadOutcome(params: {
params.payloads
.slice(lastErrorPayloadIndex + 1)
.some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim()));
const hasFatalErrorPayload = hasErrorPayload && !hasSuccessfulPayloadAfterLastError;
const hasFatalStructuredErrorPayload = hasErrorPayload && !hasSuccessfulPayloadAfterLastError;
const normalizedFinalAssistantVisibleText = normalizeOptionalString(
params.finalAssistantVisibleText,
);
@@ -169,7 +223,7 @@ export function resolveCronPayloadOutcome(params: {
const shouldUseFinalAssistantVisibleText =
params.preferFinalAssistantVisibleText === true &&
normalizedFinalAssistantVisibleText !== undefined &&
!hasFatalErrorPayload &&
!hasFatalStructuredErrorPayload &&
!hasStructuredDeliveryPayloads;
const summary = shouldUseFinalAssistantVisibleText
? (pickSummaryFromOutput(normalizedFinalAssistantVisibleText) ?? fallbackSummary)
@@ -189,6 +243,18 @@ export function resolveCronPayloadOutcome(params: {
.toReversed()
.find((payload) => payload?.isError === true && Boolean(payload?.text?.trim()))
?.text?.trim();
const denialSignal = resolveCronDenialSignal([
{ field: "summary", text: summary },
{ field: "outputText", text: outputText },
{ field: "synthesizedText", text: synthesizedText },
{ field: "fallbackSummary", text: fallbackSummary },
{ field: "fallbackOutputText", text: fallbackOutputText },
...params.payloads.map((payload, index) => ({
field: `payloads[${index}].text`,
text: payload?.text,
})),
]);
const hasFatalErrorPayload = hasFatalStructuredErrorPayload || denialSignal !== undefined;
return {
summary,
outputText,
@@ -197,8 +263,10 @@ export function resolveCronPayloadOutcome(params: {
deliveryPayloads: resolvedDeliveryPayloads,
deliveryPayloadHasStructuredContent,
hasFatalErrorPayload,
embeddedRunError: hasFatalErrorPayload
embeddedRunError: hasFatalStructuredErrorPayload
? (lastErrorPayloadText ?? "cron isolated run returned an error payload")
: undefined,
: denialSignal
? formatCronDenialSignal(denialSignal)
: undefined,
};
}