fix(cron): default automation tool lists follow the owner conversation

Agent-created agent turns without an explicit tool list store ['*'], like operator jobs. Jobs that older builds saved with an automatic creator snapshot (toolsAllowIsDefault) run with ['*'] too; the stored row is not rewritten. Script/trigger jobs and jobs bound to Codex app authority keep the creator's list. Removes the Doctor advisories and the run warning that only existed for stale snapshots.
This commit is contained in:
Ayaan Zaidi 2026-10-01 18:00:54 +05:30
parent 94f5a8d586
commit e151ea3bb5
No known key found for this signature in database
32 changed files with 152 additions and 625 deletions

View file

@ -57,12 +57,20 @@ Skill collection review runs every 7 days. It is enabled when `skills.workshop.a
Restrict which tools the job can use, for example `--tools exec,read`. Pass `--tools ""` for an empty allowlist that disables all agent tools, including tools used by a condition trigger.
</ParamField>
New jobs that can run tools always store an explicit tool policy. Jobs created by an agent
are capped to the tools available to that creating turn, and the agent cannot widen the
stored list. Jobs created by an authenticated operator without `--tools` store an
unrestricted `*` policy; `automations edit --clear-tools` restores that explicit unrestricted
policy. Existing jobs that predate an explicit tool policy retain their current behavior
until their tool policy is explicitly edited or the job is recreated.
New jobs that can run tools always store an explicit tool policy. A job created without
`--tools` (or with `*`) stores `*`: each run uses the owner session's current tool policy,
including its group, agent, sandbox, and runtime restrictions. An agent that requests a
finite list is capped to the tools available to its creating turn and cannot widen the
stored list. `automations edit --clear-tools` restores `*`. Existing jobs that predate an
explicit tool policy retain their current behavior until their tool policy is explicitly
edited or the job is recreated. Agent-created script payloads, condition triggers, and jobs
whose creator captured Codex app authority store the creating turn's tools instead: scripts
reach MCP only through servers their list names, and app authority is bound to that list.
Earlier releases saved a copy of the creating turn's tool list on agent-created agent turns.
That copy could miss tools the creator had, such as the native shell. Those jobs now run with
their owner conversation's tools, like a `*` job; the stored copy is left as it is. Jobs whose
creator captured Codex app authority keep using their copy.
Changing an account-bound job to a payload that does not run tools and later back
to an agent turn preserves its account restriction. A payload conversion does not

View file

@ -39,7 +39,6 @@ postures and maintenance modes documented on the other pages.
- State integrity checks detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
- Doctor scans historical `~/.openclaw/cron/jobs.json` stores and previously configured legacy store locations for old cron job shapes, imports jobs and quarantine records into SQLite, and archives the migrated JSON files.
- Doctor reports cron jobs with an explicit `payload.model` override, including provider-namespace counts and mismatches against `agents.defaults.model`, so scheduled jobs that do not inherit the default model are visible during auth or billing investigations.
- Doctor reports automatically captured job tool lists that contain no native capabilities when the configured backend supports native-tool capture. Older captures could omit native tools; deliberately restricted jobs can be left as is. Doctor never widens these lists, including with `--fix`. To change a list, use `openclaw cron edit <id> --tools "<complete list>" --json` from an authorized session that holds the tools, including every tool the job should retain.
- Doctor reports cron jobs still marked in-flight (`state.runningAtMs`), which can make `openclaw cron list` show them as `running`. This check is read-only: if no Gateway is currently executing a marked job, the next cron service startup records the interrupted run and clears the marker.
## Tool and channel policy

View file

@ -457,8 +457,9 @@ bridge retains the admitted sender, account, and conversation; channel access an
write permissions still apply. That authority ends with the turn or its
cancellation, including when a warm CLI process is reused for a later turn.
Automations created through the bridge inherit its final permitted tool set and
supported native tool capabilities. When Claude's native `Bash` supplies `exec`,
Automations created through the bridge without a finite `toolsAllow` list follow the
owner session's tool policy at run time. A finite list is capped to the bridge's final
permitted tools and supported native capabilities. When Claude's native `Bash` supplies `exec`,
the saved automation retains its Gateway host target, including with an explicit
`toolsAllow: ["exec"]` cap. Current account, tool, sandbox, and approval restrictions
still apply; capturing the target does not grant broader execution permission.

View file

@ -230,8 +230,8 @@ failures, and connector refresh failures fail closed.
## Scheduled app authority
Automations inherit the creator turn's callable tools and app policy without an
explicit `toolsAllow` list. With a prepared ChatGPT profile, scheduled app access
When a Codex creator turn captures scheduled app authority, an automation without an explicit
`toolsAllow` list saves that turn's callable tools and app policy. With a prepared ChatGPT profile, scheduled app access
remains bound to that exact profile and account. Without a prepared profile, an
agent-scoped configured WebSocket app-server owns the schedule through its
connection fingerprint. Reauthenticating that same endpoint to another account

View file

@ -218,11 +218,7 @@ suite.define(() => {
name: automationName,
owner: { sessionKey: expect.stringContaining(":telegram:") },
scheduledToolPolicy: { mode: "account" },
payload: {
kind: "agentTurn",
toolsAllow: expect.arrayContaining(["automations"]),
toolsAllowIsDefault: true,
},
payload: { kind: "agentTurn", toolsAllow: ["*"] },
});
if (!isRecord(created.payload)) {
throw new Error("Created automation has no payload");

View file

@ -16,12 +16,7 @@ type Job = {
id: string;
name: string;
schedule: { kind: string; at?: string; everyMs?: number };
payload: {
kind: string;
message: string;
toolsAllow?: string[];
toolsAllowIsDefault?: boolean;
};
payload: { kind: string; message: string; toolsAllow?: string[] };
sessionTarget: string;
delivery: { mode: string };
enabled: boolean;
@ -66,7 +61,7 @@ function job(name: string, index: number): Job {
id: `job-${index}`,
name,
schedule: { kind: "every", everyMs: 3_600_000 },
payload: { kind: "agentTurn", message: "", toolsAllow: ["automations", "read"] },
payload: { kind: "agentTurn", message: "", toolsAllow: ["*"] },
sessionTarget: "isolated",
delivery: { mode: "none" },
enabled: false,
@ -96,7 +91,6 @@ async function runSchedulingFixture(
entry.payload.message = `${policy} authority`;
if (index < 2) {
entry.schedule = { kind: "at", at: new Date(now + 86_400_000).toISOString() };
entry.payload.toolsAllowIsDefault = true;
} else {
entry.payload.toolsAllow = index === 2 ? ["read"] : [];
}
@ -110,12 +104,10 @@ async function runSchedulingFixture(
? { kind: "at", at: new Date(now + 240_000).toISOString() }
: { kind: "every", everyMs: 20_000 };
entry.deleteAfterRun = index === 0;
entry.payload.toolsAllowIsDefault = true;
entry.payload.message = `Reply exactly QA-MODEL-${schedule.toUpperCase()}-PAYLOAD-${suffix}`;
return entry;
});
const calls: ToolCall[] = structuredClone(jobs).map((entry, index) => {
delete entry.payload.toolsAllowIsDefault;
if (kind === "recurring" || index === 0) {
delete entry.payload.toolsAllow;
} else {
@ -378,25 +370,21 @@ describe("scheduling YAML canonical tool proof", () => {
});
it.each([
["omitted", 0, ["automations", "read", "exec"], true, /omitted policy/],
["wildcard", 1, ["automations", "read"], false, /wildcard policy/],
["overbroad", 2, ["read", "exec"], false, /overbroad policy/],
["empty", 3, ["read"], false, /empty policy/],
] as const)(
"rejects incorrect persisted %s authority",
async (_label, index, tools, marker, message) => {
await expect(
runSchedulingFixture("authority", "nested", {
mutateJobs: (jobs) => {
const target = jobs[index];
assert.ok(target);
target.payload.toolsAllow = [...tools];
target.payload.toolsAllowIsDefault = marker;
},
}),
).rejects.toThrow(message);
},
);
["omitted", 0, ["automations", "read"], /omitted policy/],
["wildcard", 1, ["automations", "read"], /wildcard policy/],
["overbroad", 2, ["read", "exec"], /overbroad policy/],
["empty", 3, ["read"], /empty policy/],
] as const)("rejects incorrect persisted %s authority", async (_label, index, tools, message) => {
await expect(
runSchedulingFixture("authority", "nested", {
mutateJobs: (jobs) => {
const target = jobs[index];
assert.ok(target);
target.payload.toolsAllow = [...tools];
},
}),
).rejects.toThrow(message);
});
it.each(["authority", "recurring"] as const)("rejects extra %s add calls", async (kind) => {
await expect(
@ -453,7 +441,7 @@ describe("scheduling YAML canonical tool proof", () => {
runSchedulingFixture(kind, "nested", {
mutateRestartedJobs: (jobs) => {
assert.ok(jobs[0]);
jobs[0].payload.toolsAllow = ["*"];
jobs[0].payload.toolsAllow = ["read"];
},
}),
).rejects.toThrow(/authority changed across restart/);

View file

@ -23,10 +23,10 @@ scenario:
allow:
- read
- automations
objective: Verify a sender-restricted live model persists exact creator-scoped authority for new cron jobs without widening requested tools.
objective: Verify a sender-restricted live model persists wildcard inheritance for omitted or wildcard policies and creator-scoped finite caps without widening requested tools.
successCriteria:
- The model creates omitted, wildcard, overbroad, and empty policy jobs through the automations tool.
- Omitted and wildcard policies become the concrete creator surface with the default marker.
- Omitted and wildcard policies persist exactly ["*"] so runs inherit the owner session policy.
- The overbroad policy is intersected with the creator surface and the empty policy remains empty.
- Every stored policy survives a Gateway restart unchanged.
docsRefs:
@ -278,23 +278,20 @@ flow:
expr: "omittedJob.payload.message === 'omitted authority' && wildcardJob.payload.message === 'wildcard authority' && overbroadJob.payload.message === 'overbroad authority' && emptyJob.payload.message === 'empty authority'"
message:
expr: "`authority matrix changed payloads: ${JSON.stringify(jobs.map((job) => job.payload))}`"
- set: expectedCreatorTools
value:
expr: "['automations', 'read'].sort()"
- assert:
expr: "JSON.stringify([...(omittedJob.payload.toolsAllow ?? [])].sort()) === JSON.stringify(expectedCreatorTools) && omittedJob.payload.toolsAllowIsDefault === true"
expr: "JSON.stringify(omittedJob.payload.toolsAllow) === JSON.stringify(['*'])"
message:
expr: "`omitted policy did not capture creator authority: ${JSON.stringify(omittedJob?.payload)}`"
expr: "`omitted policy did not persist the wildcard cap: ${JSON.stringify(omittedJob?.payload)}`"
- assert:
expr: "JSON.stringify([...(wildcardJob.payload.toolsAllow ?? [])].sort()) === JSON.stringify(expectedCreatorTools) && wildcardJob.payload.toolsAllowIsDefault === true"
expr: "JSON.stringify(wildcardJob.payload.toolsAllow) === JSON.stringify(['*'])"
message:
expr: "`wildcard policy did not capture creator authority: ${JSON.stringify(wildcardJob?.payload)}`"
expr: "`wildcard policy did not persist the wildcard cap: ${JSON.stringify(wildcardJob?.payload)}`"
- assert:
expr: "JSON.stringify(overbroadJob.payload.toolsAllow) === JSON.stringify(['read']) && overbroadJob.payload.toolsAllowIsDefault !== true"
expr: "JSON.stringify(overbroadJob.payload.toolsAllow) === JSON.stringify(['read'])"
message:
expr: "`overbroad policy was not intersected: ${JSON.stringify(overbroadJob?.payload)}`"
- assert:
expr: "Array.isArray(emptyJob.payload.toolsAllow) && emptyJob.payload.toolsAllow.length === 0 && emptyJob.payload.toolsAllowIsDefault !== true"
expr: "Array.isArray(emptyJob.payload.toolsAllow) && emptyJob.payload.toolsAllow.length === 0"
message:
expr: "`empty policy changed: ${JSON.stringify(emptyJob?.payload)}`"
detailsExpr: "`runtime=${env.primaryModel}; calls=${cronToolCalls.length}; omitted=${JSON.stringify(omittedJob.payload.toolsAllow)}; wildcard=${JSON.stringify(wildcardJob.payload.toolsAllow)}; overbroad=${JSON.stringify(overbroadJob.payload.toolsAllow)}; empty=${JSON.stringify(emptyJob.payload.toolsAllow)}`"
@ -328,7 +325,7 @@ flow:
message:
expr: "`authority jobs missing after restart: ${JSON.stringify(restartedPage.jobs.map((job) => job.name))}`"
- assert:
expr: "restartedJobs.every((job) => { const before = jobs.find((candidate) => candidate.id === job.id); return before && JSON.stringify([...(job.payload.toolsAllow ?? [])].sort()) === JSON.stringify([...(before.payload.toolsAllow ?? [])].sort()) && job.payload.toolsAllowIsDefault === before.payload.toolsAllowIsDefault; })"
expr: "restartedJobs.every((job) => { const before = jobs.find((candidate) => candidate.id === job.id); return before && JSON.stringify(job.payload.toolsAllow) === JSON.stringify(before.payload.toolsAllow); })"
message:
expr: "`authority changed across restart: before=${JSON.stringify(jobs.map((job) => job.payload))} after=${JSON.stringify(restartedJobs.map((job) => job.payload))}`"
- call: env.gateway.call

View file

@ -18,7 +18,7 @@ scenario:
objective: Verify a live model can author one-off and recurring cron jobs whose natural scheduler behavior matches their requested semantics.
successCriteria:
- The model creates exactly one `at` job and one `every` job through the automations tool.
- Both jobs persist the concrete creator tool surface as explicit default authority.
- Both jobs persist exactly ["*"] so runs inherit the owner session policy.
- The one-off job fires naturally once and is deleted after success.
- The recurring job fires naturally at least twice, remains enabled, and advances its next run.
- Stored jobs use isolated agent turns with no delivery side effects.
@ -169,10 +169,10 @@ flow:
message:
expr: "`model-created job payloads changed: ${JSON.stringify(jobs.map((job) => job.payload))}`"
- assert:
expr: "jobs.every((job) => Array.isArray(job.payload.toolsAllow) && job.payload.toolsAllow.length > 0 && job.payload.toolsAllow.includes('automations') && !job.payload.toolsAllow.includes('*') && job.payload.toolsAllowIsDefault === true)"
expr: "jobs.every((job) => JSON.stringify(job.payload.toolsAllow) === JSON.stringify(['*']))"
message:
expr: "`model-created jobs did not persist concrete default authority: ${JSON.stringify(jobs.map((job) => job.payload))}`"
detailsExpr: "`author=${authorReply.text}; cron-tool-calls=${cronToolCalls.length}; at=${oneShotJob.id}:${oneShotJob.name}:${oneShotJob.schedule.kind}:${oneShotJob.schedule.at}:${oneShotJob.deleteAfterRun}; every=${recurringJob.id}:${recurringJob.name}:${recurringJob.schedule.kind}:${recurringJob.schedule.everyMs}; targets=${jobs.map((job) => job.sessionTarget).join(',')}; delivery=${jobs.map((job) => job.delivery?.mode).join(',')}; authority-counts=${jobs.map((job) => job.payload.toolsAllow.length).join(',')}`"
expr: "`model-created jobs did not persist the wildcard cap: ${JSON.stringify(jobs.map((job) => job.payload))}`"
detailsExpr: "`author=${authorReply.text}; cron-tool-calls=${cronToolCalls.length}; at=${oneShotJob.id}:${oneShotJob.name}:${oneShotJob.schedule.kind}:${oneShotJob.schedule.at}:${oneShotJob.deleteAfterRun}; every=${recurringJob.id}:${recurringJob.name}:${recurringJob.schedule.kind}:${recurringJob.schedule.everyMs}; targets=${jobs.map((job) => job.sessionTarget).join(',')}; delivery=${jobs.map((job) => job.delivery?.mode).join(',')}; authority=${jobs.map((job) => JSON.stringify(job.payload.toolsAllow)).join(';')}`"
- name: scheduler fires one-off once and recurring repeatedly
actions:
@ -276,7 +276,7 @@ flow:
message:
expr: "`recurring next run did not advance from ${recurringJob.state.nextRunAtMs}: ${JSON.stringify(finalRecurring)}`"
- assert:
expr: "JSON.stringify([...(finalRecurring.payload.toolsAllow ?? [])].sort()) === JSON.stringify([...(recurringJob.payload.toolsAllow ?? [])].sort()) && finalRecurring.payload.toolsAllowIsDefault === true"
expr: "JSON.stringify(finalRecurring.payload.toolsAllow) === JSON.stringify(recurringJob.payload.toolsAllow)"
message:
expr: "`recurring authority changed across restart: before=${JSON.stringify(recurringJob.payload)} after=${JSON.stringify(finalRecurring?.payload)}`"
- set: finalOwnedJobs

View file

@ -4947,7 +4947,6 @@ const PR_EXEMPT_RUNTIME_TEST_FILES = [
"src/commands/doctor-skill-workshop-sqlite.relocation-conflicts.test.ts",
"src/commands/doctor-skill-workshop-sqlite.relocation.test.ts",
"src/commands/doctor-state-integrity.transcripts.test.ts",
"src/commands/doctor/cron/native-tool-advisory.test.ts",
"src/commands/onboard-agent.persistence.test.ts",
"src/commands/onboard-config-provenance.integration.test.ts",
"src/commands/onboard-interactive.test.ts",

View file

@ -170,18 +170,3 @@ export function resolveSessionMcpConfigSummary(params: {
});
return { fingerprint, serverNames };
}
/** Reads the enabled static MCP server set without opening transports or listing tools. */
export function resolveStaticSessionMcpServerNames(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): string[] {
const { loaded } = loadSessionMcpConfig({
...params,
logDiagnostics: false,
});
const { staticServers } = partitionMcpServersByConnectionScope(loaded.mcpServers);
return Object.keys(staticServers).toSorted((left, right) => left.localeCompare(right));
}

View file

@ -394,6 +394,7 @@ function bindCronCreatorAuthorityResolver(params: {
tools: snapshot.tools,
provenance: snapshot.provenance,
grant: mintCronCreatorAuthorityGrant(authority, operationSignal, snapshot.runtimeAuthority),
...(snapshot.runtimeAuthority ? { holdsRuntimeAuthority: true as const } : {}),
});
};
}

View file

@ -44,25 +44,27 @@ function readReadyPatch(plan: CronJobUpdatePatchPlan): Record<string, unknown> {
}
describe("cron tool creator cap", () => {
it("caps trigger-script creates without changing transport-only jobs", () => {
it("lets default agent turns follow their owner while scripts and Codex apps keep the creator's tools", () => {
const triggerJob = {
trigger: { script: "return true" },
payload: { kind: "systemEvent", text: "wake" },
};
const agentJob = { payload: { kind: "agentTurn", message: "work" } };
const codexAppJob = { payload: { kind: "agentTurn", message: "work" } };
const plainJob = {
payload: { kind: "systemEvent", text: "wake" },
};
capCronJobToolsAllowOnCreate(triggerJob, ["read", "cron"]);
capCronJobToolsAllowOnCreate(agentJob, ["read", "cron"]);
capCronJobToolsAllowOnCreate(codexAppJob, ["read", "cron"], true);
capCronJobToolsAllowOnCreate(plainJob, ["read", "cron"]);
// Legacy "cron" creator allowlists normalize to the canonical tool id.
expect(triggerJob.payload).toEqual({
kind: "systemEvent",
text: "wake",
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
});
const creatorSnapshot = { toolsAllow: ["read", "automations"], toolsAllowIsDefault: true };
expect(triggerJob.payload).toEqual({ kind: "systemEvent", text: "wake", ...creatorSnapshot });
expect(agentJob.payload).toEqual({ kind: "agentTurn", message: "work", toolsAllow: ["*"] });
expect(codexAppJob.payload).toEqual({ kind: "agentTurn", message: "work", ...creatorSnapshot });
expect(plainJob.payload).toEqual({ kind: "systemEvent", text: "wake" });
});

View file

@ -32,7 +32,7 @@ type CronJobUpdatePatchPlan =
* Anything else is a backend contract bug and fails closed at capture time so a
* raw harness tool name can never become a persisted cron capability.
*/
export const NATIVE_CRON_CREATOR_CAPABILITIES: ReadonlySet<string> = new Set([
const NATIVE_CRON_CREATOR_CAPABILITIES: ReadonlySet<string> = new Set([
"read",
"write",
"edit",
@ -272,6 +272,8 @@ function capCronJobToolsAllow(params: {
trigger?: unknown;
creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[];
defaultToolsAllow?: unknown;
/** Codex app authority is captured against the concrete list, so its jobs keep that list. */
creatorHoldsRuntimeAuthority?: boolean;
}): void {
const writesToolsAllow = Object.hasOwn(params.payload, "toolsAllow");
if (
@ -284,20 +286,25 @@ function capCronJobToolsAllow(params: {
}
const creatorToolsAllow = normalizeCronCreatorToolsAllow(params.creatorToolAllowlist);
const creatorToolNames = creatorToolsAllow.map((tool) => tool.name);
const requestedRaw = writesToolsAllow ? params.payload.toolsAllow : params.defaultToolsAllow;
if (!Array.isArray(requestedRaw)) {
params.payload.toolsAllow = creatorToolNames;
params.payload.toolsAllowIsDefault = true;
return;
}
const requestedToolsAllow = expandToolGroups(
requestedRaw.filter((entry): entry is string => typeof entry === "string"),
);
const requestedToolsAllow = Array.isArray(requestedRaw)
? expandToolGroups(requestedRaw.filter((entry): entry is string => typeof entry === "string"))
: ["*"];
if (requestedToolsAllow.includes("*")) {
params.payload.toolsAllow = creatorToolNames;
params.payload.toolsAllowIsDefault = true;
// A default agent turn gets what its owner conversation gets, like operator jobs.
// Scripts reach MCP only through servers their list names, and Codex app
// authority is bound to the captured list, so those keep the creator's tools.
if (
params.payload.kind === "agentTurn" &&
!hasCronTriggerScript(params.trigger) &&
!params.creatorHoldsRuntimeAuthority
) {
params.payload.toolsAllow = ["*"];
delete params.payload.toolsAllowIsDefault;
} else {
params.payload.toolsAllow = creatorToolsAllow.map((tool) => tool.name);
params.payload.toolsAllowIsDefault = true;
}
return;
}
if (requestedToolsAllow.length === 0 || creatorToolsAllow.length === 0) {
@ -328,6 +335,7 @@ function capCronJobToolsAllow(params: {
export function capCronJobToolsAllowOnCreate(
value: unknown,
creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined,
creatorHoldsRuntimeAuthority?: boolean,
): void {
if (!isRecord(value) || !isRecord(value.payload) || !creatorToolAllowlist) {
return;
@ -336,6 +344,7 @@ export function capCronJobToolsAllowOnCreate(
payload: value.payload,
trigger: value.trigger,
creatorToolAllowlist,
creatorHoldsRuntimeAuthority,
});
}
@ -349,6 +358,7 @@ export function planCronJobUpdatePatch(params: {
creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined;
currentJob?: Record<string, unknown>;
creatorAuthorityComplete?: boolean;
creatorHoldsRuntimeAuthority?: boolean;
}): CronJobUpdatePatchPlan {
const patch = structuredClone(params.patch);
const payload = isRecord(patch.payload) ? patch.payload : undefined;
@ -449,6 +459,7 @@ export function planCronJobUpdatePatch(params: {
existingPayloadRecord && existingPayloadRecord.toolsAllowIsDefault !== true
? existingPayloadRecord.toolsAllow
: undefined,
creatorHoldsRuntimeAuthority: params.creatorHoldsRuntimeAuthority,
});
return { kind: "ready", patch };
}

View file

@ -111,6 +111,7 @@ async function prepareCronJobUpdateForGateway(
creatorToolAllowlist: resolvedAuthority.tools,
currentJob: existingRecord,
creatorAuthorityComplete: true,
creatorHoldsRuntimeAuthority: resolvedAuthority.holdsRuntimeAuthority,
});
}
if (finalPlan.kind !== "ready") {

View file

@ -795,7 +795,7 @@ describe("cron tool", () => {
expect(materializations).toBe(2);
expect(callGatewayMock).toHaveBeenCalledOnce();
expect(readGatewayCall().params).toMatchObject({
payload: { toolsAllow: ["read", "configured__lookup"], toolsAllowIsDefault: true },
payload: { toolsAllow: ["*"] },
});
});
@ -1485,7 +1485,7 @@ describe("cron tool", () => {
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
it("keeps the creator tool surface when an agentTurn update clears toolsAllow", async () => {
it("restores the wildcard cap when an agentTurn update clears toolsAllow", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-8",
@ -1512,8 +1512,7 @@ describe("cron tool", () => {
const params = readGatewayCall(1).params;
expect(params).toHaveProperty("patch.payload", {
kind: "agentTurn",
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
toolsAllow: ["*"],
});
});
@ -1634,8 +1633,7 @@ describe("cron tool", () => {
patch: {
payload: {
kind: "agentTurn",
toolsAllow: ["read", "configured__lookup"],
toolsAllowIsDefault: true,
toolsAllow: ["*"],
},
},
});
@ -1644,8 +1642,7 @@ describe("cron tool", () => {
patch: {
payload: {
kind: "agentTurn",
toolsAllow: ["read", "configured__lookup"],
toolsAllowIsDefault: true,
toolsAllow: ["*"],
},
},
});
@ -1737,7 +1734,7 @@ describe("cron tool", () => {
expect(callGatewayMock).toHaveBeenCalledTimes(1);
});
it("adds the creator tool surface when converting an existing job to agentTurn", async () => {
it("adds a wildcard cap when converting an existing job to agentTurn", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-12",
@ -1771,8 +1768,7 @@ describe("cron tool", () => {
payload: {
kind: "agentTurn",
message: "run later",
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
toolsAllow: ["*"],
},
},
},

View file

@ -488,7 +488,11 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any
const creatorToolAllowlistCaptureRef = resolvedAuthority
? { value: resolvedAuthority.provenance }
: opts?.creatorToolAllowlistCaptureRef;
capCronJobToolsAllowOnCreate(job, creatorToolAllowlist);
capCronJobToolsAllowOnCreate(
job,
creatorToolAllowlist,
resolvedAuthority?.holdsRuntimeAuthority,
);
assertInheritedCronToolCaptureReady(job, creatorToolAllowlistCaptureRef);
const { mainKey, alias } = resolveMainSessionAlias(runtimeConfig);
const resolvedSessionKey = opts?.agentSessionKey

View file

@ -39,6 +39,8 @@ export type CronCreatorToolAuthoritySnapshot = Omit<
> & {
/** Gateway-process one-shot proof consumed only at the matching cron write. */
grant: CronCreatorAuthorityGrant;
/** The creator captured runtime app authority (Codex apps); its default list stays concrete. */
holdsRuntimeAuthority?: true;
};
export type CronToolOptions = {

View file

@ -235,7 +235,6 @@ describe("maybeRepairLegacyCronStore", () => {
kind: "agentTurn",
message: "scheduled continuation",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
scheduledToolPolicy: {
version: 1,
@ -423,46 +422,6 @@ describe("maybeRepairLegacyCronStore", () => {
).toEqual(unsupportedScripts);
});
it("keeps shared-workspace legacy MCP warnings scoped to each job agent", async () => {
const sharedWorkspace = path.join(path.dirname(storePath), "shared-workspace");
await writeCurrentCronStore(
["research", "support", undefined].map((agentId, index) =>
createCurrentCronJob({
id: `job-${index}`,
name: agentId ?? "Ambient",
agentId,
payload: {
kind: "agentTurn",
message: "run",
toolsAllow: ["read"],
toolsAllowIsDefault: true,
},
}),
),
);
const cfg = createCronConfig();
cfg.agents = {
ownership: "explicit",
defaults: { systemAgent: { agentId: "research" } },
entries: {
research: { workspace: sharedWorkspace },
support: { workspace: sharedWorkspace },
},
};
cfg.mcp = {
servers: {
notes: { transport: "stdio", command: "notes-mcp", codex: { agents: ["research"] } },
},
};
await maybeRepairLegacyCronStore({ cfg, options: {}, prompter: makePrompter(true) });
const advisory = noteMock.mock.calls.find(([message]) =>
message.includes("inherited default tool cap"),
)?.[0];
expect(advisory).toContain("research");
expect(advisory).toContain("Ambient");
expect(advisory).not.toContain("support");
});
it("recovers a valid quarantined schedule only after Doctor confirmation", async () => {
vi.stubEnv("OPENCLAW_STATE_DIR", path.dirname(path.dirname(storePath)));
await writeCurrentCronStore([]);

View file

@ -1,12 +1,6 @@
// Doctor cron repair orchestration for legacy stores, run logs, payloads, and warnings.
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { note } from "../../../../packages/terminal-core/src/note.js";
import { resolveStaticSessionMcpServerNames } from "../../../agents/agent-bundle-mcp-runtime-config.js";
import {
resolveAgentWorkspaceDir,
tryResolveAmbientOwnerAgentId,
} from "../../../agents/agent-scope.js";
import { resolveCodexMcpToolOverridesForAgent } from "../../../agents/cli-runner/bundle-mcp-codex.js";
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { loadCronQuarantinedJobs, resolveCronJobsStorePath } from "../../../cron/store.js";
@ -22,10 +16,8 @@ import {
type LegacyCronRepairResult,
type LegacyCronRepairState,
} from "./legacy-repair.js";
import { collectCronNativeToolAdvisories } from "./native-tool-advisory.js";
import {
formatLegacyIssuePreview,
formatIncompleteInheritedAuthorityAdvisory,
formatLegacyGatewayExecAdvisory,
formatScheduledToolPolicyAdvisory,
formatUnresolvedCommandPromptAdvisory,
@ -234,17 +226,6 @@ export async function collectLegacyCronStoreHealthFindings(params: {
return findings;
}
for (const message of collectCronNativeToolAdvisories({ cfg: params.cfg, jobs: rawJobs })) {
findings.push(
legacyCronStoreFinding({
message,
path: sqliteStorePath,
requirement: "cron-native-tool-cap-review",
fixHint:
"Review the job's tools from an authorized session; Doctor will not add native tools.",
}),
);
}
const normalized = normalizeStoredCronJobs(rawJobs);
for (const line of formatLegacyIssuePreview(normalized.issues)) {
findings.push(
@ -458,9 +439,6 @@ export async function maybeRepairLegacyCronStore(params: {
}
noteCronModelOverrides({ cfg: params.cfg, jobs: rawJobs });
noteCronDeliveryTargetAdvisory({ cfg: params.cfg, jobs: rawJobs });
for (const message of collectCronNativeToolAdvisories({ cfg: params.cfg, jobs: rawJobs })) {
note(message, "Cron");
}
const inFlightCount = countInFlightCronJobs(rawJobs);
if (inFlightCount > 0) {
@ -528,55 +506,6 @@ export async function maybeRepairLegacyCronStore(params: {
note(advisory, "Cron");
}
}
const staticMcpByAgentWorkspace = new Map<string, boolean>();
const incompleteInheritedAuthorityAdvisory = formatIncompleteInheritedAuthorityAdvisory(
rawJobs
.filter((job) => {
const payload = isRecord(job.payload) ? job.payload : undefined;
const provenance = isRecord(job.toolsAllowProvenance)
? job.toolsAllowProvenance
: undefined;
if (
payload?.toolsAllowIsDefault !== true ||
(provenance?.version === 1 && provenance.source === "final-executable-surface")
) {
return false;
}
const agentId =
typeof job.agentId === "string" && job.agentId.trim()
? job.agentId.trim()
: tryResolveAmbientOwnerAgentId(params.cfg);
if (!agentId) {
return false;
}
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
const cacheKey = `${agentId}\0${workspaceDir}`;
let hasStaticMcp = staticMcpByAgentWorkspace.get(cacheKey);
if (hasStaticMcp === undefined) {
hasStaticMcp =
resolveStaticSessionMcpServerNames({
workspaceDir,
cfg: params.cfg,
toolOverrides: resolveCodexMcpToolOverridesForAgent(params.cfg, {
agentId,
toolOverrides: undefined,
}),
}).length > 0;
staticMcpByAgentWorkspace.set(cacheKey, hasStaticMcp);
}
return hasStaticMcp;
})
.map((job) =>
typeof job.name === "string" && job.name.trim()
? job.name.trim()
: typeof job.id === "string"
? job.id
: "unknown automation",
),
);
if (incompleteInheritedAuthorityAdvisory) {
note(incompleteInheritedAuthorityAdvisory, "Cron");
}
const previewLines = formatLegacyIssuePreview(normalized.issues);
if (normalized.legacyTriggerScriptJobs.length > 0) {
previewLines.push(

View file

@ -1,169 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as cliBackends from "../../../agents/cli-backends.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { makeCronJob } from "../../../cron/delivery.test-helpers.js";
import {
loadCronJobsStoreWithConfigJobsReadOnly,
resolveCronJobsStorePath,
saveCronJobsStore,
} from "../../../cron/store.js";
import { resolveDoctorContributionHealthChecks } from "../../../flows/doctor-health-contributions.js";
import {
createDoctorHealthFlowContext,
resolveDoctorHealthContributions,
runDoctorHealthContributionList,
} from "../../../flows/doctor-health-contributions.test-support.js";
import { runDoctorLintChecks } from "../../../flows/doctor-lint-flow.js";
import * as processExec from "../../../process/exec.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../../../test-utils/openclaw-test-state.js";
import { createDoctorPrompter } from "../../doctor-prompter.js";
const { note } = vi.hoisted(() => ({ note: vi.fn() }));
vi.mock("../../../../packages/terminal-core/src/note.js", () => ({ note }));
let state: OpenClawTestState;
const cfg: OpenClawConfig = {
agents: {
entries: { main: { model: "native-cli/example" } },
},
};
const projectNativeToolAuthority = vi.fn(() => ["read", "exec"]);
beforeEach(async () => {
state = await createOpenClawTestState({ label: "doctor-native-cap" });
vi.spyOn(processExec, "runExec").mockResolvedValue({ stdout: "", stderr: "" });
vi.spyOn(cliBackends, "resolveCliBackendConfig").mockImplementation((provider) => ({
id: provider,
config: { command: "fixture-cli" },
bundleMcp: true,
...(provider === "native-cli" ? { projectNativeToolAuthority } : {}),
}));
});
afterEach(async () => {
vi.restoreAllMocks();
note.mockClear();
projectNativeToolAuthority.mockClear();
await state.cleanup();
});
async function runRegisteredCronCheck(config: OpenClawConfig) {
const context = createDoctorHealthFlowContext({ cfg: config, configPath: state.configPath });
const result = await runDoctorLintChecks(
{ mode: "lint", runtime: context.runtime, cfg: config, configPath: state.configPath },
{
checks: await resolveDoctorContributionHealthChecks(),
onlyIds: ["core/doctor/legacy-cron-store"],
includeAllChecks: true,
},
);
expect(result.checksRun).toBe(1);
return result.findings;
}
describe("Doctor cron native-tool advisory", () => {
it.each([false, true])(
"doctor:legacy-cron dispatch reports incomplete defaults without rewriting tools (repair=%s)",
async (repair) => {
const jobs = [
{ id: "old-default", toolsAllow: ["message"], toolsAllowIsDefault: true },
{ id: "empty-default", toolsAllow: [], toolsAllowIsDefault: true },
{ id: "explicit", toolsAllow: ["message"], toolsAllowIsDefault: false },
{ id: "unmarked", toolsAllow: ["message"] },
{ id: "native-read", toolsAllow: ["read"], toolsAllowIsDefault: true },
{ id: "native-search", toolsAllow: ["web_search"], toolsAllowIsDefault: true },
{
id: "no-native-backend",
model: "other-cli/example",
toolsAllow: ["message"],
toolsAllowIsDefault: true,
},
].map(({ id, ...payload }) =>
makeCronJob({
id,
name: id,
agentId: "main",
enabled: false,
payload: { kind: "agentTurn", message: "Check the local report.", ...payload },
}),
);
const storePath = resolveCronJobsStorePath();
await saveCronJobsStore(storePath, { version: 1, jobs });
const before = (await loadCronJobsStoreWithConfigJobsReadOnly(storePath)).store.jobs;
const context = createDoctorHealthFlowContext({
cfg,
configPath: state.configPath,
options: { repair, nonInteractive: true },
});
context.prompter = createDoctorPrompter({
runtime: context.runtime,
options: context.options,
});
const contributions = resolveDoctorHealthContributions().filter(
(contribution) => contribution.id === "doctor:legacy-cron",
);
expect(contributions).toHaveLength(1);
await runDoctorHealthContributionList(context, contributions);
const advisories = note.mock.calls
.map(([message]) => String(message))
.filter((message) => message.includes("no native file, command, or web tools"));
expect(advisories).toHaveLength(2);
expect(advisories[0]).toContain('Automation "old-default"');
expect(advisories[1]).toContain('Automation "empty-default"');
expect(advisories[0]).toContain('openclaw cron edit <id> --tools "<complete list>" --json');
expect(advisories[0]).toContain("deliberately restricted jobs can be left as is");
expect(advisories[0]).toContain("Doctor --fix does not add missing native tools");
expect(projectNativeToolAuthority).not.toHaveBeenCalled();
const after = (await loadCronJobsStoreWithConfigJobsReadOnly(storePath)).store.jobs;
expect(after.map((job) => job.payload)).toEqual(before.map((job) => job.payload));
const findings = await runRegisteredCronCheck(cfg);
const nativeFindings = findings.filter(
(finding) => finding.requirement === "cron-native-tool-cap-review",
);
expect(nativeFindings.map((finding) => finding.message).toSorted()).toEqual(
advisories.toSorted(),
);
expect(nativeFindings.every((finding) => finding.fixHint?.includes("will not add"))).toBe(
true,
);
},
);
it("core/doctor/legacy-cron-store dispatch uses the owning agent's model alias", async () => {
const storePath = resolveCronJobsStorePath();
await saveCronJobsStore(storePath, {
version: 1,
jobs: [
makeCronJob({
agentId: "research",
payload: {
kind: "agentTurn",
message: "Check the report.",
model: "reviewer",
toolsAllow: ["message"],
toolsAllowIsDefault: true,
},
}),
],
});
const findings = await runRegisteredCronCheck({
agents: {
entries: {
main: { model: "other-cli/example" },
research: {
model: "other-cli/example",
models: { "native-cli/example": { alias: "reviewer" } },
},
},
},
});
expect(
findings.filter((finding) => finding.requirement === "cron-native-tool-cap-review"),
).toHaveLength(1);
});
});

View file

@ -1,90 +0,0 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { tryResolveAmbientOwnerAgentId } from "../../../agents/agent-scope-config.js";
import { resolveCliBackendConfig } from "../../../agents/cli-backends.js";
import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js";
import { resolveCliRuntimeExecutionProvider } from "../../../agents/model-runtime-aliases.js";
import {
resolveDefaultModelForAgent,
resolveSubagentConfiguredModelSelection,
} from "../../../agents/model-selection-config.js";
import {
buildModelAliasIndex,
resolveModelRefFromString,
} from "../../../agents/model-selection-shared.js";
import { NATIVE_CRON_CREATOR_CAPABILITIES } from "../../../agents/tools/cron-tool-creator-cap.js";
import { formatCliCommand } from "../../../cli/command-format.js";
import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { tryResolveCronJobEffectiveAgentId } from "../../../cron/agent-id.js";
/** An incomplete default cap is a review hint, never evidence to grant missing tools. */
export function collectCronNativeToolAdvisories(params: {
cfg: OpenClawConfig;
jobs: Array<Record<string, unknown>>;
}): string[] {
const advisories: string[] = [];
for (const job of params.jobs) {
const payload = isRecord(job.payload) ? job.payload : undefined;
if (
payload?.kind !== "agentTurn" ||
payload.toolsAllowIsDefault !== true ||
!Array.isArray(payload.toolsAllow) ||
payload.toolsAllow.some((name) => NATIVE_CRON_CREATOR_CAPABILITIES.has(name))
) {
continue;
}
const agentId = tryResolveCronJobEffectiveAgentId(
{
agentId: normalizeOptionalString(job.agentId),
sessionKey: normalizeOptionalString(job.sessionKey),
},
tryResolveAmbientOwnerAgentId(params.cfg),
);
if (!agentId) {
continue;
}
const defaults = resolveDefaultModelForAgent({ cfg: params.cfg, agentId });
const rawModel =
normalizeOptionalString(payload.model) ??
resolveSubagentConfiguredModelSelection({ cfg: params.cfg, agentId }) ??
resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model);
const model = rawModel
? resolveModelRefFromString({
cfg: params.cfg,
agentId,
raw: rawModel,
defaultProvider: defaults.provider,
aliasIndex: buildModelAliasIndex({
cfg: params.cfg,
agentId,
defaultProvider: defaults.provider,
}),
})?.ref
: defaults;
if (!model) {
continue;
}
const backendId =
resolveCliRuntimeExecutionProvider({
cfg: params.cfg,
agentId,
provider: model.provider,
modelId: model.model,
authProfileId: rawModel ? splitTrailingAuthProfile(rawModel).profile : undefined,
}) ?? model.provider;
if (!resolveCliBackendConfig(backendId, params.cfg, { agentId })?.projectNativeToolAuthority) {
continue;
}
const name = normalizeOptionalString(job.name) ?? normalizeOptionalString(job.id);
advisories.push(
[
`Automation "${name}" has an automatically captured tool list with no native file, command, or web tools.`,
"Before the native-tool capture fix in 2026.9.x, scheduled jobs could omit these tools. A restricted creator session can produce the same list; deliberately restricted jobs can be left as is.",
`To change the list, run ${formatCliCommand('openclaw cron edit <id> --tools "<complete list>" --json')} from an authorized session that holds the tools. Include every tool the job should retain.`,
"Doctor --fix does not add missing native tools to this list.",
].join("\n"),
);
}
return advisories;
}

View file

@ -89,18 +89,6 @@ export function formatLegacyGatewayExecAdvisory(names: string[]): string | null
].join("\n");
}
/** Advisory for legacy default caps that were captured before configured MCP was final. */
export function formatIncompleteInheritedAuthorityAdvisory(names: string[]): string | null {
if (names.length === 0) {
return null;
}
return [
`${pluralize(names.length, "automation")} ${names.length === 1 ? "has" : "have"} an inherited default tool cap captured before final configured-MCP provenance was recorded${formatJobNameList(names)}.`,
"- The stored finite cap remains unchanged; doctor will not silently widen or rewrite it.",
"- If the job uses Codex configured MCP, reauthorize in place with an exact explicit list: `openclaw automations edit <id> --tools <tool,...>`.",
].join("\n");
}
export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] {
const descriptions: Record<string, string> = {
jobId: "still uses legacy `jobId`",

View file

@ -1,77 +0,0 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createCronToolsAllowPreflightDiagnostics } from "./run-delivery-trace.js";
const cfg = {
mcp: {
servers: {
notes: { transport: "stdio", command: "notes-mcp" },
},
},
} as OpenClawConfig;
const base = {
cfg,
jobId: "job-1",
provider: "openai",
model: "gpt-5.4-codex",
workspaceDir: "/workspace",
agentRuntime: "codex",
agentPayload: {
kind: "agentTurn" as const,
message: "run",
toolsAllow: ["read"],
toolsAllowIsDefault: true,
},
};
describe("configured MCP inherited-cap diagnostics", () => {
it("persists an actionable warning for legacy Codex default caps", async () => {
const diagnostics = await createCronToolsAllowPreflightDiagnostics(base);
expect(diagnostics?.entries[0]).toMatchObject({
source: "cron-preflight",
severity: "warn",
});
expect(diagnostics?.summary).toContain("openclaw automations edit job-1 --tools <tool,...>");
});
it("does not warn after final executable-surface capture", async () => {
await expect(
createCronToolsAllowPreflightDiagnostics({
...base,
toolsAllowProvenance: { version: 1, source: "final-executable-surface" },
agentPayload: {
...base.agentPayload,
toolsAllow: ["notes__read"],
},
}),
).resolves.toBeUndefined();
});
it("does not warn for a configured MCP server excluded from the run agent", async () => {
const agentScopedCfg = {
mcp: {
servers: {
notes: {
transport: "stdio",
command: "notes-mcp",
codex: { agents: ["research"] },
},
},
},
} as OpenClawConfig;
const scoped = {
...base,
cfg: agentScopedCfg,
jobId: "job-agent-scope",
};
await expect(
createCronToolsAllowPreflightDiagnostics({ ...scoped, agentId: "support" }),
).resolves.toBeUndefined();
await expect(
createCronToolsAllowPreflightDiagnostics({ ...scoped, agentId: "research" }),
).resolves.toMatchObject({ entries: [expect.objectContaining({ severity: "warn" })] });
});
});

View file

@ -1,6 +1,4 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveStaticSessionMcpServerNames } from "../../agents/agent-bundle-mcp-runtime-config.js";
import { resolveCodexMcpToolOverridesForAgent } from "../../agents/cli-runner/bundle-mcp-codex.js";
import { wrapUntrustedPromptDataBlock } from "../../agents/sanitize-for-prompt.js";
/** Delivery planning, prompt policy, and delivery trace construction for cron runs. */
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@ -16,7 +14,6 @@ import {
type CronDeliveryPlan,
} from "../delivery-plan.js";
import {
createCronRunDiagnosticsFromError,
createCronRunDiagnosticsFromMissingWebSearchProvider,
toolsAllowRequestsWebSearch,
} from "../run-diagnostics.js";
@ -28,7 +25,6 @@ import type {
CronDeliveryTraceTarget,
CronJob,
CronRunDiagnostics,
CronToolsAllowProvenance,
} from "../types.js";
import { logWarn } from "./run.runtime.js";
import { resolveCronSourceDeliveryPlan } from "./source-delivery-plan.js";
@ -185,36 +181,10 @@ export async function createCronToolsAllowPreflightDiagnostics(params: {
modelApi?: string;
agentId?: string;
agentDir?: string;
workspaceDir: string;
sessionKey?: string;
agentPayload: Extract<CronJob["payload"], { kind: "agentTurn" }> | null;
agentRuntime?: string;
toolsAllowProvenance?: CronToolsAllowProvenance;
}): Promise<CronRunDiagnostics | undefined> {
const toolsAllow = params.agentPayload?.toolsAllow;
if (params.agentPayload?.toolsAllowIsDefault === true) {
const hasEnabledStaticMcp =
resolveStaticSessionMcpServerNames({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
toolOverrides: resolveCodexMcpToolOverridesForAgent(params.cfg, {
agentId: params.agentId,
toolOverrides: undefined,
}),
}).length > 0;
if (
params.agentRuntime === "codex" &&
hasEnabledStaticMcp &&
params.toolsAllowProvenance?.source !== "final-executable-surface"
) {
return createCronRunDiagnosticsFromError(
"cron-preflight",
`This automation's inherited tool cap predates final configured-MCP capture, so it continues with its stored finite tools and may omit MCP capabilities. Reauthorize in place with an exact explicit cap: openclaw automations edit ${params.jobId} --tools <tool,...>.`,
{ severity: "warn" },
);
}
return undefined;
}
if (!toolsAllowRequestsWebSearch(toolsAllow)) {
return undefined;
}

