diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 46eca3160f71..7ecc079ea41f 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -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`. +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: diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 329e2eea2228..c47a26e687f7 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -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", diff --git a/extensions/qqbot/src/bridge/tools/remind.test.ts b/extensions/qqbot/src/bridge/tools/remind.test.ts index 2cac8fa72f4f..a3c6fc5d0cd4 100644 --- a/extensions/qqbot/src/bridge/tools/remind.test.ts +++ b/extensions/qqbot/src/bridge/tools/remind.test.ts @@ -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", diff --git a/extensions/qqbot/src/engine/tools/remind-logic.test.ts b/extensions/qqbot/src/engine/tools/remind-logic.test.ts index f38a75fbda77..8eac34723d1a 100644 --- a/extensions/qqbot/src/engine/tools/remind-logic.test.ts +++ b/extensions/qqbot/src/engine/tools/remind-logic.test.ts @@ -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", diff --git a/extensions/qqbot/src/engine/tools/remind-logic.ts b/extensions/qqbot/src/engine/tools/remind-logic.ts index c21eaef8d346..6f3b64c72857 100644 --- a/extensions/qqbot/src/engine/tools/remind-logic.ts +++ b/extensions/qqbot/src/engine/tools/remind-logic.ts @@ -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, diff --git a/qa/scenarios/scheduling/cron-model-created-explicit-authority.yaml b/qa/scenarios/scheduling/cron-model-created-explicit-authority.yaml new file mode 100644 index 000000000000..a774384f6593 --- /dev/null +++ b/qa/scenarios/scheduling/cron-model-created-explicit-authority.yaml @@ -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(';')}`" diff --git a/qa/scenarios/scheduling/cron-model-created-one-shot-recurring.yaml b/qa/scenarios/scheduling/cron-model-created-one-shot-recurring.yaml index cc6681bfa9d3..ca35cbe0e1a2 100644 --- a/qa/scenarios/scheduling/cron-model-created-one-shot-recurring.yaml +++ b/qa/scenarios/scheduling/cron-model-created-one-shot-recurring.yaml @@ -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: diff --git a/scripts/e2e/cron-cli-docker.sh b/scripts/e2e/cron-cli-docker.sh index 2dd55e47da4b..e2272385a1d4 100755 --- a/scripts/e2e/cron-cli-docker.sh +++ b/scripts/e2e/cron-cli-docker.sh @@ -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 diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index ee71fdc3be53..a5eaa2e7ac99 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -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(); diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index ef5c7bf66d4d..83faba551233 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -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. diff --git a/src/agents/tools/cron-tool-creator-cap.test.ts b/src/agents/tools/cron-tool-creator-cap.test.ts index 750b88423cc3..1c256fef7b6b 100644 --- a/src/agents/tools/cron-tool-creator-cap.test.ts +++ b/src/agents/tools/cron-tool-creator-cap.test.ts @@ -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, }, diff --git a/src/agents/tools/cron-tool-creator-cap.ts b/src/agents/tools/cron-tool-creator-cap.ts index c5ab43ee2787..818bd3d398ee 100644 --- a/src/agents/tools/cron-tool-creator-cap.ts +++ b/src/agents/tools/cron-tool-creator-cap.ts @@ -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 && diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 977a48b251ed..4c5b1cd1dcd4 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -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 }, }, }); }); diff --git a/src/cli/cron-cli/register.cron-edit-options.ts b/src/cli/cron-cli/register.cron-edit-options.ts index 1de28df3648c..38173740eed6 100644 --- a/src/cli/cron-cli/register.cron-edit-options.ts +++ b/src/cli/cron-cli/register.cron-edit-options.ts @@ -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; } diff --git a/src/cli/cron-cli/register.cron-edit.test.ts b/src/cli/cron-cli/register.cron-edit.test.ts index 152a6e8b58d0..de632c6635cf 100644 --- a/src/cli/cron-cli/register.cron-edit.test.ts +++ b/src/cli/cron-cli/register.cron-edit.test.ts @@ -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(); diff --git a/src/cron/service.declarative-jobs.test.ts b/src/cron/service.declarative-jobs.test.ts index 36a7d10c2fd2..a14e3ec2376a 100644 --- a/src/cron/service.declarative-jobs.test.ts +++ b/src/cron/service.declarative-jobs.test.ts @@ -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 })); diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 59b2c5e9fdc5..425266b8cf3c 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -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") => ({ diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 984c8deb704e..5f23eeac46bb 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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); diff --git a/src/cron/tools-allow.ts b/src/cron/tools-allow.ts new file mode 100644 index 000000000000..aeaa59d8a5e8 --- /dev/null +++ b/src/cron/tools-allow.ts @@ -0,0 +1,19 @@ +import type { CronJob } from "./types.js"; + +type CronToolRuntimeSpec = Pick; + +/** 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 = ["*"]; + } +} diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 032f2232985c..b12e2e15a18d 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -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; + 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) { diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index fa4098d9bc0a..f8dca01d8acf 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -255,7 +255,7 @@ function createCronJob(overrides: Partial = {}): 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 = {}) { 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 } }, diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index ca215bc64ca2..e6bfddd481ea 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -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" }, + ]); + }); }); diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index eb175e6887f0..1de36e26be21 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -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, diff --git a/src/skills/runtime/tool-dispatch.test.ts b/src/skills/runtime/tool-dispatch.test.ts index 60f0b0e47618..f14c40062d75 100644 --- a/src/skills/runtime/tool-dispatch.test.ts +++ b/src/skills/runtime/tool-dispatch.test.ts @@ -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" }, diff --git a/src/skills/runtime/tool-dispatch.ts b/src/skills/runtime/tool-dispatch.ts index 3969c1a995d9..4097c6598deb 100644 --- a/src/skills/runtime/tool-dispatch.ts +++ b/src/skills/runtime/tool-dispatch.ts @@ -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; }