fix(cron): persist explicit scheduled tool authority (#112483)

* fix(cron): persist explicit scheduled tool authority

* test(cron): cover explicit scheduled authority

* fix(cron): preserve legacy authority on routine edits

* test(cron): add explicit authority e2e matrix

* test(cron): harden explicit authority live proof
This commit is contained in:
Josh Avant
2026-07-22 02:28:49 -05:00
committed by GitHub
parent 132d91e427
commit 3acc168c4f
25 changed files with 1063 additions and 218 deletions

View File

@@ -196,6 +196,13 @@ Every job carries exactly one payload kind, chosen by flag:
Restrict which tools the job can use, for example `--tools exec,read`.
</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; `cron 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.
`--model` sets the job's primary model; it does not replace a session `/model` override, so configured fallback chains still apply on top of it. An unresolved or disallowed model fails the run with an explicit validation error rather than silently falling back to the default. If a job has `--model` but no explicit or configured fallback list, OpenClaw passes an empty fallback override instead of silently appending the agent primary as a hidden retry target.
Model-selection precedence for isolated jobs, highest first:

View File

@@ -181,6 +181,9 @@ describe("qa scenario catalog", () => {
const cronRestart = requireFlowScenario(
readQaScenarioById("cron-model-created-one-shot-recurring"),
);
const cronAuthority = requireFlowScenario(
readQaScenarioById("cron-model-created-explicit-authority"),
);
expect(staleLinks.execution.suiteIsolation).toBe("isolated");
expect(staleLinks.execution.isolationReason).toContain("gateway session");
@@ -189,6 +192,19 @@ describe("qa scenario catalog", () => {
expect(cronRestart.execution.suiteIsolation).toBe("isolated");
expect(cronRestart.execution.retryCount).toBe(0);
expect(JSON.stringify(cronRestart.execution.flow)).toContain("liveTurnTimeoutMs(env, 180000)");
expect(cronAuthority.execution.suiteIsolation).toBe("isolated");
expect(cronAuthority.execution.retryCount).toBe(0);
expect(cronAuthority.runtimeParityTier).toBe("live-only");
expect(JSON.stringify(cronAuthority.gatewayConfigPatch)).toContain(
"qa-cron-authority-operator",
);
const cronAuthorityFlow = JSON.stringify(cronAuthority.execution.flow);
expect(cronAuthorityFlow).toContain("toolsAllowIsDefault");
expect(cronAuthorityFlow).toContain("model did not submit the wildcard-policy job");
expect(cronAuthorityFlow).toContain("model did not submit the overbroad-policy job");
expect(cronAuthorityFlow).toContain("overbroad policy was not intersected");
expect(cronAuthorityFlow).not.toContain("cron.run");
expect(cronAuthorityFlow).not.toContain("waitForCronRunCompletion");
});
it("requires explicit suite isolation for gateway state restart scenarios", () => {
@@ -200,6 +216,7 @@ describe("qa scenario catalog", () => {
expect(scenarios.map((scenario) => scenario.id).toSorted()).toEqual([
"active-memory-preprompt-recall",
"cron-model-created-explicit-authority",
"cron-model-created-one-shot-recurring",
"kitchen-sink-live-openai",
"matrix-post-restart-room-continue",

View File

@@ -38,6 +38,7 @@ type CronAddToolPayload = {
payload?: {
kind?: string;
message?: string;
toolsAllow?: string[];
};
delivery?: {
mode?: string;
@@ -73,6 +74,7 @@ describe("bridge/tools/remind", () => {
expect(addPayload?.job?.sessionTarget).toBe("isolated");
expect(addPayload?.job?.payload?.kind).toBe("agentTurn");
expect(addPayload?.job?.payload?.message).toContain("drink water");
expect(addPayload?.job?.payload?.toolsAllow).toEqual([]);
expect(addPayload?.job?.delivery).toEqual({
mode: "announce",
channel: "qqbot",

View File

@@ -42,6 +42,7 @@ describe("engine/tools/remind-logic", () => {
expect(call.job.payload).toEqual({
kind: "agentTurn",
message: expect.stringContaining("test reminder"),
toolsAllow: [],
});
expect(call.job.delivery).toEqual({
mode: "announce",

View File

@@ -209,6 +209,8 @@ function buildOnceJob(params: RemindParams, atMs: number, to: string, accountId:
payload: {
kind: "agentTurn" as const,
message: buildReminderPrompt(content),
// The scheduled turn only renders reminder text; delivery is host-owned.
toolsAllow: [],
},
delivery: {
mode: "announce" as const,
@@ -235,6 +237,8 @@ function buildCronJob(params: RemindParams, to: string, accountId: string) {
payload: {
kind: "agentTurn" as const,
message: buildReminderPrompt(content),
// The scheduled turn only renders reminder text; delivery is host-owned.
toolsAllow: [],
},
delivery: {
mode: "announce" as const,

View File

@@ -0,0 +1,339 @@
title: Model-created cron explicit authority
scenario:
id: cron-model-created-explicit-authority
surface: cron
runtimeParityTier: live-only
coverage:
primary:
- scheduling.cron
secondary:
- agents.tool-use
- scheduling.persistence
- channels.qa-channel
gatewayConfigPatch:
commands:
ownerAllowFrom:
- qa-cron-authority-operator
channels:
qa-channel:
groups:
qa-cron-authority:
tools:
allow:
- read
- cron
objective: Verify a sender-restricted live model persists exact creator-scoped authority for new cron jobs without widening requested tools.
successCriteria:
- The model creates omitted, wildcard, overbroad, and empty policy jobs through the cron tool.
- Omitted and wildcard policies become the concrete creator surface with the default marker.
- The overbroad policy is intersected with the creator surface and the empty policy remains empty.
- Every stored policy survives a Gateway restart unchanged.
docsRefs:
- docs/automation/cron-jobs.md
- docs/gateway/config-tools.md
codeRefs:
- src/agents/tools/cron-tool-creator-cap.ts
- src/agents/tools/cron-tool.ts
- src/cron/service/jobs.ts
- src/gateway/server-methods/cron.ts
execution:
kind: flow
retryCount: 0
suiteIsolation: isolated
isolationReason: Applies a sender-scoped tool policy, creates four durable jobs, and restarts the Gateway.
channel: qa-channel
summary: Ask a live model with only read and cron tools to create a policy matrix, then inspect and restart the persisted jobs.
config:
conversationId: qa-cron-authority
sessionKey: agent:qa:qa-channel:group:group:qa-cron-authority
flow:
steps:
- name: restricted model creates omitted policy job
actions:
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: waitForQaChannelReady
args:
- ref: env
- 60000
- resetTransport: true
- set: suffix
value:
expr: randomUUID().slice(0, 8)
- set: omittedName
value:
expr: "`qa-authority-omitted-${suffix}`"
- set: wildcardName
value:
expr: "`qa-authority-wildcard-${suffix}`"
- set: overbroadName
value:
expr: "`qa-authority-overbroad-${suffix}`"
- set: emptyName
value:
expr: "`qa-authority-empty-${suffix}`"
- set: futureAt
value:
expr: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
- sendInbound:
conversation:
id:
ref: config.conversationId
kind: group
title: Cron Authority QA
senderId: qa-cron-authority-operator
senderName: Cron Authority Operator
text:
expr: |-
`Use the cron tool once to create one disabled isolated agent-turn job. Do not run it.
Name it '${omittedName}', schedule it at '${futureAt}', use payload message 'omitted authority', delivery none, and omit toolsAllow.
Set enabled=false. After the cron add call succeeds, reply exactly CRON-AUTHORITY-OMITTED-OK.`
- call: waitForCondition
saveAs: omittedCall
args:
- lambda:
async: true
expr: "env.gateway.call('chat.history', { sessionKey: config.sessionKey, limit: 100 }, { timeoutMs: 30000 }).then((history) => { for (const message of history.messages ?? []) { if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue; for (const item of message.content) { const args = item?.arguments ?? item?.input; if ((item?.type === 'toolCall' || item?.type === 'toolUse') && (item?.name ?? item?.toolName) === 'cron' && args?.job?.name === omittedName) return { name: 'cron', arguments: args }; } } return undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- call: waitForCondition
saveAs: omittedSession
args:
- lambda:
async: true
expr: "env.gateway.call('sessions.list', {}, { timeoutMs: 30000 }).then((result) => { const session = result.sessions?.find((entry) => entry.key === config.sessionKey); return session && session.hasActiveRun !== true ? session : undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- assert:
expr: "omittedCall.arguments?.action === 'add' && !Object.prototype.hasOwnProperty.call(omittedCall.arguments.job.payload ?? {}, 'toolsAllow')"
message:
expr: "`model did not submit the omitted-policy job: ${JSON.stringify(omittedCall ?? null)}`"
detailsExpr: "`omitted-input=${JSON.stringify(omittedCall.arguments.job.payload)}`"
- name: restricted model creates wildcard policy job
actions:
- sendInbound:
conversation:
id:
ref: config.conversationId
kind: group
title: Cron Authority QA
senderId: qa-cron-authority-operator
senderName: Cron Authority Operator
text:
expr: |-
`Use the cron tool once to create one disabled isolated agent-turn job. Do not run it.
Name it '${wildcardName}', schedule it at '${futureAt}', use payload message 'wildcard authority', delivery none, and set toolsAllow to ["*"].
Set enabled=false. After the cron add call succeeds, reply exactly CRON-AUTHORITY-WILDCARD-OK.`
- call: waitForCondition
saveAs: wildcardCall
args:
- lambda:
async: true
expr: "env.gateway.call('chat.history', { sessionKey: config.sessionKey, limit: 100 }, { timeoutMs: 30000 }).then((history) => { for (const message of history.messages ?? []) { if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue; for (const item of message.content) { const args = item?.arguments ?? item?.input; if ((item?.type === 'toolCall' || item?.type === 'toolUse') && (item?.name ?? item?.toolName) === 'cron' && args?.job?.name === wildcardName) return { name: 'cron', arguments: args }; } } return undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- call: waitForCondition
saveAs: wildcardSession
args:
- lambda:
async: true
expr: "env.gateway.call('sessions.list', {}, { timeoutMs: 30000 }).then((result) => { const session = result.sessions?.find((entry) => entry.key === config.sessionKey); return session && session.hasActiveRun !== true ? session : undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- assert:
expr: "wildcardCall.arguments?.action === 'add' && JSON.stringify(wildcardCall.arguments.job.payload?.toolsAllow) === JSON.stringify(['*'])"
message:
expr: "`model did not submit the wildcard-policy job: ${JSON.stringify(wildcardCall ?? null)}`"
detailsExpr: "`wildcard-input=${JSON.stringify(wildcardCall.arguments.job.payload)}`"
- name: restricted model creates overbroad policy job
actions:
- sendInbound:
conversation:
id:
ref: config.conversationId
kind: group
title: Cron Authority QA
senderId: qa-cron-authority-operator
senderName: Cron Authority Operator
text:
expr: |-
`Use the cron tool once to create one disabled isolated agent-turn job. Do not run it.
Name it '${overbroadName}', schedule it every 3600000 milliseconds, use payload message 'overbroad authority', delivery none, and set toolsAllow to ["read", "exec"].
Set enabled=false. After the cron add call succeeds, reply exactly CRON-AUTHORITY-OVERBROAD-OK.`
- call: waitForCondition
saveAs: overbroadCall
args:
- lambda:
async: true
expr: "env.gateway.call('chat.history', { sessionKey: config.sessionKey, limit: 100 }, { timeoutMs: 30000 }).then((history) => { for (const message of history.messages ?? []) { if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue; for (const item of message.content) { const args = item?.arguments ?? item?.input; if ((item?.type === 'toolCall' || item?.type === 'toolUse') && (item?.name ?? item?.toolName) === 'cron' && args?.job?.name === overbroadName) return { name: 'cron', arguments: args }; } } return undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- call: waitForCondition
saveAs: overbroadSession
args:
- lambda:
async: true
expr: "env.gateway.call('sessions.list', {}, { timeoutMs: 30000 }).then((result) => { const session = result.sessions?.find((entry) => entry.key === config.sessionKey); return session && session.hasActiveRun !== true ? session : undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- assert:
expr: "overbroadCall.arguments?.action === 'add' && JSON.stringify([...(overbroadCall.arguments.job.payload?.toolsAllow ?? [])].sort()) === JSON.stringify(['exec', 'read'])"
message:
expr: "`model did not submit the overbroad-policy job: ${JSON.stringify(overbroadCall ?? null)}`"
detailsExpr: "`overbroad-input=${JSON.stringify(overbroadCall.arguments.job.payload)}`"
- name: restricted model creates empty policy job
actions:
- sendInbound:
conversation:
id:
ref: config.conversationId
kind: group
title: Cron Authority QA
senderId: qa-cron-authority-operator
senderName: Cron Authority Operator
text:
expr: |-
`Use the cron tool once to create one disabled isolated agent-turn job. Do not run it.
Name it '${emptyName}', schedule it every 3600000 milliseconds, use payload message 'empty authority', delivery none, and set toolsAllow to [].
Set enabled=false. After the cron add call succeeds, reply exactly CRON-AUTHORITY-EMPTY-OK.`
- call: waitForCondition
saveAs: emptyCall
args:
- lambda:
async: true
expr: "env.gateway.call('chat.history', { sessionKey: config.sessionKey, limit: 100 }, { timeoutMs: 30000 }).then((history) => { for (const message of history.messages ?? []) { if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue; for (const item of message.content) { const args = item?.arguments ?? item?.input; if ((item?.type === 'toolCall' || item?.type === 'toolUse') && (item?.name ?? item?.toolName) === 'cron' && args?.job?.name === emptyName) return { name: 'cron', arguments: args }; } } return undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- call: waitForCondition
saveAs: emptySession
args:
- lambda:
async: true
expr: "env.gateway.call('sessions.list', {}, { timeoutMs: 30000 }).then((result) => { const session = result.sessions?.find((entry) => entry.key === config.sessionKey); return session && session.hasActiveRun !== true ? session : undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- assert:
expr: "emptyCall.arguments?.action === 'add' && Array.isArray(emptyCall.arguments.job.payload?.toolsAllow) && emptyCall.arguments.job.payload.toolsAllow.length === 0"
message:
expr: "`model did not submit the empty-policy job: ${JSON.stringify(emptyCall ?? null)}`"
detailsExpr: "`empty-input=${JSON.stringify(emptyCall.arguments.job.payload)}`"
- name: stored authority matches the submitted matrix
actions:
- call: waitForCondition
saveAs: cronToolCalls
args:
- lambda:
async: true
expr: "env.gateway.call('chat.history', { sessionKey: config.sessionKey, limit: 100 }, { timeoutMs: 30000 }).then((history) => { const calls = []; for (const message of history.messages ?? []) { if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue; for (const item of message.content) { const args = item?.arguments ?? item?.input; if ((item?.type === 'toolCall' || item?.type === 'toolUse') && (item?.name ?? item?.toolName) === 'cron') calls.push({ name: 'cron', arguments: args }); } } return calls.length >= 4 ? calls : undefined; })"
- expr: liveTurnTimeoutMs(env, 240000)
- 250
- assert:
expr: "cronToolCalls.length === 4 && cronToolCalls.every((call) => call.arguments?.action === 'add')"
message:
expr: "`expected exactly four cron add calls, saw ${JSON.stringify(cronToolCalls)}`"
- call: waitForCondition
saveAs: jobs
args:
- lambda:
async: true
expr: "env.gateway.call('cron.list', { includeDisabled: true }, { timeoutMs: 30000 }).then((page) => { const names = new Set([omittedName, wildcardName, overbroadName, emptyName]); const matches = page.jobs.filter((job) => names.has(job.name)); return matches.length === 4 ? matches : undefined; })"
- 60000
- 250
- set: omittedJob
value:
expr: jobs.find((job) => job.name === omittedName)
- set: wildcardJob
value:
expr: jobs.find((job) => job.name === wildcardName)
- set: overbroadJob
value:
expr: jobs.find((job) => job.name === overbroadName)
- set: emptyJob
value:
expr: jobs.find((job) => job.name === emptyName)
- set: expectedCreatorTools
value:
expr: "['cron', 'read'].sort()"
- assert:
expr: "JSON.stringify([...(omittedJob.payload.toolsAllow ?? [])].sort()) === JSON.stringify(expectedCreatorTools) && omittedJob.payload.toolsAllowIsDefault === true"
message:
expr: "`omitted policy did not capture creator authority: ${JSON.stringify(omittedJob?.payload)}`"
- assert:
expr: "JSON.stringify([...(wildcardJob.payload.toolsAllow ?? [])].sort()) === JSON.stringify(expectedCreatorTools) && wildcardJob.payload.toolsAllowIsDefault === true"
message:
expr: "`wildcard policy did not capture creator authority: ${JSON.stringify(wildcardJob?.payload)}`"
- assert:
expr: "JSON.stringify(overbroadJob.payload.toolsAllow) === JSON.stringify(['read']) && overbroadJob.payload.toolsAllowIsDefault !== true"
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"
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)}`"
- name: stored authority survives Gateway restart
actions:
- assert:
expr: "typeof env.gateway.restartAfterStateMutation === 'function'"
message: qa gateway child does not expose restartAfterStateMutation
- call: env.gateway.restartAfterStateMutation
args:
- lambda:
async: true
params: [ctx]
expr: Promise.resolve(ctx)
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: env.gateway.call
saveAs: restartedPage
args:
- cron.list
- includeDisabled: true
- timeoutMs: 30000
- set: restartedJobs
value:
expr: "restartedPage.jobs.filter((job) => [omittedName, wildcardName, overbroadName, emptyName].includes(job.name))"
- assert:
expr: "restartedJobs.length === 4"
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; })"
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
args:
- cron.remove
- id:
expr: omittedJob.id
- timeoutMs: 30000
- call: env.gateway.call
args:
- cron.remove
- id:
expr: wildcardJob.id
- timeoutMs: 30000
- call: env.gateway.call
args:
- cron.remove
- id:
expr: overbroadJob.id
- timeoutMs: 30000
- call: env.gateway.call
args:
- cron.remove
- id:
expr: emptyJob.id
- timeoutMs: 30000
detailsExpr: "`restart-preserved=${restartedJobs.map((job) => `${job.name}:${JSON.stringify(job.payload.toolsAllow)}`).join(';')}`"