View file

@ -409,7 +409,18 @@ export async function prepareCronRunContext(params: {
// Preserve an explicit cron timeout even when it equals the agent default;
// the embedded runner uses its presence to configure the idle watchdog.
const runTimeoutOverrideMs = resolveCronRunTimeoutOverrideMs(explicitTimeoutSeconds);
const agentPayload = input.job.payload.kind === "agentTurn" ? input.job.payload : null;
const storedAgentPayload = input.job.payload.kind === "agentTurn" ? input.job.payload : null;
// Older builds froze an automatic snapshot of the creator's tools, which could
// miss tools the creator had. Such a run gets what its owner conversation gets,
// like a `*` job; Codex app authority stays bound to the list it was captured with.
const inheritsOwnerTools =
storedAgentPayload?.toolsAllowIsDefault === true &&
!input.job.runtimeAuthority &&
!input.job.runtimeAuthorityRecoveryRequired;
const agentPayload =
storedAgentPayload && inheritsOwnerTools
? { ...storedAgentPayload, toolsAllow: ["*"], toolsAllowIsDefault: undefined }
: storedAgentPayload;
const configuredProvider = cfgWithAgentDefaults.models?.providers?.[provider];
const modelApi =
findModelInCatalog(thinkingSelection.catalog, provider, model)?.api ??
@ -423,11 +434,8 @@ export async function prepareCronRunContext(params: {
modelApi,
agentId: modelOwner.agentId,
agentDir: modelOwner.agentDir,
workspaceDir: executionWorkspaceDir,
sessionKey: agentSessionKey,
agentPayload,
agentRuntime: effectiveAgentRuntime,
toolsAllowProvenance: input.job.toolsAllowProvenance,
});
const {
deliveryPlan,

View file

@ -405,7 +405,7 @@ describe("runCronIsolatedAgentTurn delivery policy", () => {
expect(runPrompt(cliRun, true)).toContain("Message delivery destination metadata");
});
it("keeps a cron-tool default toolsAllow marker after a self-edit before CLI execution", async () => {
it("runs a self-edited automatic snapshot with the owner tools on CLI", async () => {
mockCliAnnounce();
const job = makeJob(announce, {
toolsAllow: ["read", "cron"],
@ -419,13 +419,9 @@ describe("runCronIsolatedAgentTurn delivery policy", () => {
},
});
await runCronIsolatedAgentTurn(makeParams(job));
const cliRun = expectFields(
mockCall(runCliAgentMock)[0],
{
toolsAllow: ["read", "cron"],
},
"CLI run params",
);
// The automatic snapshot runs with the owner conversation's tools: no CLI cap.
const cliRun = expectFields(mockCall(runCliAgentMock)[0], {}, "CLI run params");
expect(cliRun.toolsAllow).toBeUndefined();
expect(runPrompt(cliRun)).not.toContain("Message delivery destination metadata");
expect(cliRun.transcriptPrompt).toBeUndefined();
});

View file

@ -92,6 +92,14 @@ describe("runCronIsolatedAgentTurn toolsAllow", () => {
});
});
it("runs an automatic creator snapshot with its owner's tools", options, async () => {
// Older builds saved this snapshot without the creator's native shell.
await runCronIsolatedAgentTurn(makeParams(["message", "read"], { toolsAllowIsDefault: true }));
const call = runEmbeddedAgentMock.mock.calls[0]?.[0];
expect(call.toolsAllow).toEqual(["*"]);
expect(call.scheduledToolPolicy).toMatchObject(policy);
});
it.each([
["unavailable shell tools", ["terminal", "node_exec", "node_process"]],
["a blank entry", [" "]],

View file

@ -140,7 +140,7 @@ export function restoreCronPinnedExecGrant(params: {
return undefined;
}
const requirement = resolveMatchingCronExecTarget(params);
if (!requirement || params.toolsAllow.includes("exec")) {
if (!requirement || params.toolsAllow.includes("exec") || params.toolsAllow.includes("*")) {
return [...params.toolsAllow];
}
const restored = [...params.toolsAllow];

View file

@ -39,6 +39,22 @@ describe("reconcileToolsAllowAuthority exec pin", () => {
});
});
it("keeps the creator's exec pin on a wildcard cap", () => {
const job = toolJob(["*"]);
reconcileToolsAllowAuthority({
job,
previouslyUsedToolRuntime: true,
explicitlyMutatesToolsAllow: true,
toolsAllowExecTarget: { version: 1, host: "gateway", ask: "always" },
});
expect(job.toolsAllowExecTarget).toEqual({ version: 1, host: "gateway", ask: "always" });
expect(job.toolsAllowExecTargetRequirement).toEqual({
version: 1,
target: { version: 1, host: "gateway", ask: "always" },
grantIndex: 0,
});
});
it("never stamps a pin onto a cap that does not grant exec", () => {
const job = toolJob(["read"]);
reconcileToolsAllowAuthority({

View file

@ -288,14 +288,16 @@ function reconcileToolsAllowExecTarget(params: {
if (!params.explicitlyMutatesToolsAllow) {
return;
}
const grantsExec =
Array.isArray(job.payload.toolsAllow) && job.payload.toolsAllow.includes("exec");
if (params.toolsAllowExecTarget && grantsExec) {
const toolsAllow = job.payload.toolsAllow;
const execIndex = toolsAllow.indexOf("exec");
// A wildcard cap carries the creator's exec pin like an explicit exec grant.
const grantIndex = execIndex === -1 && toolsAllow.includes("*") ? 0 : execIndex;
if (params.toolsAllowExecTarget && grantIndex !== -1) {
job.toolsAllowExecTarget = structuredClone(params.toolsAllowExecTarget);
job.toolsAllowExecTargetRequirement = {
version: 1,
target: structuredClone(params.toolsAllowExecTarget),
grantIndex: job.payload.toolsAllow.indexOf("exec"),
grantIndex,
} satisfies CronToolsAllowExecTargetRequirement;
} else {
delete job.toolsAllowExecTarget;

View file

@ -87,7 +87,7 @@ describe("original caller through Cron creator transports", () => {
ownerSessionKey: SESSION,
ownerAccountId: "default",
},
payload: { toolsAllow: [AUTOMATIONS_TOOL_NAME], timeoutSeconds: 0 },
payload: { toolsAllow: ["*"], timeoutSeconds: 0 },
},
]);
} finally {
@ -212,8 +212,7 @@ describe("original caller through Cron creator transports", () => {
payload: {
kind: "agentTurn",
timeoutSeconds: 0,
toolsAllow: [AUTOMATIONS_TOOL_NAME],
toolsAllowIsDefault: true,
toolsAllow: ["*"],
},
owner: { agentId: "main", sessionKey: SESSION, accountId: "default" },
scheduledToolPolicy: {

View file

@ -110,7 +110,7 @@ describe("MCP automation creator capture", () => {
{ label: "native excluded", nativeExec: true, nativeRestriction: "allow" },
];
it.each(cases)(
"persists the final $label creator surface",
"persists the $label creator authority",
async ({ toolsAllow, nativeExec, unreadableSchema, nativeRestriction }) => {
const root = tempDirs.make("openclaw-cli-cron-capture-");
const storePath = path.join(root, "cron", "jobs.json");
@ -232,9 +232,7 @@ describe("MCP automation creator capture", () => {
execTarget: stored.toolsAllowExecTarget,
});
const capturesNativeExec = nativeExec && !nativeRestriction;
expect(stored.payload.toolsAllow).toEqual(
toolsAllow ?? ["automations", ...(capturesNativeExec ? ["exec"] : [])],
);
expect(stored.payload.toolsAllow).toEqual(toolsAllow ?? ["*"]);
expect(stored.toolsAllowExecTarget).toEqual(
capturesNativeExec ? { version: 1, host: "gateway" } : undefined,
);