From 90b53ec2d9c23bdb53fe62039f2c4c47a5577f07 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:21:29 -0500 Subject: [PATCH] fix(cron): bind scheduled authority to creator session --- CHANGELOG.md | 1 - .../server-methods/cron-caller-scope.ts | 24 ++++++ .../server-methods/cron.validation.test.ts | 84 ++++++++++++++++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 784753bef5f1..12c8fa0effba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,6 @@ Docs: https://docs.openclaw.ai ### Fixes -- **Cron scheduled tool authority:** persist versioned creator provenance for tool-capped jobs, recover pre-v2026.7.2 account authority only when stored identity proves it, and surface ambiguous legacy jobs for explicit operator reauthorization instead of silently widening access. Fixes #111809. (#112661) - **ClickClack split-origin setup codes:** consume versioned exact claim endpoints without appending a second claim path, validate the returned canonical API base, preserve private API transport overrides, and keep legacy setup URLs working. Fixes #111919. Thanks @shakkernerd. - **Standalone plugin files:** let manifestless files explicitly listed in `plugins.load.paths` pass config validation and load independently when several files share a directory. - **Control UI terminal error messages:** preserve message-only assistant output beginning with `Error:` or a warning marker instead of treating text prefixes as synthetic failures. Thanks @shakkernerd. diff --git a/src/gateway/server-methods/cron-caller-scope.ts b/src/gateway/server-methods/cron-caller-scope.ts index ab8453a6102c..7fa4be62e77d 100644 --- a/src/gateway/server-methods/cron-caller-scope.ts +++ b/src/gateway/server-methods/cron-caller-scope.ts @@ -90,6 +90,27 @@ function isOperatorCommandCronJob(job: CronJob): boolean { ); } +function cronJobScheduledAuthorityMatchesCaller( + job: CronJob, + callerScope: CronCallerScope, +): boolean { + const policy = job.scheduledToolPolicy; + if (!policy) { + return true; + } + // Trusted jobs remain operator-only. Account jobs reuse the exact persisted + // session's group authority, so sibling sessions must not control them. + if (policy.mode === "trusted") { + return false; + } + const callerSessionKey = callerScope.sessionKey?.trim(); + return ( + callerSessionKey === policy.ownerSessionKey && + job.owner?.sessionKey?.trim() === policy.ownerSessionKey && + callerScope.accountId === normalizeAccountId(policy.ownerAccountId) + ); +} + export function cronJobMatchesCallerScope(params: { job: CronJob; callerScope: CronCallerScope | undefined; @@ -104,6 +125,9 @@ export function cronJobMatchesCallerScope(params: { if (isOperatorCommandCronJob(params.job)) { return false; } + if (!cronJobScheduledAuthorityMatchesCaller(params.job, params.callerScope)) { + return false; + } const ownerAccountId = params.job.owner?.accountId; // Operator-created records may name an account without an owner agent; account ownership is // therefore an independent boundary, not a refinement of ownerAgentId. diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 69a693277bd6..548d3cf6c2e5 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -264,14 +264,14 @@ function createCronJob(overrides: Partial = {}): CronJob { }; } -function callerClient(agentId: string, accountId?: string): GatewayClient { +function callerClient(agentId: string, accountId?: string, sessionKey?: string): GatewayClient { return { connect: {} as GatewayClient["connect"], internal: { agentRuntimeIdentity: { kind: "agentRuntime", agentId, - sessionKey: `agent:${agentId}:main`, + sessionKey: sessionKey ?? `agent:${agentId}:main`, ...(accountId ? { turnSourceAccountId: accountId } : {}), }, }, @@ -1234,6 +1234,86 @@ describe("cron method validation", () => { ); }); + it("binds scheduled authority access to its exact creator session", async () => { + const ownerSessionKey = "agent:ops:discord:work:group:creator"; + const accountJob = createCronJob({ + owner: { agentId: "ops", sessionKey: ownerSessionKey, accountId: "work" }, + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey, + ownerAccountId: "work", + }, + }); + const context = createCronContext(accountJob); + + const siblingClient = callerClient("ops", "work", "agent:ops:discord:work:group:sibling"); + const siblingList = await invokeCron( + "cron.list", + { compact: true }, + { context, client: siblingClient }, + ); + expect(siblingList.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 0, jobs: [] }), + undefined, + ); + + const siblingUpdate = await invokeCron( + "cron.update", + { + id: accountJob.id, + patch: { + payload: { + kind: "agentTurn", + message: "replace creator prompt", + toolsAllow: ["*"], + }, + }, + }, + { context, client: siblingClient }, + ); + expect(siblingUpdate.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "invalid cron.update params: id not found" }), + ); + expect(context.cron.updateWithPrecondition).not.toHaveBeenCalled(); + + const ownerList = await invokeCron( + "cron.list", + { compact: true }, + { context, client: callerClient("ops", "work", ownerSessionKey) }, + ); + expect(ownerList.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 1 }), + undefined, + ); + }); + + it("keeps trusted scheduled authority operator-only", async () => { + const trustedJob = createCronJob({ + owner: { agentId: "ops", sessionKey: "agent:ops:main", accountId: "default" }, + scheduledToolPolicy: { version: 1, mode: "trusted" }, + }); + const context = createCronContext(trustedJob); + + const agentList = await invokeCron( + "cron.list", + { compact: true }, + { context, client: callerClient("ops") }, + ); + expect(agentList.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 0, jobs: [] }), + undefined, + ); + + const operatorGet = await invokeCron("cron.get", { id: trustedJob.id }, { context }); + expectCronSuccess(operatorGet.respond); + }); + it("keeps explicit declaration ownership for operator callers", async () => { const owner = { agentId: "ops", sessionKey: "agent:ops:main" }; const { context, respond } = await invokeCronAdd(