View File

@@ -18,6 +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 cron tool.
- Both jobs persist the concrete creator tool surface as explicit default authority.
- 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.
@@ -139,7 +140,11 @@ flow:
expr: "jobs.every((job) => job.sessionTarget === 'isolated' && job.payload.kind === 'agentTurn' && job.delivery?.mode === 'none')"
message:
expr: "`model created unsafe job shape: ${JSON.stringify(jobs)}`"
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(',')}`"
- assert:
expr: "jobs.every((job) => Array.isArray(job.payload.toolsAllow) && job.payload.toolsAllow.length > 0 && job.payload.toolsAllow.includes('cron') && !job.payload.toolsAllow.includes('*') && job.payload.toolsAllowIsDefault === true)"
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(',')}`"
- name: scheduler fires one-off once and recurring repeatedly
actions:
@@ -242,6 +247,10 @@ flow:
expr: "finalRecurring.state.nextRunAtMs > recurringJob.state.nextRunAtMs"
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"
message:
expr: "`recurring authority changed across restart: before=${JSON.stringify(recurringJob.payload)} after=${JSON.stringify(finalRecurring?.payload)}`"
- assert:
expr: "finalList.jobs.length === 1 && finalList.jobs[0].id === recurringJob.id"
message:

View File

@@ -53,9 +53,13 @@ dump_logs_on_error() {
if [ "$status" -ne 0 ]; then
openclaw_e2e_dump_logs \
/tmp/cron-cli-gateway.log \
/tmp/cron-cli-device-seed.json \
/tmp/cron-cli-status.json \
/tmp/cron-cli-add.json \
/tmp/cron-cli-agent-add.json \
/tmp/cron-cli-agent-default.json \
/tmp/cron-cli-agent-restricted.json \
/tmp/cron-cli-agent-cleared.json \
/tmp/cron-authority-operator-matrix.json \
/tmp/cron-cli-edit-exact.json \
/tmp/cron-cli-edit-timeout.json \
/tmp/cron-cli-get-after-edit.json \
@@ -78,78 +82,214 @@ cron_cli() {
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
}
seed_paired_cli_device() {
node --input-type=module <<'NODE'
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
run_operator_authority_matrix() {
local phase="$1"
node --input-type=module - "$entry" "${GW_TOKEN:?missing GW_TOKEN}" "$phase" <<'NODE'
import { readFile, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
async function importDistChunk(prefix, marker) {
const distDir = join(process.cwd(), "dist");
const entries = await readdir(distDir);
for (const entry of entries) {
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
continue;
}
const fullPath = join(distDir, entry);
if ((await readFile(fullPath, "utf8")).includes(marker)) {
return await import(pathToFileURL(fullPath).href);
}
const [entry, token, phase] = process.argv.slice(2);
const snapshotPath = "/tmp/cron-authority-operator-matrix.json";
function callGateway(method, params) {
const result = spawnSync(
process.execPath,
[entry, "gateway", "call", method, "--params", JSON.stringify(params), "--token", token, "--json"],
{ encoding: "utf8", env: process.env },
);
if (result.status !== 0) {
throw new Error(
`${method} failed (${result.status}): ${String(result.stderr || result.stdout).trim()}`,
);
}
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
return JSON.parse(result.stdout);
}
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
const loadOrCreateDeviceIdentity =
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
const publicKeyRawBase64UrlFromPem =
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
if (
typeof loadOrCreateDeviceIdentity !== "function" ||
typeof publicKeyRawBase64UrlFromPem !== "function" ||
typeof approveDevicePairing !== "function" ||
typeof getPairedDevice !== "function" ||
typeof requestDevicePairing !== "function"
) {
throw new Error("missing device pairing exports in dist chunks");
function readAuthority(job) {
return {
toolsAllow: job.payload?.toolsAllow,
toolsAllowIsDefault: job.payload?.toolsAllowIsDefault,
};
}
const identity = loadOrCreateDeviceIdentity();
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const requiredScopes = ["operator.admin"];
const paired = await getPairedDevice(identity.deviceId);
const pairedScopes = Array.isArray(paired?.approvedScopes)
? paired.approvedScopes
: Array.isArray(paired?.scopes)
? paired.scopes
: [];
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
const pairing = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
displayName: "cron cli docker smoke",
platform: process.platform,
clientId: "cli",
clientMode: "cli",
role: "operator",
scopes: requiredScopes,
silent: true,
});
const approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: requiredScopes,
});
if (approved?.status !== "approved") {
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
function assertAuthority(label, job, expected) {
const actual = readAuthority(job);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${label} authority mismatch: expected=${JSON.stringify(expected)} actual=${JSON.stringify(actual)}`,
);
}
}
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
if (phase === "create") {
const schedule = { kind: "every", everyMs: 3_600_000 };
const cases = [
{
label: "agent-default",
input: {
name: "operator agent default",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "agent default" },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "agent-wildcard",
input: {
name: "operator agent wildcard",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "agent wildcard", toolsAllow: ["*"] },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "agent-empty",
input: {
name: "operator agent empty",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "agent empty", toolsAllow: [] },
delivery: { mode: "none" },
},
expected: { toolsAllow: [] },
},
{
label: "script-default",
input: {
name: "operator script default",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "script", script: "return {}" },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "trigger-system-default",
input: {
name: "operator trigger system default",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
trigger: { script: "return { fire: false }" },
payload: { kind: "systemEvent", text: "trigger system default" },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "trigger-command-default",
input: {
name: "operator trigger command default",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
trigger: { script: "return { fire: false }" },
payload: { kind: "command", argv: ["printf", "trigger-command"] },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["*"] },
},
{
label: "transport-system-capless",
input: {
name: "operator transport system capless",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "transport system capless" },
delivery: { mode: "none" },
},
expected: {},
},
{
label: "transport-command-capless",
input: {
name: "operator transport command capless",
enabled: false,
schedule,
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "command", argv: ["printf", "transport-command"] },
delivery: { mode: "none" },
},
expected: {},
},
{
label: "transport-system-narrow-trigger",
input: {
name: "operator transport system narrow",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "transport system narrow", toolsAllow: ["read"] },
delivery: { mode: "none" },
},
expected: { toolsAllow: ["read"] },
patch: { trigger: { script: "return { fire: false }" } },
expectedAfterPatch: { toolsAllow: ["read"] },
},
{
label: "transport-system-capless-trigger",
input: {
name: "operator transport system adopts wildcard",
enabled: false,
schedule,
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "transport system adopts wildcard" },
delivery: { mode: "none" },
},
expected: {},
patch: { trigger: { script: "return { fire: false }" } },
expectedAfterPatch: { toolsAllow: ["*"] },
},
];
const snapshots = [];
for (const testCase of cases) {
let job = callGateway("cron.add", testCase.input);
assertAuthority(`${testCase.label} create`, job, testCase.expected);
if (testCase.patch) {
callGateway("cron.update", { id: job.id, patch: testCase.patch });
job = callGateway("cron.get", { id: job.id });
assertAuthority(`${testCase.label} update`, job, testCase.expectedAfterPatch);
}
snapshots.push({
id: job.id,
label: testCase.label,
authority: readAuthority(job),
});
}
await writeFile(snapshotPath, `${JSON.stringify({ cases: snapshots }, null, 2)}\n`, "utf8");
process.stdout.write(`operator authority matrix created ${snapshots.length} cases\n`);
} else if (phase === "verify") {
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
for (const testCase of snapshot.cases) {
const job = callGateway("cron.get", { id: testCase.id });
assertAuthority(`${testCase.label} restart`, job, testCase.authority);
callGateway("cron.remove", { id: testCase.id });
}
process.stdout.write(`operator authority matrix restart-verified ${snapshot.cases.length} cases\n`);
} else {
throw new Error(`unknown authority matrix phase: ${phase}`);
}
NODE
}
@@ -167,10 +307,29 @@ read_json_field() {
' "$file" "$field"
}
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH is required");
}
const raw = await fs.readFile(configPath, "utf8").catch(() => "{}");
const config = JSON.parse(raw || "{}");
config.cron ??= {};
config.cron.triggers = { ...(config.cron.triggers ?? {}), enabled: true };
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
'
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
run_operator_authority_matrix create
openclaw_e2e_stop_process "$gateway_pid"
gateway_pid=
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
run_operator_authority_matrix verify
cron_cli status --json > /tmp/cron-cli-status.json
cron_add_args=(
"cli cron smoke"
@@ -184,6 +343,38 @@ cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
cron_cli add \
"agent authority smoke" \
--every 1h \
--session isolated \
--message "verify explicit cron tool authority" \
--no-deliver \
--json > /tmp/cron-cli-agent-add.json
agent_job_id="$(read_json_field /tmp/cron-cli-agent-add.json id)"
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-default.json
cron_cli edit "$agent_job_id" --tools read
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-restricted.json
cron_cli edit "$agent_job_id" --clear-tools
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-cleared.json
node --input-type=module -e '
const fs = await import("node:fs/promises");
const readPayload = async (path) => JSON.parse(await fs.readFile(path, "utf8")).payload;
const defaultPayload = await readPayload("/tmp/cron-cli-agent-default.json");
const restrictedPayload = await readPayload("/tmp/cron-cli-agent-restricted.json");
const clearedPayload = await readPayload("/tmp/cron-cli-agent-cleared.json");
if (JSON.stringify(defaultPayload?.toolsAllow) !== JSON.stringify(["*"])) {
throw new Error(`new agent job is not explicitly unrestricted: ${JSON.stringify(defaultPayload)}`);
}
if (JSON.stringify(restrictedPayload?.toolsAllow) !== JSON.stringify(["read"])) {
throw new Error(`cron edit --tools did not persist: ${JSON.stringify(restrictedPayload)}`);
}
if (JSON.stringify(clearedPayload?.toolsAllow) !== JSON.stringify(["*"])) {
throw new Error(`cron edit --clear-tools is not explicit: ${JSON.stringify(clearedPayload)}`);
}
'
cron_cli rm "$agent_job_id" --json >/dev/null
cron_cli edit "$job_id" --exact > /tmp/cron-cli-edit-exact.json
cron_cli edit "$job_id" --timeout-seconds 30 > /tmp/cron-cli-edit-timeout.json
cron_cli get "$job_id" > /tmp/cron-cli-get-after-edit.json

View File

@@ -1196,6 +1196,19 @@ describe("createOpenClawCodingTools", () => {
expect(cronAllowNames?.includes("process")).toBe(false);
});
it("passes the final unrestricted tool surface to cron-created agent turns", () => {
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
createOpenClawToolsMock.mockClear();
createOpenClawCodingTools({ config: {} });
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
const cronAllowNames = cronCreatorToolNames(
latestCreateOpenClawToolsOptions().cronCreatorToolAllowlist,
);
expectListIncludes(cronAllowNames, ["read", "cron", "exec"]);
});
it("lets embedded attempts refresh a caller-owned cron creator tool surface", () => {
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
createOpenClawToolsMock.mockClear();

View File

@@ -120,13 +120,6 @@ import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-contex
const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
function hasExplicitDenyPolicy(policy?: { deny?: string[] }): boolean {
return (
Array.isArray(policy?.deny) &&
policy.deny.some((entry) => typeof entry === "string" && entry.trim())
);
}
type GuardContainerMount = {
containerRoot: string;
hostRoot: string;
@@ -847,9 +840,6 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
const shouldInheritEffectiveToolAllowlist =
toolPolicyInheritanceSources.some(hasRestrictiveAllowPolicy);
const cronCreatorToolAllowlist = options?.cronCreatorToolAllowlistRef ?? [];
const shouldCaptureCronCreatorToolAllowlist = toolPolicyInheritanceSources.some(
(policy) => hasRestrictiveAllowPolicy(policy) || hasExplicitDenyPolicy(policy),
);
// Plugin-only plans bypass createOpenClawTools, so the capability gate must
// apply here too or narrow allowlists leak gated tools onto capless surfaces.
const pluginToolCallerIdentity =
@@ -999,9 +989,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
toolBindings: options?.toolBindings,
pluginToolAllowlist,
pluginToolDenylist,
cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist
? cronCreatorToolAllowlist
: undefined,
cronCreatorToolAllowlist,
currentChannelId: options?.currentChannelId,
currentChatType: options?.chatType,
currentMessagingTarget: options?.currentMessagingTarget,
@@ -1169,13 +1157,9 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
// never filters the mandatory structured_output tool from this turn.
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, authorizedTools);
}
if (shouldCaptureCronCreatorToolAllowlist) {
replaceWithEffectiveCronCreatorToolAllowlist(
cronCreatorToolAllowlist,
authorizedTools,
(tool) => getPluginToolMeta(tool),
);
}
replaceWithEffectiveCronCreatorToolAllowlist(cronCreatorToolAllowlist, authorizedTools, (tool) =>
getPluginToolMeta(tool),
);
options?.recordToolPrepStage?.("authorization-policy");
// Always normalize tool JSON Schemas before handing them to OpenClaw model runtime.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.

View File

@@ -53,19 +53,28 @@ describe("cron tool creator cap", () => {
});
});
it("requests current state before deriving an implicit cap", () => {
it("preserves non-policy patches without loading or synthesizing authority", () => {
expect(
planCronJobUpdatePatch({
patch: { enabled: false },
creatorToolAllowlist: ["read", "cron"],
}),
).toEqual({ kind: "ready", patch: { enabled: false } });
});
it("requests current state before deriving an implicit cap for a payload edit", () => {
expect(
planCronJobUpdatePatch({
patch: { payload: { message: "updated" } },
creatorToolAllowlist: ["read", "cron"],
}),
).toEqual({ kind: "needs-current-job" });
});
it("preserves explicit narrower caps and re-derives stored defaults", () => {
const narrower = readReadyPatch(
planCronJobUpdatePatch({
patch: { enabled: false },
patch: { payload: { message: "updated" } },
creatorToolAllowlist: ["read", "exec", "cron"],
currentJob: {
payload: { kind: "agentTurn", message: "work", toolsAllow: ["read"] },
@@ -74,7 +83,7 @@ describe("cron tool creator cap", () => {
);
const storedDefault = readReadyPatch(
planCronJobUpdatePatch({
patch: { enabled: false },
patch: { payload: { message: "updated" } },
creatorToolAllowlist: ["read", "cron"],
currentJob: {
payload: {
@@ -88,13 +97,12 @@ describe("cron tool creator cap", () => {
);
expect(narrower).toEqual({
enabled: false,
payload: { kind: "agentTurn", toolsAllow: ["read"] },
payload: { kind: "agentTurn", message: "updated", toolsAllow: ["read"] },
});
expect(storedDefault).toEqual({
enabled: false,
payload: {
kind: "agentTurn",
message: "updated",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},

View File

@@ -136,6 +136,11 @@ export function planCronJobUpdatePatch(params: {
}): CronJobUpdatePatchPlan {
const patch = structuredClone(params.patch);
const payload = isRecord(patch.payload) ? patch.payload : undefined;
if (payload === undefined && !Object.hasOwn(patch, "trigger")) {
// Schedule, delivery, naming, and enabled-state edits do not reauthorize
// legacy jobs. Only tool-runtime changes may synthesize durable authority.
return { kind: "ready", patch };
}
const explicitPayloadKind = readCronPayloadKind(payload);
if (
params.creatorToolAllowlist &&

View File

@@ -2368,13 +2368,7 @@ describe("cron tool", () => {
});
it("allows non-payload updates to triggered command jobs", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-command",
trigger: { script: "json({ fire: true })" },
payload: { kind: "command", argv: ["echo", "before"] },
})
.mockResolvedValueOnce({ ok: true });
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({ creatorToolAllowlist: ["read", "cron"] });
await tool.execute("call-command-disable", {
@@ -2383,19 +2377,12 @@ describe("cron tool", () => {
patch: { enabled: false },
});
expect(readGatewayCall(1)).toEqual({
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-command",
expectedConfigRevision: "sha256:test",
patch: {
enabled: false,
payload: {
kind: "command",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
patch: { enabled: false },
},
});
});
@@ -2833,13 +2820,8 @@ describe("cron tool", () => {
});
});
it("adds the creator tool surface when updating an existing agentTurn without a payload patch", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-9",
payload: { kind: "agentTurn", message: "hello" },
})
.mockResolvedValueOnce({ ok: true });
it("preserves legacy authority when updating an agentTurn without a policy patch", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
@@ -2851,35 +2833,18 @@ describe("cron tool", () => {
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(readGatewayCall(0)).toEqual({
method: "cron.get",
params: { id: "job-9" },
});
expect(readGatewayCall(1)).toEqual({
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-9",
expectedConfigRevision: "sha256:test",
patch: {
enabled: false,
payload: {
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
patch: { enabled: false },
},
});
});
it("preserves an existing narrower toolsAllow when updating without a payload patch", async () => {
callGatewayMock
.mockResolvedValueOnce({
id: "job-10",
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["read"] },
})
.mockResolvedValueOnce({ ok: true });
it("leaves a stored narrower cap untouched when updating without a policy patch", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
@@ -2891,19 +2856,12 @@ describe("cron tool", () => {
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(readGatewayCall(1)).toEqual({
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-10",
expectedConfigRevision: "sha256:test",
patch: {
enabled: false,
payload: {
kind: "agentTurn",
toolsAllow: ["read"],
},
},
patch: { enabled: false },
},
});
});
@@ -2940,7 +2898,7 @@ describe("cron tool", () => {
await tool.execute("call-update-retry-cap-race", {
action: "update",
id: "job-race",
patch: { enabled: false },
patch: { payload: { message: "updated" } },
});
expect(callGatewayMock).toHaveBeenCalledTimes(4);
@@ -2949,10 +2907,7 @@ describe("cron tool", () => {
params: {
id: "job-race",
expectedConfigRevision: "sha256:first",
patch: {
enabled: false,
payload: { kind: "agentTurn", toolsAllow: ["read"] },
},
patch: { payload: { kind: "agentTurn", message: "updated", toolsAllow: ["read"] } },
},
});
expect(readGatewayCall(3)).toEqual({
@@ -2960,10 +2915,7 @@ describe("cron tool", () => {
params: {
id: "job-race",
expectedConfigRevision: "sha256:second",
patch: {
enabled: false,
payload: { kind: "agentTurn", toolsAllow: [] },
},
patch: { payload: { kind: "agentTurn", message: "updated", toolsAllow: [] } },
},
});
});
@@ -2980,7 +2932,7 @@ describe("cron tool", () => {
tool.execute("call-update-no-revision", {
action: "update",
id: "job-no-revision",
patch: { enabled: false },
patch: { payload: { message: "updated" } },
}),
).rejects.toThrow("cron.get response is missing configRevision");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
@@ -3023,23 +2975,8 @@ describe("cron tool", () => {
});
});
it("preserves the default toolsAllow flag across an update that omits toolsAllow", async () => {
// Regression guard: a routine update (here, toggling enabled) of an
// agentTurn job whose cap was an auto-stamped default must keep
// toolsAllowIsDefault set. Otherwise the run-time CLI drop (which keys off
// the flag) stops applying and the job fails closed again after a restart —
// re-breaking the exact #91499 regression this change fixes.
callGatewayMock
.mockResolvedValueOnce({
id: "job-13",
payload: {
kind: "agentTurn",
message: "hi",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
})
.mockResolvedValueOnce({ ok: true });
it("leaves a stored default cap untouched across a non-policy update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
@@ -3051,20 +2988,12 @@ describe("cron tool", () => {
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(readGatewayCall(1)).toEqual({
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(readGatewayCall()).toEqual({
method: "cron.update",
params: {
id: "job-13",
expectedConfigRevision: "sha256:test",
patch: {
enabled: false,
payload: {
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
patch: { enabled: false },
},
});
});

View File

@@ -194,7 +194,9 @@ export async function resolveCronEditPayloadDeliveryPatch(
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(payload, "lightContext", opts.lightContext, typeof opts.lightContext === "boolean");
if (opts.clearTools) {
payload.toolsAllow = null;
// Clearing a restriction means an explicit unrestricted grant. Persisting
// a wildcard avoids creating a new capless legacy job at the upgrade boundary.
payload.toolsAllow = ["*"];
} else if (toolsAllow) {
payload.toolsAllow = toolsAllow;
}

View File

@@ -323,6 +323,26 @@ describe("cron edit command", () => {
);
});
it("stores an explicit wildcard with --clear-tools", async () => {
const program = createCronProgram();
await program.parseAsync(["edit", "job-1", "--clear-tools"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.update",
expect.objectContaining({ clearTools: true }),
{
id: "job-1",
patch: {
payload: {
kind: "agentTurn",
toolsAllow: ["*"],
},
},
},
);
});
it("clears the thinking override with --clear-thinking (CLI parity with cron.update thinking:null)", async () => {
const program = createCronProgram();

View File

@@ -64,6 +64,7 @@ describe("CronService declarative jobs", () => {
declarationKey: "agent:ops:daily-report",
displayName: "Daily report",
owner: { agentId: "ops", sessionKey: "agent:ops:main" },
payload: { toolsAllow: ["*"] },
});
const identical = declarativeResult(await cron.add(declaration(), { enabledExplicit: true }));

View File

@@ -1,6 +1,7 @@
// Cron service job tests cover job creation, updates, and runtime scheduling.
import { describe, expect, it } from "vitest";
import {
applyDeclarativeJobSpec,
applyJobPatch,
computeJobNextRunAtMs,
computeJobPreviousRunAtOrBeforeMs,
@@ -479,7 +480,7 @@ describe("applyJobPatch", () => {
}
});
it("clears agentTurn payload.toolsAllow when patch requests null", () => {
it("stores an explicit wildcard when a patch clears agentTurn payload.toolsAllow", () => {
const job = createIsolatedAgentTurnJob("job-tools-clear", {
mode: "announce",
channel: "telegram",
@@ -501,7 +502,7 @@ describe("applyJobPatch", () => {
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.toolsAllow).toBeUndefined();
expect(job.payload.toolsAllow).toEqual(["*"]);
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
}
});
@@ -833,6 +834,143 @@ function createMockState(
} as unknown as CronServiceState;
}
describe("cron tool authority defaults", () => {
const now = Date.parse("2026-07-21T12:00:00.000Z");
it("stores an explicit wildcard for newly created tool-runtime jobs", () => {
const agentTurn = createJob(createMockState(now), {
name: "agent turn",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "work" },
});
const triggeredEvent = createJob(createMockState(now, { scriptPayloadsEnabled: true }), {
name: "triggered event",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "wake" },
trigger: { script: "return true" },
});
expect(agentTurn.payload.toolsAllow).toEqual(["*"]);
expect(triggeredEvent.payload.toolsAllow).toEqual(["*"]);
});
it("preserves explicit empty caps and leaves transport-only jobs capless", () => {
const noTools = createJob(createMockState(now), {
name: "no tools",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "render", toolsAllow: [] },
});
const transportOnly = createJob(createMockState(now), {
name: "transport only",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "wake" },
});
expect(noTools.payload.toolsAllow).toEqual([]);
expect(transportOnly.payload.toolsAllow).toBeUndefined();
});
it("preserves legacy and explicit state during declarative convergence", () => {
const base = {
id: "declared-job",
name: "declared job",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every" as const, everyMs: 60_000, anchorMs: now },
sessionTarget: "isolated" as const,
wakeMode: "now" as const,
delivery: { mode: "none" as const },
state: {},
};
const input = {
name: "declared job",
enabled: true,
schedule: { kind: "every" as const, everyMs: 60_000 },
sessionTarget: "isolated" as const,
wakeMode: "now" as const,
payload: { kind: "agentTurn" as const, message: "updated" },
delivery: { mode: "none" as const },
};
const legacy: CronJob = {
...base,
payload: { kind: "agentTurn", message: "legacy" },
};
const explicit: CronJob = {
...base,
id: "explicit-job",
payload: {
kind: "agentTurn",
message: "explicit",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
};
applyDeclarativeJobSpec(legacy, input, {
enabledExplicit: true,
nowMs: now,
});
applyDeclarativeJobSpec(explicit, input, {
enabledExplicit: true,
nowMs: now,
});
expect(legacy.payload.toolsAllow).toBeUndefined();
expect(explicit.payload).toMatchObject({
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
});
});
it("adopts explicit authority when a declaration becomes tool-bearing", () => {
const job: CronJob = {
id: "declared-trigger",
name: "declared trigger",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every", everyMs: 60_000, anchorMs: now },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "wake" },
state: {},
};
applyDeclarativeJobSpec(
job,
{
name: "declared trigger",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "wake" },
trigger: { script: "return true" },
},
{
enabledExplicit: true,
nowMs: now,
cronConfig: { triggers: { enabled: true } },
},
);
expect(job.payload.toolsAllow).toEqual(["*"]);
});
});
describe("script payload validation", () => {
const now = Date.parse("2026-07-18T12:00:00.000Z");
const input = (sessionTarget: CronJob["sessionTarget"] = "isolated") => ({

View File

@@ -26,6 +26,7 @@ import {
resolveDefaultCronStaggerMs,
} from "../stagger.js";
import { createCronStreamSourceIdentity, resolveCronStreamBatching } from "../stream-schedule.js";
import { applyDefaultCronToolsAllow, cronJobUsesToolRuntime } from "../tools-allow.js";
import type {
CronDelivery,
CronDeliveryPatch,
@@ -1119,7 +1120,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
payload:
input.payload.kind === "script"
? normalizeCronScriptPayload(structuredClone(input.payload))
: input.payload,
: structuredClone(input.payload),
delivery: resolveInitialCronDelivery(input),
failureAlert: input.failureAlert,
...(input.trigger ? { trigger: structuredClone(input.trigger) } : {}),
@@ -1130,6 +1131,9 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
: {}),
},
};
// New trusted jobs are explicit by construction. Agent-runtime callers are
// required to arrive with a creator cap before the service can apply this default.
applyDefaultCronToolsAllow(job);
assertSupportedJobSpec(job);
assertPacingSupport(job);
assertTriggerSupport(job, {
@@ -1162,6 +1166,8 @@ export function applyJobPatch(
cronConfig?: CronConfig;
},
) {
const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job);
const explicitlyClearsToolsAllow = patch.payload?.toolsAllow === null;
const previousScheduleKind = job.schedule.kind;
if ("name" in patch) {
job.name = normalizeRequiredName(patch.name);
@@ -1250,6 +1256,11 @@ export function applyJobPatch(
job.payload = normalizeCronScriptPayload(job.payload);
}
}
if (cronJobUsesToolRuntime(job) && (!previouslyUsedToolRuntime || explicitlyClearsToolsAllow)) {
// `null` means unrestricted, not a return to ambiguous legacy semantics.
// Ordinary edits to an existing capless job intentionally remain legacy.
applyDefaultCronToolsAllow(job);
}
if (patch.delivery) {
const implicitMode = resolveCronDeliveryPlan(job).mode;
job.delivery = mergeCronDelivery(job.delivery, patch.delivery, implicitMode);
@@ -1336,6 +1347,9 @@ export function applyDeclarativeJobSpec(
cronConfig?: CronConfig;
},
) {
const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job);
const previousToolsAllow = job.payload.toolsAllow;
const previousToolsAllowIsDefault = job.payload.toolsAllowIsDefault;
// Name, target, routing, owner, and run policy remain outside declaration
// convergence; changing those uses cron.update and cannot retarget an identity.
const displayName = normalizeOptionalString(input.displayName);
@@ -1390,6 +1404,19 @@ export function applyDeclarativeJobSpec(
} else {
delete job.trigger;
}
if (cronJobUsesToolRuntime(job) && job.payload.toolsAllow === undefined) {
if (previousToolsAllow !== undefined) {
// Omitted declaration fields preserve explicit authority already stored
// on the job, including the server-managed creator-default marker.
job.payload.toolsAllow = [...previousToolsAllow];
if (previousToolsAllowIsDefault === true) {
job.payload.toolsAllowIsDefault = true;
}
} else if (!previouslyUsedToolRuntime) {
// A declaration that newly becomes tool-bearing adopts current explicit semantics.
applyDefaultCronToolsAllow(job);
}
}
const delivery = resolveInitialCronDelivery(input);
if (delivery) {
job.delivery = structuredClone(delivery);

19
src/cron/tools-allow.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { CronJob } from "./types.js";
type CronToolRuntimeSpec = Pick<CronJob, "payload" | "trigger">;
/** Returns whether a cron job can construct or execute OpenClaw agent tools. */
export function cronJobUsesToolRuntime(job: CronToolRuntimeSpec): boolean {
return (
job.payload.kind === "agentTurn" ||
job.payload.kind === "script" ||
Boolean(job.trigger?.script.trim())
);
}
/** Stamps an explicit unrestricted cap without changing jobs that already carry one. */
export function applyDefaultCronToolsAllow(job: CronToolRuntimeSpec): void {
if (cronJobUsesToolRuntime(job) && job.payload.toolsAllow === undefined) {
job.payload.toolsAllow = ["*"];
}
}

View File

@@ -35,6 +35,7 @@ import {
isInvalidCronTaskRunJobIdError,
readCronTaskRunHistoryPage,
} from "../../cron/task-run-history.js";
import { cronJobUsesToolRuntime } from "../../cron/tools-allow.js";
import type { CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js";
import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -56,6 +57,7 @@ import {
cronJobMatchesCallerScope,
cronPatchSessionRefsMatchCaller,
readCronCallerScope,
type CronCallerScope,
} from "./cron-caller-scope.js";
import { isCronInvalidRequestError } from "./cron-error-classification.js";
import { listCronPageForCallerScope } from "./cron-list-caller-scope.js";
@@ -240,6 +242,22 @@ async function assertValidCronUpdatePatch(params: {
delivery: effectiveDelivery,
});
}
return nextJob;
}
function requiresExplicitAgentRuntimeToolsAllow(params: {
job: Pick<CronJob, "payload" | "trigger">;
callerScope: CronCallerScope | undefined;
}): boolean {
return (
params.callerScope !== undefined &&
cronJobUsesToolRuntime(params.job) &&
params.job.payload.toolsAllow === undefined
);
}
function cronPatchTouchesToolRuntime(patch: CronJobPatch): boolean {
return patch.payload !== undefined || Object.hasOwn(patch, "trigger");
}
function assertCronDoesNotTargetAgentHarness(input: {
@@ -577,6 +595,14 @@ export const cronHandlers: GatewayRequestHandlers = {
respondInvalidCronParams(respond, "cron.add", "job agentId outside caller scope");
return;
}
if (requiresExplicitAgentRuntimeToolsAllow({ job: jobCreate, callerScope })) {
respondInvalidCronParams(
respond,
"cron.add",
"agent-runtime tool jobs require an explicit payload.toolsAllow cap",
);
return;
}
const timestampValidation = validateScheduleTimestamp(jobCreate.schedule);
if (!timestampValidation.ok) {
respond(
@@ -740,12 +766,18 @@ export const cronHandlers: GatewayRequestHandlers = {
}
}
try {
await assertValidCronUpdatePatch({
const nextJob = await assertValidCronUpdatePatch({
cfg,
defaultAgentId: context.cron.getDefaultAgentId(),
currentJob,
patch,
});
if (
cronPatchTouchesToolRuntime(patch) &&
requiresExplicitAgentRuntimeToolsAllow({ job: nextJob, callerScope })
) {
throw new TypeError("agent-runtime tool jobs require an explicit payload.toolsAllow cap");
}
} catch (err) {
respond(
false,
@@ -778,12 +810,18 @@ export const cronHandlers: GatewayRequestHandlers = {
);
}
}
await assertValidCronUpdatePatch({
const nextJob = await assertValidCronUpdatePatch({
cfg,
defaultAgentId: context.cron.getDefaultAgentId(),
currentJob: lockedJob,
patch,
});
if (
cronPatchTouchesToolRuntime(patch) &&
requiresExplicitAgentRuntimeToolsAllow({ job: nextJob, callerScope })
) {
throw new TypeError("agent-runtime tool jobs require an explicit payload.toolsAllow cap");
}
});
} catch (err) {
if (err instanceof CronJobConfigRevisionConflictError) {

View File

@@ -255,7 +255,7 @@ function createCronJob(overrides: Partial<CronJob> = {}): CronJob {
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["*"] },
delivery: { mode: "none" },
state: {},
...overrides,
@@ -373,7 +373,7 @@ function agentTurnCronParams(overrides: Record<string, unknown> = {}) {
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
payload: { kind: "agentTurn", message: "hello", toolsAllow: ["*"] },
...overrides,
};
}
@@ -831,6 +831,34 @@ describe("cron method validation", () => {
expectCronSuccess(respond);
});
it("rejects agent-runtime tool jobs without an explicit toolsAllow cap", async () => {
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
payload: { kind: "agentTurn", message: "hello" },
}),
{ client: callerClient("ops") },
);
expect(context.cron.add).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "explicit payload.toolsAllow cap",
});
});
it("allows agent-runtime transport-only jobs without a toolsAllow cap", async () => {
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
sessionTarget: "main",
payload: { kind: "systemEvent", text: "wake" },
}),
{ client: callerClient("ops") },
);
expect(context.cron.add).toHaveBeenCalled();
expectCronSuccess(respond);
});
it.each([
{
name: "explicit reserved target",
@@ -1407,6 +1435,43 @@ describe("cron method validation", () => {
expectCronSuccess(respond);
});
it("rejects agent-runtime edits that leave a tool-runtime job capless", async () => {
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: { payload: { kind: "agentTurn", message: "updated" } },
},
createCronJob({
agentId: "ops",
payload: { kind: "agentTurn", message: "legacy" },
}),
{ client: callerClient("ops") },
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "explicit payload.toolsAllow cap",
});
});
it("allows agent-runtime non-policy edits to legacy capless jobs", async () => {
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: { enabled: false },
},
createCronJob({
agentId: "ops",
payload: { kind: "agentTurn", message: "legacy" },
}),
{ client: callerClient("ops") },
);
expect(context.cron.update).toHaveBeenCalledWith("cron-1", { enabled: false });
expectCronSuccess(respond);
});
it("allows cron.update to clear a display name", async () => {
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { displayName: null } },

View File

@@ -708,4 +708,26 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
{ name: "cron" },
]);
});
it("passes unrestricted gateway tool surfaces to cron jobs", () => {
hoisted.createOpenClawToolsMock.mockReturnValueOnce([
hoisted.makeTool("read"),
hoisted.makeTool("cron"),
hoisted.makeTool("exec"),
]);
const result = resolveGatewayScopedTools({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:direct:test",
surface: "loopback",
senderIsOwner: true,
});
expect(result.tools.map((tool) => tool.name)).toEqual(["read", "cron", "exec"]);
expect(readCreateToolsArgs().cronCreatorToolAllowlist).toEqual([
{ name: "read" },
{ name: "cron" },
{ name: "exec" },
]);
});
});

View File

@@ -223,10 +223,6 @@ export function resolveGatewayScopedTools(params: {
inheritedToolPolicy,
gatewayRequestedTools.length > 0 ? { allow: gatewayRequestedTools } : undefined,
].some(hasRestrictiveAllowPolicy);
const shouldCaptureCronCreatorToolAllowlist =
shouldInheritEffectiveToolAllowlist ||
explicitDenylist.length > 0 ||
excludedToolNames.length > 0;
const openClawTools = createOpenClawTools({
agentSessionKey: params.sessionKey,
@@ -270,9 +266,7 @@ export function resolveGatewayScopedTools(params: {
gatewayRequestedTools.length > 0 ? { allow: gatewayRequestedTools } : undefined,
]),
pluginToolDenylist: explicitDenylist,
cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist
? cronCreatorToolAllowlist
: undefined,
cronCreatorToolAllowlist,
inheritedToolAllowlist,
inheritedToolDenylist,
});
@@ -399,13 +393,9 @@ export function resolveGatewayScopedTools(params: {
if (shouldInheritEffectiveToolAllowlist) {
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, inheritableTools);
}
if (shouldCaptureCronCreatorToolAllowlist) {
replaceWithEffectiveCronCreatorToolAllowlist(
cronCreatorToolAllowlist,
inheritableTools,
(tool) => getPluginToolMeta(tool),
);
}
replaceWithEffectiveCronCreatorToolAllowlist(cronCreatorToolAllowlist, inheritableTools, (tool) =>
getPluginToolMeta(tool),
);
return {
agentId,

View File

@@ -63,6 +63,26 @@ describe("resolveSkillDispatchTools", () => {
expect(args?.nativeChannelId).toBe("native-room-1");
});
it("passes unrestricted skill-dispatch tool surfaces to cron jobs", () => {
const tools = resolveSkillDispatchTools({
message: { surface: "telegram", senderId: "user-1" },
cfg: {} as OpenClawConfig,
agentId: "main",
sessionKey: "agent:main:telegram:direct:user-1",
workspaceDir: "/tmp/openclaw-skill-tool-dispatch-test",
provider: "openai",
model: "gpt-5.5",
});
const args = hoisted.createOpenClawToolsMock.mock.calls.at(-1)?.[0];
expect(tools.map((tool) => tool.name)).toEqual(["read", "cron", "exec"]);
expect(args?.cronCreatorToolAllowlist).toEqual([
{ name: "read" },
{ name: "cron" },
{ name: "exec" },
]);
});
it("carries command skill file identity into tool diagnostics", () => {
resolveSkillDispatchTools({
message: { surface: "telegram", senderId: "user-1" },

View File

@@ -132,8 +132,6 @@ export function resolveSkillDispatchTools(params: {
const explicitDenylist = collectExplicitDenylist(explicitPolicyList);
const inheritedToolAllowlist: string[] = [];
const cronCreatorToolAllowlist: CronCreatorToolAllowlistEntry[] = [];
const shouldCaptureCronCreatorToolAllowlist =
explicitPolicyList.some(hasRestrictiveAllowPolicy) || explicitDenylist.length > 0;
const beforeToolCallHookContext = params.skillCommand
? {
cwd: params.workspaceDir,
@@ -175,9 +173,7 @@ export function resolveSkillDispatchTools(params: {
modelId: params.model,
pluginToolAllowlist: collectExplicitAllowlist(explicitPolicyList),
pluginToolDenylist: explicitDenylist,
cronCreatorToolAllowlist: shouldCaptureCronCreatorToolAllowlist
? cronCreatorToolAllowlist
: undefined,
cronCreatorToolAllowlist,
inheritedToolAllowlist,
inheritedToolDenylist: explicitDenylist,
});
@@ -214,10 +210,8 @@ export function resolveSkillDispatchTools(params: {
if (explicitPolicyList.some(hasRestrictiveAllowPolicy)) {
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, policyFiltered);
}
if (shouldCaptureCronCreatorToolAllowlist) {
replaceWithEffectiveCronCreatorToolAllowlist(cronCreatorToolAllowlist, policyFiltered, (tool) =>
getPluginToolMeta(tool),
);
}
replaceWithEffectiveCronCreatorToolAllowlist(cronCreatorToolAllowlist, policyFiltered, (tool) =>
getPluginToolMeta(tool),
);
return policyFiltered;
}