mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:41:37 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(';')}`"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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") => ({
|
||||
|
||||
@@ -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
19
src/cron/tools-allow.ts
Normal 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 = ["*"];
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user