mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
94f5a8d586
commit
e151ea3bb5
32 changed files with 152 additions and 625 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ async function prepareCronJobUpdateForGateway(
|
|||
creatorToolAllowlist: resolvedAuthority.tools,
|
||||
currentJob: existingRecord,
|
||||
creatorAuthorityComplete: true,
|
||||
creatorHoldsRuntimeAuthority: resolvedAuthority.holdsRuntimeAuthority,
|
||||
});
|
||||
}
|
||||
if (finalPlan.kind !== "ready") {
|
||||
|
|
|
|||
|
|
@ -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: ["*"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`",
|
||||
|
|
|
|||
|
|
@ -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" })] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", [" "]],
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue