diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 280ad28296cb..79dcc9e0d24d 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -105,14 +105,26 @@ function resolveAgentRuntimeAuthorityCommitGuard( } function combineCronCommitGuards( - ...guards: Array<(() => void) | undefined> -): (() => void) | undefined { - const active = guards.filter((guard): guard is () => void => guard !== undefined); + ...guards: Array<(() => void | CronRuntimeAuthority) | undefined> +): (() => CronRuntimeAuthority | undefined) | undefined { + const active = guards.filter( + (guard): guard is () => void | CronRuntimeAuthority => guard !== undefined, + ); return active.length > 0 ? () => { + let runtimeAuthority: CronRuntimeAuthority | undefined; for (const guard of active) { - guard(); + const candidate = guard(); + if (candidate !== undefined) { + // A mutation has one runtime-authority owner. Combining validation + // guards must not silently choose between competing authority caps. + if (runtimeAuthority !== undefined) { + throw new TypeError("multiple cron runtime authorities resolved at commit"); + } + runtimeAuthority = candidate; + } } + return runtimeAuthority; } : undefined; } diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 11a3f663b84c..a5490fef7de0 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createOperationalRunInstanceRef } from "../../agents/admitted-run-context.js"; import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CronRuntimeAuthority } from "../../cron/runtime-authority.js"; import type { CronDelivery, CronJob } from "../../cron/types.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; import { @@ -132,6 +133,7 @@ function setCronValidationTestRegistry(): void { function createCronContext(currentJobs?: CronJob | CronJob[]) { const jobs = currentJobs ? (Array.isArray(currentJobs) ? currentJobs : [currentJobs]) : []; const committedAdds: Partial[] = []; + const committedRuntimeAuthorities: Array = []; const committedUpdates: Array<{ id: string; patch: Partial }> = []; const update = vi.fn(async (id: string, patch: Partial) => { committedUpdates.push({ id, patch }); @@ -143,13 +145,19 @@ function createCronContext(currentJobs?: CronJob | CronJob[]) { }); return { committedAdds, + committedRuntimeAuthorities, committedUpdates, cron: { - add: vi.fn(async (input: Partial, opts?: { commitGuard?: () => void }) => { - opts?.commitGuard?.(); - committedAdds.push(input); - return createCronJob({ ...input, id: "cron-1" }); - }), + add: vi.fn( + async ( + input: Partial, + opts?: { commitGuard?: () => CronRuntimeAuthority | undefined }, + ) => { + committedRuntimeAuthorities.push(opts?.commitGuard?.()); + committedAdds.push(input); + return createCronJob({ ...input, id: "cron-1" }); + }, + ), update, updateWithPrecondition: vi.fn( async ( @@ -1326,6 +1334,28 @@ describe("cron method validation", () => { revokeCronCreatorAuthorityRunScope(scope); }); + it("preserves creator runtime authority while revalidating delegated authority at commit", async () => { + const runtimeAuthority = { + version: 1 as const, + runtimeId: "codex", + namespace: "codex.apps", + payload: { apps: [{ id: "calendar" }] }, + }; + const scope = createCronCreatorAuthorityRunScope("run-add-authority"); + const grant = mintCronCreatorAuthorityGrant(scope, undefined, runtimeAuthority); + const context = createCronContext(); + context.validateAgentRuntimeApprovalAuthority = () => true; + + const result = await invokeCron("cron.add", agentTurnCronParams(), { + context, + client: callerClientWithCronCreatorAuthority(grant), + }); + + expectCronSuccess(result.respond); + expect(context.committedRuntimeAuthorities).toEqual([runtimeAuthority]); + revokeCronCreatorAuthorityRunScope(scope); + }); + it("rejects a mismatched cron.add runId without consuming the exact grant", async () => { const scope = createCronCreatorAuthorityRunScope("run-add"); const grant = mintCronCreatorAuthorityGrant(scope);