diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c8fa0effba..784753bef5f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ 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/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 24b98f28d9dd..0ed9af839604 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -461,6 +461,8 @@ describe("Codex app-server dynamic tool build", () => { }; params.trustedInternalHandoff = true; params.scheduledToolPolicy = { + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "default", }; diff --git a/extensions/copilot/src/tool-bridge.test.ts b/extensions/copilot/src/tool-bridge.test.ts index f0e71b1a7373..3e254fd32455 100644 --- a/extensions/copilot/src/tool-bridge.test.ts +++ b/extensions/copilot/src/tool-bridge.test.ts @@ -728,6 +728,8 @@ describe("createCopilotToolBridge", () => { memoryFlushWritePath: ".memory/append.md", toolsAllow: ["read", "edit"], scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "default", }, @@ -747,6 +749,8 @@ describe("createCopilotToolBridge", () => { // renamed key, so the bridge must surface the renamed shape too. expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]); expect(opts.scheduledToolPolicy).toEqual({ + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "default", }); diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index 3e8539482da7..9797b50b7ab6 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -36,6 +36,17 @@ describe("cron protocol validators", () => { expect(validateCronAddParams(minimalAddParams)).toBe(true); }); + it("rejects client-authored scheduled authority provenance", () => { + const scheduledToolPolicy = { version: 1, mode: "trusted" } as const; + expect(validateCronAddParams({ ...minimalAddParams, scheduledToolPolicy })).toBe(false); + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { scheduledToolPolicy }, + }), + ).toBe(false); + }); + it("accepts failure alert field clears only in update patches", () => { const failureAlert = { after: null, diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index 0205b71adac0..2a4791e2f9e6 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -132,6 +132,15 @@ const CronOwnerSchema = closedObject({ sessionKey: Type.Optional(NonEmptyString), accountId: Type.Optional(NonEmptyString), }); +const CronScheduledToolPolicySchema = Type.Union([ + closedObject({ version: Type.Literal(1), mode: Type.Literal("trusted") }), + closedObject({ + version: Type.Literal(1), + mode: Type.Literal("account"), + ownerSessionKey: NonEmptyString, + ownerAccountId: NonEmptyString, + }), +]); const CronAnnounceChannelSchema = Type.Union([Type.Literal("last"), NonBlankString]); const CronFailoverReasonSchema = Type.Union([ Type.Literal("auth"), @@ -515,6 +524,7 @@ export const CronJobSchema = closedObject({ declarationKey: Type.Optional(CronDeclarationKeySchema), displayName: Type.Optional(CronDisplayNameSchema), owner: Type.Optional(CronOwnerSchema), + scheduledToolPolicy: Type.Optional(CronScheduledToolPolicySchema), agentId: Type.Optional(NonEmptyString), sessionKey: Type.Optional(NonEmptyString), name: NonEmptyString, diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index a2f8b1cf581c..6151de55c720 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -19,6 +19,7 @@ const SCENARIOS = new Set([ "tilde-log-path", "meeting-transcripts-sqlite", "versioned-runtime-deps", + "cron-scheduled-authority", ]); const PERSONA_FILES = new Map([ @@ -184,6 +185,67 @@ function seedLegacyMeetingTranscripts(stateDir) { write(path.join(sessionDir, "summary.md"), "# Design review\n\nShipped transcript summary.\n"); } +function seedLegacyCronScheduledAuthority(stateDir) { + const createdAtMs = Date.parse("2026-07-01T10:00:00.000Z"); + const base = { + enabled: true, + createdAtMs, + updatedAtMs: createdAtMs, + schedule: { kind: "every", everyMs: 3_600_000, anchorMs: createdAtMs }, + sessionTarget: "isolated", + wakeMode: "now", + delivery: { mode: "none" }, + state: { nextRunAtMs: createdAtMs + 3_600_000 }, + }; + writeJson(path.join(stateDir, "cron", "jobs.json"), { + version: 1, + jobs: [ + { + ...base, + id: "cron-pre-cap", + name: "Pre-cap agent job", + payload: { kind: "agentTurn", message: "pre-cap" }, + }, + { + ...base, + id: "cron-ownerless-cap", + name: "Ownerless capped job", + payload: { kind: "agentTurn", message: "ownerless", toolsAllow: ["write"] }, + }, + { + ...base, + id: "cron-owner-session", + name: "Persisted owner session", + owner: { + agentId: "main", + sessionKey: "agent:main:discord:group:ops", + }, + payload: { kind: "agentTurn", message: "owned", toolsAllow: ["write"] }, + }, + { + ...base, + id: "cron-encoded-account", + name: "Encoded owner account", + owner: { + agentId: "main", + sessionKey: "agent:main:discord:personal:direct:user-1", + }, + payload: { kind: "agentTurn", message: "encoded", toolsAllow: ["write"] }, + }, + { + ...base, + id: "cron-agent-mismatch", + name: "Mismatched owner agent", + owner: { + agentId: "other", + sessionKey: "agent:main:discord:work:direct:user-2", + }, + payload: { kind: "agentTurn", message: "mismatch", toolsAllow: ["write"] }, + }, + ], + }); +} + function getScenario() { const scenario = process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base"; assert(SCENARIOS.has(scenario), `unknown upgrade survivor scenario: ${scenario}`); @@ -244,6 +306,9 @@ function seedState() { if (scenario === "meeting-transcripts-sqlite") { seedLegacyMeetingTranscripts(stateDir); } + if (scenario === "cron-scheduled-authority") { + seedLegacyCronScheduledAuthority(stateDir); + } const runtimeRoot = path.join(stateDir, "plugin-runtime-deps"); for (const plugin of ["discord", "telegram", "whatsapp"]) { @@ -483,6 +548,9 @@ function assertStateSurvived() { if (scenario === "meeting-transcripts-sqlite") { assertMeetingTranscriptsMigrated(stateDir, stage); } + if (scenario === "cron-scheduled-authority") { + assertCronScheduledAuthorityMigrated(stateDir, stage); + } const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps"); if (stage === "baseline") { if (fs.existsSync(legacyRuntimeRoot)) { @@ -526,6 +594,60 @@ function assertStateSurvived() { } } +function assertCronScheduledAuthorityMigrated(stateDir, stage) { + const legacyStorePath = path.join(stateDir, "cron", "jobs.json"); + const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); + if (stage === "baseline") { + if (fs.existsSync(legacyStorePath)) { + const jobs = readJson(legacyStorePath).jobs ?? []; + assert(jobs.length === 5, "legacy cron authority fixture row count changed before update"); + return; + } + assert(fs.existsSync(databasePath), "legacy cron authority fixture missing before update"); + const db = new DatabaseSync(databasePath, { readOnly: true }); + try { + const rows = db.prepare("SELECT job_json FROM cron_jobs WHERE job_id LIKE 'cron-%'").all(); + assert(rows.length === 5, "baseline cron authority fixture row count changed"); + assert( + rows.every((row) => JSON.parse(row.job_json).scheduledToolPolicy === undefined), + "baseline unexpectedly authored current scheduled authority provenance", + ); + } finally { + db.close(); + } + return; + } + const db = new DatabaseSync(databasePath, { readOnly: true }); + try { + const rows = db + .prepare("SELECT job_id, job_json FROM cron_jobs WHERE job_id LIKE 'cron-%'") + .all(); + const jobs = new Map(rows.map((row) => [row.job_id, JSON.parse(row.job_json)])); + assert(jobs.size === 5, `cron authority fixture row count changed: ${jobs.size}`); + assert( + jobs.get("cron-encoded-account")?.scheduledToolPolicy?.ownerAccountId === "personal", + "session-encoded account authority was not recovered", + ); + assert( + jobs.get("cron-encoded-account")?.owner?.accountId === "personal", + "session-encoded account was not projected onto the owner", + ); + for (const id of [ + "cron-pre-cap", + "cron-ownerless-cap", + "cron-owner-session", + "cron-agent-mismatch", + ]) { + assert( + jobs.get(id)?.scheduledToolPolicy === undefined, + `ambiguous legacy job unexpectedly gained scheduled authority: ${id}`, + ); + } + } finally { + db.close(); + } +} + function assertMeetingTranscriptsMigrated(stateDir, stage) { const legacySessionDir = path.join(stateDir, "transcripts", "2026-07-01", "design-review"); if (stage === "baseline") { diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index ac4efd4d35f3..c70adc035202 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -88,6 +88,7 @@ const UPGRADE_SURVIVOR_SCENARIOS = [ "tilde-log-path", "meeting-transcripts-sqlite", "versioned-runtime-deps", + "cron-scheduled-authority", ]; const UPGRADE_SURVIVOR_SCENARIO_ALIASES = new Map([ @@ -98,6 +99,10 @@ const UPGRADE_SURVIVOR_SCENARIO_ALIASES = new Map([ // Pre-protocol catalogs are content-addressed. Unknown legacy blocks fail // closed instead of requiring a dependency or reimplementing a JavaScript parser. const LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS = new Map([ + [ + "10ea475027d8b320d6a704cd6e4dd0f7e984c57a93b327048db229a7e0132c8a", + "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority", + ], [ "213e004a28814fe0f7bb33018ae59a709c2e6d6e13b273df82a0e7935fbaf5af", "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps", 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 271730214d60..ed2f3abc8d14 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -935,6 +935,8 @@ describe("createOpenClawCodingTools", () => { messageTo: "channel:123", agentAccountId: "work", scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "creator", }, @@ -1017,6 +1019,8 @@ describe("createOpenClawCodingTools", () => { config: testConfig, agentAccountId: "delivery", scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "creator", }, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 1034ba8acf49..10d88830f0d1 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -2435,6 +2435,8 @@ describe("prepareCliRunContext", () => { runId: "run-test-loopback-prompt-tools", config: createCliBackendConfig({ bundleMcp: true }), scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:worker:discord:group:ops", ownerAccountId: "default", }, @@ -2465,6 +2467,8 @@ describe("prepareCliRunContext", () => { modelProvider: "native-cli", modelId: "test-model", scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:worker:discord:group:ops", ownerAccountId: "default", }, @@ -2989,6 +2993,8 @@ describe("prepareCliRunContext", () => { provider: "claude-cli", toolsAllow: ["write"], scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "default", }, @@ -3005,6 +3011,8 @@ describe("prepareCliRunContext", () => { "apply_patch", ]); expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.scheduledToolPolicy).toEqual({ + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "default", }); @@ -3012,6 +3020,8 @@ describe("prepareCliRunContext", () => { expect.objectContaining({ toolsAllow: ["write"], scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "default", }, diff --git a/src/agents/conversation-capability-profile.test.ts b/src/agents/conversation-capability-profile.test.ts index cc61f7149c2c..4c5efc723b77 100644 --- a/src/agents/conversation-capability-profile.test.ts +++ b/src/agents/conversation-capability-profile.test.ts @@ -197,6 +197,8 @@ describe("resolveConversationCapabilityProfile", () => { const scheduled = resolveConversationCapabilityProfile({ ...baseParams, scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:whatsapp:group:team", ownerAccountId: "default", }, diff --git a/src/agents/conversation-capability-profile.ts b/src/agents/conversation-capability-profile.ts index cf725554e1ba..27b217352c92 100644 --- a/src/agents/conversation-capability-profile.ts +++ b/src/agents/conversation-capability-profile.ts @@ -231,7 +231,7 @@ export function resolveConversationCapabilityProfile( trustedInternalHandoff: params.trustedInternalHandoff, senderPolicyMode: params.scheduledToolPolicy || isOwnerInternalSession ? "never" : "always", groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey, - requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy), + requireConfiguredGroupAccount: params.scheduledToolPolicy?.mode === "account", }); const { groupPolicy, senderPolicy, subagentPolicy, inheritedToolPolicy } = requesterPolicies; const profilePolicy = resolveToolProfilePolicy(effective.profile); diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index f85be329774d..b95eeef631ea 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -694,7 +694,7 @@ function resolvePluginHarnessToolPolicies( groupChannel: params.groupChannel, groupSpace: params.groupSpace, accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId, - requireConfiguredAccount: Boolean(params.scheduledToolPolicy), + requireConfiguredAccount: params.scheduledToolPolicy?.mode === "account", senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, diff --git a/src/agents/scheduled-tool-policy.test.ts b/src/agents/scheduled-tool-policy.test.ts index 737e8441f439..8fffb9b6c19a 100644 --- a/src/agents/scheduled-tool-policy.test.ts +++ b/src/agents/scheduled-tool-policy.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { resolveScheduledToolPolicyContext } from "./scheduled-tool-policy.js"; describe("resolveScheduledToolPolicyContext", () => { - it("requires a persisted cap and a trusted owner session/account pair", () => { + it("requires both a persisted cap and valid server provenance", () => { expect( resolveScheduledToolPolicyContext({ - ownerSessionKey: "agent:main:discord:group:ops", + scheduledToolPolicy: { version: 1, mode: "trusted" }, }), ).toBeUndefined(); expect( @@ -16,26 +16,28 @@ describe("resolveScheduledToolPolicyContext", () => { expect( resolveScheduledToolPolicyContext({ toolsAllow: ["write"], - ownerSessionKey: " ", - ownerAccountId: "work", + scheduledToolPolicy: { version: 2, mode: "trusted" }, }), ).toBeUndefined(); expect( - resolveScheduledToolPolicyContext({ - toolsAllow: ["write"], - ownerSessionKey: "agent:main:discord:group:ops", - }), + resolveScheduledToolPolicyContext({ toolsAllow: ["write"], scheduledToolPolicy: {} }), ).toBeUndefined(); }); - it("normalizes the trusted owner for explicitly capped runs", () => { + it("normalizes account provenance for explicitly capped runs", () => { expect( resolveScheduledToolPolicyContext({ toolsAllow: [], - ownerSessionKey: " agent:main:discord:group:ops ", - ownerAccountId: " work ", + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: " agent:main:discord:group:ops ", + ownerAccountId: " work ", + }, }), ).toEqual({ + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "work", }); diff --git a/src/agents/scheduled-tool-policy.ts b/src/agents/scheduled-tool-policy.ts index 01b4436c52b5..0fe5b15d2761 100644 --- a/src/agents/scheduled-tool-policy.ts +++ b/src/agents/scheduled-tool-policy.ts @@ -1,25 +1,18 @@ -/** - * Trusted runtime context for a scheduled run with a server-stamped tool cap. - * The owner session selects group policy; sender-specific policy was already - * projected into the immutable cap when the job was created. - */ -export type ScheduledToolPolicyContext = { - ownerSessionKey: string; - ownerAccountId: string; -}; +import { + normalizeCronScheduledToolPolicy, + type CronScheduledToolPolicy, +} from "../cron/scheduled-tool-policy.js"; + +/** Trusted runtime context for a scheduled run with a server-stamped tool cap. */ +export type ScheduledToolPolicyContext = CronScheduledToolPolicy; /** Builds scheduled policy context only when both the cap and trusted owner exist. */ export function resolveScheduledToolPolicyContext(params: { toolsAllow?: readonly string[]; - ownerSessionKey?: string | null; - ownerAccountId?: string | null; + scheduledToolPolicy?: unknown; }): ScheduledToolPolicyContext | undefined { if (params.toolsAllow === undefined) { return undefined; } - const ownerSessionKey = params.ownerSessionKey?.trim(); - const ownerAccountId = params.ownerAccountId?.trim(); - // Accountless jobs predate creator-account authority. Keep their shipped sender-policy path; - // inferring authority from a later delivery account would cross the account boundary. - return ownerSessionKey && ownerAccountId ? { ownerSessionKey, ownerAccountId } : undefined; + return normalizeCronScheduledToolPolicy(params.scheduledToolPolicy); } diff --git a/src/agents/web-search-tool-policy.ts b/src/agents/web-search-tool-policy.ts index fb822c7c48e6..f60a37e3df54 100644 --- a/src/agents/web-search-tool-policy.ts +++ b/src/agents/web-search-tool-policy.ts @@ -70,7 +70,7 @@ export function resolveWebSearchToolPolicy( groupChannel: params.groupChannel, groupSpace: params.groupSpace, accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId, - requireConfiguredAccount: Boolean(params.scheduledToolPolicy), + requireConfiguredAccount: params.scheduledToolPolicy?.mode === "account", senderPolicyMode: params.scheduledToolPolicy ? ("never" as const) : ("always" as const), }; const senderPolicyParams = { @@ -89,7 +89,7 @@ export function resolveWebSearchToolPolicy( trustedInternalHandoff: params.trustedInternalHandoff, senderPolicyMode: params.scheduledToolPolicy ? "never" : "always", groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey, - requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy), + requireConfiguredGroupAccount: params.scheduledToolPolicy?.mode === "account", }); const persistentGroupPolicy = requesterPolicies.delegated ? undefined diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index 7ced19ff8122..3ef5b8a82355 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -15,6 +15,7 @@ import { } from "./legacy-repair.js"; import { formatLegacyIssuePreview, + formatScheduledToolPolicyAdvisory, formatUnresolvedCommandPromptAdvisory, formatUnresolvedShellPromptAdvisory, } from "./repair-plan.js"; @@ -201,6 +202,29 @@ export async function collectLegacyCronStoreHealthFindings(params: { }), ); } + for (const [names, requirement, description] of [ + [ + normalized.legacyScheduledToolPolicyJobs, + "cron-scheduled-authority-reauthorization", + "require explicit scheduled authority reauthorization", + ], + [ + normalized.invalidScheduledToolPolicyJobs, + "cron-scheduled-authority-valid", + "have invalid scheduled authority provenance", + ], + ] as const) { + if (names.length > 0) { + findings.push( + legacyCronStoreFinding({ + message: `${pluralize(names.length, "tool-bearing cron job")} ${description}.`, + path: storePath, + requirement, + fixHint: `Review with ${formatCliCommand("openclaw cron list")} and reauthorize with ${formatCliCommand("openclaw cron edit --tools ")}.`, + }), + ); + } + } if (sqliteProjectionBackfillCount > 0) { findings.push( @@ -376,6 +400,13 @@ export async function maybeRepairLegacyCronStore(params: { if (shellPromptAdvisory) { note(shellPromptAdvisory, "Cron"); } + const scheduledToolPolicyAdvisory = formatScheduledToolPolicyAdvisory({ + legacyJobs: normalized.legacyScheduledToolPolicyJobs, + invalidJobs: normalized.invalidScheduledToolPolicyJobs, + }); + if (scheduledToolPolicyAdvisory) { + note(scheduledToolPolicyAdvisory, "Cron"); + } const previewLines = formatLegacyIssuePreview(normalized.issues); if (legacyStoreDetected) { previewLines.unshift( diff --git a/src/commands/doctor/cron/repair-plan.ts b/src/commands/doctor/cron/repair-plan.ts index 8ff7cd404e18..2e26e15913d3 100644 --- a/src/commands/doctor/cron/repair-plan.ts +++ b/src/commands/doctor/cron/repair-plan.ts @@ -53,6 +53,32 @@ export function formatUnresolvedShellPromptAdvisory(names: string[]): string | n ].join("\n"); } +/** Advisory for jobs whose scheduled authority cannot be recovered without a caller decision. */ +export function formatScheduledToolPolicyAdvisory(params: { + legacyJobs: string[]; + invalidJobs: string[]; +}): string | null { + const lines: string[] = []; + if (params.legacyJobs.length > 0) { + lines.push( + `${pluralize(params.legacyJobs.length, "tool-bearing cron job")} ${params.legacyJobs.length === 1 ? "keeps" : "keep"} legacy sender-policy resolution because stored account authority is not provable${formatJobNameList(params.legacyJobs)}.`, + ); + } + if (params.invalidJobs.length > 0) { + lines.push( + `${pluralize(params.invalidJobs.length, "tool-bearing cron job")} ${params.invalidJobs.length === 1 ? "has" : "have"} invalid or inconsistent scheduled authority provenance${formatJobNameList(params.invalidJobs)}.`, + ); + } + if (lines.length === 0) { + return null; + } + lines.push( + "- These jobs continue through restrictive sender-policy resolution; doctor will not infer authority from delivery or current configuration.", + "- Reauthorize with `openclaw cron edit --tools `, or use `--clear-tools` to adopt the current default cap.", + ); + return lines.join("\n"); +} + /** Convert legacy cron issue counts into doctor preview lines. */ export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] { const lines: string[] = []; @@ -106,6 +132,11 @@ export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[ `- ${pluralize(issues.legacyDeliveryMode, "job")} still uses delivery mode \`deliver\``, ); } + if (issues.migratedScheduledToolPolicy) { + lines.push( + `- ${pluralize(issues.migratedScheduledToolPolicy, "job")} can recover scheduled account authority from persisted owner identity`, + ); + } if (issues.invalidSchedule) { lines.push( `- ${pluralize(issues.invalidSchedule, "job")} has an invalid persisted schedule and will be removed`, @@ -190,6 +221,7 @@ export function needsSqliteProjectionBackfill(params: { "name", "payload", "schedule", + "scheduledToolPolicy", "sessionKey", "sessionTarget", "wakeMode", diff --git a/src/commands/doctor/cron/scheduled-tool-policy-migration.test.ts b/src/commands/doctor/cron/scheduled-tool-policy-migration.test.ts new file mode 100644 index 000000000000..d162b2d17d0f --- /dev/null +++ b/src/commands/doctor/cron/scheduled-tool-policy-migration.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { formatScheduledToolPolicyAdvisory } from "./repair-plan.js"; +import { migrateScheduledToolPolicy } from "./scheduled-tool-policy-migration.js"; +import { normalizeStoredCronJobs } from "./store-migration.js"; + +function job(overrides: Record = {}): Record { + return { + id: "legacy", + name: "Legacy", + enabled: true, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + owner: { + agentId: "main", + sessionKey: "agent:main:discord:group:ops", + accountId: "work", + }, + payload: { kind: "agentTurn", message: "run", toolsAllow: ["write"] }, + ...overrides, + }; +} + +describe("migrateScheduledToolPolicy", () => { + it("recovers an account from the persisted owner pair", () => { + const raw = job(); + expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: true, status: "migrated" }); + expect(raw.scheduledToolPolicy).toEqual({ + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }); + }); + + it("recovers an account structurally encoded in a direct-session key", () => { + const raw = job({ + owner: { + agentId: "main", + sessionKey: "agent:main:discord:work:direct:user-1", + }, + }); + expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: true, status: "migrated" }); + expect(raw.owner).toMatchObject({ accountId: "work" }); + }); + + it.each([ + { + label: "agent mismatch", + owner: { + agentId: "other", + sessionKey: "agent:main:discord:work:direct:user-1", + accountId: "work", + }, + }, + { + label: "encoded account mismatch", + owner: { + agentId: "main", + sessionKey: "agent:main:discord:work:direct:user-1", + accountId: "personal", + }, + }, + { + label: "accountless owner", + owner: { agentId: "main", sessionKey: "agent:main:discord:group:ops" }, + }, + ])("does not guess authority for $label", ({ owner }) => { + const raw = job({ owner }); + expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: false, status: "legacy" }); + expect(raw.scheduledToolPolicy).toBeUndefined(); + }); + + it("keeps capless historical jobs on legacy sender policy", () => { + const raw = job({ payload: { kind: "agentTurn", message: "run" } }); + expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: false, status: "legacy" }); + }); + + it("rejects malformed and owner-inconsistent provenance", () => { + const malformed = job({ scheduledToolPolicy: { version: 2, mode: "trusted" } }); + expect(migrateScheduledToolPolicy(malformed)).toEqual({ + mutated: false, + status: "invalid", + }); + + const inconsistent = job({ + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "personal", + }, + }); + expect(migrateScheduledToolPolicy(inconsistent)).toEqual({ + mutated: false, + status: "invalid", + }); + }); + + it("preserves valid trusted provenance", () => { + const raw = job({ scheduledToolPolicy: { version: 1, mode: "trusted" } }); + expect(migrateScheduledToolPolicy(raw)).toEqual({ mutated: false, status: "current" }); + }); + + it("reports auto-recoverable and ambiguous jobs through the doctor result", () => { + const recoverable = job(); + const ambiguous = job({ + id: "ambiguous", + name: "Ambiguous", + owner: { agentId: "main", sessionKey: "agent:main:discord:group:ops" }, + }); + const result = normalizeStoredCronJobs([recoverable, ambiguous]); + + expect(result.issues.migratedScheduledToolPolicy).toBe(1); + expect(result.legacyScheduledToolPolicyJobs).toEqual(["Ambiguous"]); + expect( + formatScheduledToolPolicyAdvisory({ + legacyJobs: result.legacyScheduledToolPolicyJobs, + invalidJobs: result.invalidScheduledToolPolicyJobs, + }), + ).toContain("openclaw cron edit --tools"); + }); +}); diff --git a/src/commands/doctor/cron/scheduled-tool-policy-migration.ts b/src/commands/doctor/cron/scheduled-tool-policy-migration.ts new file mode 100644 index 000000000000..17434907e272 --- /dev/null +++ b/src/commands/doctor/cron/scheduled-tool-policy-migration.ts @@ -0,0 +1,109 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + createAccountCronScheduledToolPolicy, + normalizeCronScheduledToolPolicy, + resolveCronScheduledToolPolicy, +} from "../../../cron/scheduled-tool-policy.js"; +import { normalizeOptionalAccountId } from "../../../routing/account-id.js"; +import { normalizeAgentId, parseSessionDeliveryRoute } from "../../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../../sessions/session-key-utils.js"; + +export type ScheduledToolPolicyMigrationResult = { + mutated: boolean; + status: "current" | "migrated" | "legacy" | "invalid" | "not-applicable"; +}; + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function usesToolRuntime(raw: Record): boolean { + const payload = readRecord(raw.payload); + const trigger = readRecord(raw.trigger); + return ( + payload?.kind === "agentTurn" || + payload?.kind === "script" || + (typeof trigger?.script === "string" && trigger.script.trim().length > 0) + ); +} + +/** Recovers only account authority proven by immutable persisted owner identity. */ +export function migrateScheduledToolPolicy( + raw: Record, +): ScheduledToolPolicyMigrationResult { + if (!usesToolRuntime(raw)) { + return { + mutated: false, + status: raw.scheduledToolPolicy === undefined ? "not-applicable" : "invalid", + }; + } + const payload = readRecord(raw.payload); + const toolsAllow = + Array.isArray(payload?.toolsAllow) && + payload.toolsAllow.every((value): value is string => typeof value === "string") + ? payload.toolsAllow + : undefined; + const owner = readRecord(raw.owner); + const ownerSessionKey = normalizeOptionalString(owner?.sessionKey); + const ownerAccountId = normalizeOptionalAccountId( + typeof owner?.accountId === "string" ? owner.accountId : undefined, + ); + + if (raw.scheduledToolPolicy !== undefined) { + const normalized = normalizeCronScheduledToolPolicy(raw.scheduledToolPolicy); + const resolved = resolveCronScheduledToolPolicy({ + toolsAllow, + scheduledToolPolicy: normalized, + owner: { sessionKey: ownerSessionKey, accountId: ownerAccountId }, + }); + if (!resolved) { + return { mutated: false, status: "invalid" }; + } + const mutated = JSON.stringify(raw.scheduledToolPolicy) !== JSON.stringify(resolved); + if (mutated) { + raw.scheduledToolPolicy = resolved; + } + return { mutated, status: "current" }; + } + + // Capless historical jobs have no bounded authority to recover. They remain + // on legacy sender-policy resolution until an operator explicitly edits tools. + if (!toolsAllow || !ownerSessionKey) { + return { mutated: false, status: "legacy" }; + } + const parsedSession = parseAgentSessionKey(ownerSessionKey); + if (!parsedSession) { + return { mutated: false, status: "legacy" }; + } + const ownerAgentId = normalizeOptionalString(owner?.agentId); + if (ownerAgentId && normalizeAgentId(ownerAgentId) !== normalizeAgentId(parsedSession.agentId)) { + return { mutated: false, status: "legacy" }; + } + const encodedAccountId = normalizeOptionalAccountId( + parseSessionDeliveryRoute(ownerSessionKey)?.accountId, + ); + if (ownerAccountId && encodedAccountId && ownerAccountId !== encodedAccountId) { + return { mutated: false, status: "legacy" }; + } + const recoveredAccountId = ownerAccountId ?? encodedAccountId; + if (!recoveredAccountId) { + return { mutated: false, status: "legacy" }; + } + const scheduledToolPolicy = createAccountCronScheduledToolPolicy({ + ownerSessionKey, + ownerAccountId: recoveredAccountId, + }); + if (!scheduledToolPolicy) { + return { mutated: false, status: "legacy" }; + } + raw.owner = { + ...owner, + ...(ownerAgentId ? { agentId: normalizeAgentId(ownerAgentId) } : {}), + sessionKey: ownerSessionKey, + accountId: recoveredAccountId, + }; + raw.scheduledToolPolicy = scheduledToolPolicy; + return { mutated: true, status: "migrated" }; +} diff --git a/src/commands/doctor/cron/store-migration.ts b/src/commands/doctor/cron/store-migration.ts index 865277ddc93d..b50103453aa3 100644 --- a/src/commands/doctor/cron/store-migration.ts +++ b/src/commands/doctor/cron/store-migration.ts @@ -25,6 +25,7 @@ import { migrateLegacyAgentTurnCommandPayload, migrateLegacyCronPayload, } from "./payload-migration.js"; +import { migrateScheduledToolPolicy } from "./scheduled-tool-policy-migration.js"; type CronStoreIssueKey = | "jobId" @@ -40,6 +41,7 @@ type CronStoreIssueKey = | "legacyTopLevelPayloadFields" | "legacyTopLevelDeliveryFields" | "legacyDeliveryMode" + | "migratedScheduledToolPolicy" | "invalidSchedule" | "invalidPayload"; @@ -95,6 +97,8 @@ type NormalizeCronStoreJobsResult = { issues: CronStoreIssues; unresolvedAgentTurnCommandPromptJobs: string[]; unresolvedAgentTurnShellToolPromptJobs: string[]; + legacyScheduledToolPolicyJobs: string[]; + invalidScheduledToolPolicyJobs: string[]; jobs: Array>; mutated: boolean; removedJobs: Array<{ job: Record; reason: string; sourceIndex: number }>; @@ -306,6 +310,8 @@ export function normalizeStoredCronJobs( const issues: CronStoreIssues = {}; const unresolvedAgentTurnCommandPromptJobs: string[] = []; const unresolvedAgentTurnShellToolPromptJobs: string[] = []; + const legacyScheduledToolPolicyJobs: string[] = []; + const invalidScheduledToolPolicyJobs: string[] = []; const unresolvedAgentTurnPromptJobsByKind = { commandPromptWithoutShellAccess: unresolvedAgentTurnCommandPromptJobs, shellToolPrompt: unresolvedAgentTurnShellToolPromptJobs, @@ -698,6 +704,20 @@ export function normalizeStoredCronJobs( mutated = true; } + const scheduledPolicyMigration = migrateScheduledToolPolicy(raw); + if (scheduledPolicyMigration.mutated) { + mutated = true; + } + const scheduledPolicyJobName = + normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); + if (scheduledPolicyMigration.status === "migrated") { + trackIssue("migratedScheduledToolPolicy"); + } else if (scheduledPolicyMigration.status === "legacy" && scheduledPolicyJobName) { + legacyScheduledToolPolicyJobs.push(scheduledPolicyJobName); + } else if (scheduledPolicyMigration.status === "invalid" && scheduledPolicyJobName) { + invalidScheduledToolPolicyJobs.push(scheduledPolicyJobName); + } + const invalidPersistedReason = getInvalidPersistedCronJobReason(raw); if ( invalidPersistedReason === "missing-schedule" || @@ -729,6 +749,8 @@ export function normalizeStoredCronJobs( issues, unresolvedAgentTurnCommandPromptJobs, unresolvedAgentTurnShellToolPromptJobs, + legacyScheduledToolPolicyJobs, + invalidScheduledToolPolicyJobs, jobs, mutated, removedJobs, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index b8e3f9f88b3b..3c4db0ab0e4b 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -10,6 +10,7 @@ import type { SessionObserverDigest } from "../../../packages/gateway-protocol/s import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; +import type { CronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js"; import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import type { Skill } from "../../skills/loading/skill-contract.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; @@ -386,10 +387,8 @@ export type SessionEntry = SessionRestartRecoveryState & cliExecutionProvider?: string; toolsAllow?: string[]; toolsAllowIsDefault?: boolean; - /** Server-stamped creator session used to resolve sender-independent group policy. */ - ownerSessionKey?: string; - /** Server-stamped creator account paired with ownerSessionKey. */ - ownerAccountId?: string; + /** Exact server-stamped authority provenance copied from the owning cron job. */ + scheduledToolPolicy?: CronScheduledToolPolicy; cliSessionBindingFacts?: { extraSystemPromptStatic?: string; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 82de4f208756..12f7e98e2ce6 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -8,7 +8,6 @@ import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; import { wrapUntrustedPromptDataBlock } from "../../agents/sanitize-for-prompt.js"; -import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js"; import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-placement-admission.js"; import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js"; import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js"; @@ -27,6 +26,7 @@ import { getGeneratedMediaTaskIdsForSessionKey, hasNewGeneratedMediaTaskForSessionKey, } from "../../tasks/task-status-access.js"; +import { resolveCronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import type { CronAgentExecutionPhaseUpdate, CronJob } from "../types.js"; import { resolveCronChannelOutputPolicy, @@ -274,10 +274,10 @@ function createCronPromptExecutor(params: { params.cronSession.sessionEntry.systemPromptReport, ); const bootstrapContextMode = resolveCronBootstrapContextMode(params.agentPayload); - const scheduledToolPolicy = resolveScheduledToolPolicyContext({ + const scheduledToolPolicy = resolveCronScheduledToolPolicy({ toolsAllow: params.agentPayload?.toolsAllow, - ownerSessionKey: params.job.owner?.sessionKey, - ownerAccountId: params.job.owner?.accountId, + scheduledToolPolicy: params.job.scheduledToolPolicy, + owner: params.job.owner, }); if (!params.sourceDelivery) { logWarn( diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts index cb678f3a78a3..a47f067c35cc 100644 --- a/src/cron/isolated-agent/run-session-state.test.ts +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -145,8 +145,12 @@ describe("createPersistCronSessionEntry", () => { thinkingLevel: "high", toolsAllow: ["image_generate", "write"], toolsAllowIsDefault: true, - ownerSessionKey: "agent:main:discord:group:ops", - ownerAccountId: "work", + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }, persistSessionEntry, }); @@ -172,8 +176,12 @@ describe("createPersistCronSessionEntry", () => { phase: "running", toolsAllow: ["image_generate", "write"], toolsAllowIsDefault: true, - ownerSessionKey: "agent:main:discord:group:ops", - ownerAccountId: "work", + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }, }, }); diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index 73c2119b5fd2..c3495f8db109 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import { isDeepStrictEqual } from "node:util"; import { clearBootstrapSnapshotOnSessionBoundary } from "../../agents/bootstrap-cache.js"; import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js"; +import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js"; import type { SessionEntry } from "../../config/sessions.js"; import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js"; @@ -10,6 +11,7 @@ import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marke import { isCronSessionKey } from "../../sessions/session-key-utils.js"; import { isSessionWorkAdmissionActive } from "../../sessions/session-lifecycle-admission.js"; import type { SkillSnapshot } from "../../skills/types.js"; +import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import type { resolveCronSession } from "./session.js"; type MutableSessionStore = Record; @@ -203,8 +205,7 @@ export function createCronRunContinuationSession(params: { thinkingLevel?: string; toolsAllow?: string[]; toolsAllowIsDefault?: boolean; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: CronScheduledToolPolicy; cliSessionBindingFacts?: { extraSystemPromptStatic?: string; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; @@ -212,19 +213,16 @@ export function createCronRunContinuationSession(params: { }; persistSessionEntry: PersistSessionEntry; }): CronRunContinuationSession { + const scheduledToolPolicy = resolveScheduledToolPolicyContext({ + toolsAllow: params.toolsAllow, + scheduledToolPolicy: params.scheduledToolPolicy, + }); const continuation: NonNullable = { lifecycleRevision: params.cronSession.lifecycleRevision, phase: "running" as const, ...(params.toolsAllow !== undefined ? { toolsAllow: [...params.toolsAllow] } : {}), ...(params.toolsAllowIsDefault === true ? { toolsAllowIsDefault: true } : {}), - ...(params.toolsAllow !== undefined && - params.ownerSessionKey?.trim() && - params.ownerAccountId?.trim() - ? { - ownerSessionKey: params.ownerSessionKey.trim(), - ownerAccountId: params.ownerAccountId.trim(), - } - : {}), + ...(scheduledToolPolicy ? { scheduledToolPolicy } : {}), ...(params.cliSessionBindingFacts ? { cliSessionBindingFacts: { ...params.cliSessionBindingFacts } } : {}), diff --git a/src/cron/isolated-agent/run.tools-allow.test.ts b/src/cron/isolated-agent/run.tools-allow.test.ts index ba4fee3e9735..6e413751abf3 100644 --- a/src/cron/isolated-agent/run.tools-allow.test.ts +++ b/src/cron/isolated-agent/run.tools-allow.test.ts @@ -52,6 +52,12 @@ function makeParamsWithToolsAllow(toolsAllow: string[]) { ...params, job: { ...job, + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:whatsapp:group:team", + ownerAccountId: "default", + }, payload: { kind: "agentTurn", message: "check allowed tools", @@ -68,6 +74,12 @@ function makeParamsWithDefaultToolsAllow(toolsAllow: string[]) { ...params, job: { ...job, + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:whatsapp:group:team", + ownerAccountId: "default", + }, payload: { kind: "agentTurn", message: "check allowed tools", @@ -81,13 +93,23 @@ function makeParamsWithDefaultToolsAllow(toolsAllow: string[]) { function requireEmbeddedAgentCall(): { jobId?: string; toolsAllow?: string[]; - scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string }; + scheduledToolPolicy?: { + version: 1; + mode: "account"; + ownerSessionKey: string; + ownerAccountId: string; + }; } { const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as | { jobId?: string; toolsAllow?: string[]; - scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string }; + scheduledToolPolicy?: { + version: 1; + mode: "account"; + ownerSessionKey: string; + ownerAccountId: string; + }; } | undefined; if (!call) { @@ -164,6 +186,8 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { expect(call.jobId).toBe("tools-allow"); expect(call.toolsAllow).toEqual(["cron"]); expect(call.scheduledToolPolicy).toEqual({ + version: 1, + mode: "account", ownerSessionKey: "agent:main:whatsapp:group:team", ownerAccountId: "default", }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 04f7a1c481bb..ccbc9f8dc79b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -70,6 +70,7 @@ import { mergeCronRunDiagnostics, toolsAllowRequestsWebSearch, } from "../run-diagnostics.js"; +import { resolveCronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import { resolveCronAbortReasonText } from "../service/execution-errors.js"; import { isDetachedCronSessionTarget, resolveCronDeliverySessionKey } from "../session-target.js"; import type { @@ -1126,8 +1127,11 @@ async function prepareCronRunContext(params: { thinkingLevel: requestedThinkLevel, toolsAllow: agentPayload?.toolsAllow, toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault, - ownerSessionKey: input.job.owner?.sessionKey, - ownerAccountId: input.job.owner?.accountId, + scheduledToolPolicy: resolveCronScheduledToolPolicy({ + toolsAllow: agentPayload?.toolsAllow, + scheduledToolPolicy: input.job.scheduledToolPolicy, + owner: input.job.owner, + }), cliSessionBindingFacts: { sourceReplyDeliveryMode: sourceDelivery.sourceReplyDeliveryMode, requireExplicitMessageTarget: sourceDelivery.messageTool.requireExplicitTarget, diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index d71214f3aac3..6fda66eeec21 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -14,6 +14,7 @@ import { parseDeliveryInput } from "./delivery-field-schemas.js"; import { normalizeCronCommandArgv, normalizeCronPayload } from "./normalize-payload.js"; import { parseAbsoluteTimeMs } from "./parse.js"; import { coerceFiniteScheduleNumber } from "./schedule-number.js"; +import { normalizeCronScheduledToolPolicy } from "./scheduled-tool-policy.js"; import { inferCronJobName } from "./service/normalize.js"; import { assertSafeCronSessionTargetId, @@ -385,6 +386,15 @@ export function normalizeCronJobInput( } } + if ("scheduledToolPolicy" in base) { + const scheduledToolPolicy = normalizeCronScheduledToolPolicy(base.scheduledToolPolicy); + if (scheduledToolPolicy) { + next.scheduledToolPolicy = scheduledToolPolicy; + } else { + delete next.scheduledToolPolicy; + } + } + if ("agentId" in base) { const agentId = base.agentId; if (agentId === null) { diff --git a/src/cron/scheduled-tool-policy.test.ts b/src/cron/scheduled-tool-policy.test.ts new file mode 100644 index 000000000000..43dca6906d24 --- /dev/null +++ b/src/cron/scheduled-tool-policy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + createAccountCronScheduledToolPolicy, + normalizeCronScheduledToolPolicy, + resolveCronScheduledToolPolicy, +} from "./scheduled-tool-policy.js"; + +describe("cron scheduled tool policy", () => { + it("accepts only the closed current version", () => { + expect(normalizeCronScheduledToolPolicy({ version: 1, mode: "trusted" })).toEqual({ + version: 1, + mode: "trusted", + }); + expect(normalizeCronScheduledToolPolicy({ version: 2, mode: "trusted" })).toBeUndefined(); + expect( + normalizeCronScheduledToolPolicy({ version: 1, mode: "trusted", ownerAccountId: "work" }), + ).toBeUndefined(); + }); + + it("requires account provenance to match the persisted owner", () => { + const policy = createAccountCronScheduledToolPolicy({ + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }); + expect( + resolveCronScheduledToolPolicy({ + toolsAllow: ["write"], + scheduledToolPolicy: policy, + owner: { + sessionKey: "agent:main:discord:group:ops", + accountId: "work", + }, + }), + ).toEqual(policy); + expect( + resolveCronScheduledToolPolicy({ + toolsAllow: ["write"], + scheduledToolPolicy: policy, + owner: { + sessionKey: "agent:main:discord:group:ops", + accountId: "personal", + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/cron/scheduled-tool-policy.ts b/src/cron/scheduled-tool-policy.ts new file mode 100644 index 000000000000..3681cb5a4fad --- /dev/null +++ b/src/cron/scheduled-tool-policy.ts @@ -0,0 +1,86 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalAccountId } from "../routing/account-id.js"; + +/** Server-authored provenance for a persisted scheduled tool-cap authority envelope. */ +export type CronScheduledToolPolicy = + | { + version: 1; + mode: "trusted"; + ownerSessionKey?: never; + ownerAccountId?: never; + } + | { + version: 1; + mode: "account"; + ownerSessionKey: string; + ownerAccountId: string; + }; + +/** Creates provenance for an authenticated operator or trusted in-process caller. */ +export function createTrustedCronScheduledToolPolicy(): CronScheduledToolPolicy { + return { version: 1, mode: "trusted" }; +} + +/** Creates requester-scoped provenance from an authenticated account identity. */ +export function createAccountCronScheduledToolPolicy(params: { + ownerSessionKey: string; + ownerAccountId: string; +}): CronScheduledToolPolicy | undefined { + const ownerSessionKey = normalizeOptionalString(params.ownerSessionKey); + const ownerAccountId = normalizeOptionalAccountId(params.ownerAccountId); + if (!ownerSessionKey || !ownerAccountId) { + return undefined; + } + return { version: 1, mode: "account", ownerSessionKey, ownerAccountId }; +} + +/** Accepts only the current closed provenance shape; unknown versions fail closed. */ +export function normalizeCronScheduledToolPolicy( + value: unknown, +): CronScheduledToolPolicy | undefined { + if (!isRecord(value) || value.version !== 1) { + return undefined; + } + if (value.mode === "trusted") { + return Object.keys(value).every((key) => key === "version" || key === "mode") + ? createTrustedCronScheduledToolPolicy() + : undefined; + } + if (value.mode !== "account") { + return undefined; + } + const policy = createAccountCronScheduledToolPolicy({ + ownerSessionKey: typeof value.ownerSessionKey === "string" ? value.ownerSessionKey : "", + ownerAccountId: typeof value.ownerAccountId === "string" ? value.ownerAccountId : "", + }); + if (!policy) { + return undefined; + } + return Object.keys(value).every( + (key) => + key === "version" || key === "mode" || key === "ownerSessionKey" || key === "ownerAccountId", + ) + ? policy + : undefined; +} + +/** Resolves trusted provenance only when it is consistent with the persisted job owner. */ +export function resolveCronScheduledToolPolicy(params: { + toolsAllow?: readonly string[]; + scheduledToolPolicy?: unknown; + owner?: { sessionKey?: string; accountId?: string }; +}): CronScheduledToolPolicy | undefined { + if (params.toolsAllow === undefined) { + return undefined; + } + const policy = normalizeCronScheduledToolPolicy(params.scheduledToolPolicy); + if (!policy || policy.mode === "trusted") { + return policy; + } + const ownerSessionKey = normalizeOptionalString(params.owner?.sessionKey); + const ownerAccountId = normalizeOptionalAccountId(params.owner?.accountId); + return ownerSessionKey === policy.ownerSessionKey && ownerAccountId === policy.ownerAccountId + ? policy + : undefined; +} diff --git a/src/cron/service-contract.ts b/src/cron/service-contract.ts index db2d356c0354..39733ec9ff8e 100644 --- a/src/cron/service-contract.ts +++ b/src/cron/service-contract.ts @@ -10,6 +10,7 @@ import type { CronRunResult, CronStatusSummary, CronUpdateInput, + CronUpdateOptions, CronUpdatePrecondition, CronUpdateResult, CronWakeMode, @@ -41,11 +42,12 @@ export interface CronServiceContract { list(opts?: { includeDisabled?: boolean }): Promise; listPage(opts?: CronListPageOptions): Promise; add(input: CronAddInput, opts?: CronAddOptions): Promise; - update(id: string, patch: CronUpdateInput): Promise; + update(id: string, patch: CronUpdateInput, opts?: CronUpdateOptions): Promise; updateWithPrecondition( id: string, patch: CronUpdateInput, precondition: CronUpdatePrecondition, + opts?: CronUpdateOptions, ): Promise; remove(id: string, opts?: { systemOwned?: boolean }): Promise; run(id: string, mode?: CronRunMode, opts?: CronServiceRunOptions): Promise; diff --git a/src/cron/service.ts b/src/cron/service.ts index c5fbc809c3a7..e7c8675e1a7b 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -10,6 +10,7 @@ import { type CronAddOptions, type CronServiceDeps, type CronUpdatePrecondition, + type CronUpdateOptions, type CronWakeMode, createCronServiceState, } from "./service/state.js"; @@ -105,16 +106,17 @@ export class CronService implements CronServiceContract { return await ops.add(this.state, input, opts); } - async update(id: string, patch: CronJobPatch) { - return await ops.update(this.state, id, patch); + async update(id: string, patch: CronJobPatch, opts?: CronUpdateOptions) { + return await ops.update(this.state, id, patch, opts); } async updateWithPrecondition( id: string, patch: CronJobPatch, precondition: CronUpdatePrecondition, + opts?: CronUpdateOptions, ) { - return await ops.updateWithPrecondition(this.state, id, patch, precondition); + return await ops.updateWithPrecondition(this.state, id, patch, precondition, opts); } async remove(id: string, opts?: { systemOwned?: boolean }) { diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 51a8bed2a22c..3e662c2c01ec 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -19,6 +19,11 @@ import { computeNextRunAtMs, computePreviousRunAtMs, } from "../schedule.js"; +import { + createTrustedCronScheduledToolPolicy, + resolveCronScheduledToolPolicy, + type CronScheduledToolPolicy, +} from "../scheduled-tool-policy.js"; import { normalizeCronScriptPayload } from "../script-payload.js"; import { assertSafeCronSessionTargetId } from "../session-target.js"; import { @@ -1046,8 +1051,58 @@ export function nextWakeAtMs(state: CronServiceState) { }, first); } +/** Applies one canonical server-authored authority envelope to a tool-bearing job. */ +function stampScheduledToolPolicy( + job: CronJob, + scheduledToolPolicy: CronScheduledToolPolicy | undefined, +): void { + if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) { + delete job.scheduledToolPolicy; + return; + } + const policy = scheduledToolPolicy ?? createTrustedCronScheduledToolPolicy(); + if ( + policy.mode === "account" && + (job.owner?.sessionKey !== policy.ownerSessionKey || + job.owner?.accountId !== policy.ownerAccountId) + ) { + throw new Error("scheduled account policy must match the persisted job owner"); + } + job.scheduledToolPolicy = structuredClone(policy); +} + +function reconcileScheduledToolPolicy(params: { + job: CronJob; + previouslyUsedToolRuntime: boolean; + explicitlyMutatesToolsAllow: boolean; + scheduledToolPolicy?: CronScheduledToolPolicy; +}): void { + const { job } = params; + if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) { + delete job.scheduledToolPolicy; + return; + } + const current = resolveCronScheduledToolPolicy({ + toolsAllow: job.payload.toolsAllow, + scheduledToolPolicy: job.scheduledToolPolicy, + owner: job.owner, + }); + if (current) { + job.scheduledToolPolicy = current; + return; + } + delete job.scheduledToolPolicy; + if (params.explicitlyMutatesToolsAllow || !params.previouslyUsedToolRuntime) { + stampScheduledToolPolicy(job, params.scheduledToolPolicy); + } +} + /** Creates a normalized cron job row from public add input and computes its initial schedule. */ -export function createJob(state: CronServiceState, input: CronJobCreate): CronJob { +export function createJob( + state: CronServiceState, + input: CronJobCreate, + opts?: { scheduledToolPolicy?: CronScheduledToolPolicy }, +): CronJob { const now = state.deps.nowMs(); const id = normalizeOptionalString(input.id) ?? crypto.randomUUID(); const schedule = @@ -1141,6 +1196,7 @@ 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); + stampScheduledToolPolicy(job, opts?.scheduledToolPolicy); assertSupportedJobSpec(job); assertPacingSupport(job); assertTriggerSupport(job, { @@ -1171,6 +1227,7 @@ export function applyJobPatch( defaultAgentId?: string; scheduleValidationNowMs?: number; cronConfig?: CronConfig; + scheduledToolPolicy?: CronScheduledToolPolicy; }, ) { const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job); @@ -1268,6 +1325,13 @@ export function applyJobPatch( // Ordinary edits to an existing capless job intentionally remain legacy. applyDefaultCronToolsAllow(job); } + reconcileScheduledToolPolicy({ + job, + previouslyUsedToolRuntime, + explicitlyMutatesToolsAllow: + patch.payload !== undefined && Object.hasOwn(patch.payload, "toolsAllow"), + scheduledToolPolicy: opts?.scheduledToolPolicy, + }); if (patch.delivery) { const implicitMode = resolveCronDeliveryPlan(job).mode; job.delivery = mergeCronDelivery(job.delivery, patch.delivery, implicitMode); @@ -1352,9 +1416,11 @@ export function applyDeclarativeJobSpec( enabledExplicit: boolean; nowMs: number; cronConfig?: CronConfig; + scheduledToolPolicy?: CronScheduledToolPolicy; }, ) { const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job); + const explicitlyDeclaresToolsAllow = input.payload.toolsAllow !== undefined; const previousToolsAllow = job.payload.toolsAllow; const previousToolsAllowIsDefault = job.payload.toolsAllowIsDefault; // Name, target, routing, owner, and run policy remain outside declaration @@ -1424,6 +1490,12 @@ export function applyDeclarativeJobSpec( applyDefaultCronToolsAllow(job); } } + reconcileScheduledToolPolicy({ + job, + previouslyUsedToolRuntime, + explicitlyMutatesToolsAllow: explicitlyDeclaresToolsAllow, + scheduledToolPolicy: opts.scheduledToolPolicy, + }); const delivery = resolveInitialCronDelivery(input); if (delivery) { job.delivery = structuredClone(delivery); diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index 33eb9f538e5a..ddad0ebe940b 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -22,6 +22,95 @@ const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-service-ops-seam", }); +describe("scheduled tool policy provenance", () => { + it("stamps trusted and authenticated-account creates", async () => { + const { storePath } = await makeStorePath(); + const now = Date.parse("2026-07-23T12:00:00.000Z"); + const state = createOkIsolatedCronState({ storePath, now }); + const base = { + enabled: true, + schedule: { kind: "every" as const, everyMs: 60_000 }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + payload: { kind: "agentTurn" as const, message: "run", toolsAllow: ["write"] }, + }; + + const trusted = await add(state, { ...base, name: "trusted" }); + expect(trusted.scheduledToolPolicy).toEqual({ version: 1, mode: "trusted" }); + + const account = await add( + state, + { + ...base, + name: "account", + owner: { + agentId: "main", + sessionKey: "agent:main:discord:group:ops", + accountId: "work", + }, + }, + { + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }, + }, + ); + expect(account.scheduledToolPolicy).toEqual({ + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }); + if (state.timer) { + clearTimeout(state.timer); + } + }); + + it("keeps routine legacy edits restrictive and adopts authority on an explicit tool edit", async () => { + const { storePath } = await makeStorePath(); + const now = Date.parse("2026-07-23T12:00:00.000Z"); + const state = createOkIsolatedCronState({ storePath, now }); + const created = await add(state, { + name: "legacy", + enabled: true, + owner: { + agentId: "main", + sessionKey: "agent:main:discord:group:ops", + accountId: "work", + }, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "run", toolsAllow: ["write"] }, + }); + delete created.scheduledToolPolicy; + + const routine = await update(state, created.id, { description: "routine" }); + expect(routine.scheduledToolPolicy).toBeUndefined(); + + const reauthorized = await update( + state, + created.id, + { payload: { kind: "agentTurn", toolsAllow: ["write"] } }, + { + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }, + }, + ); + expect(reauthorized.scheduledToolPolicy?.mode).toBe("account"); + if (state.timer) { + clearTimeout(state.timer); + } + }); +}); + async function withStateDirForStorePath( storePath: string, runWithStateDir: () => Promise, diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index acaf664834dc..431914b7cf0e 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -84,6 +84,7 @@ import type { CronAddOptions, CronEvent, CronServiceState, + CronUpdateOptions, CronUpdatePrecondition, CronWakeMode, } from "./state.js"; @@ -756,6 +757,7 @@ function declarativeFields(job: CronJob, includeEnabled: boolean) { pacing: job.pacing, trigger: job.trigger, payload: job.payload, + scheduledToolPolicy: job.scheduledToolPolicy, delivery: job.delivery, displayName: job.displayName, ...(includeEnabled ? { enabled: job.enabled } : {}), @@ -811,6 +813,7 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?: enabledExplicit: opts?.enabledExplicit === true, nowMs: now, cronConfig: state.deps.cronConfig, + scheduledToolPolicy: opts?.scheduledToolPolicy, }); const includeEnabled = opts?.enabledExplicit === true; if ( @@ -837,7 +840,9 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?: throw new Error(`cron job already exists: ${normalizedId}`); } const snapshot = snapshotStoreForRollback(state); - const job = createJob(state, normalizedInput); + const job = createJob(state, normalizedInput, { + scheduledToolPolicy: opts?.scheduledToolPolicy, + }); state.store?.jobs.push(job); // Auto-disable notifications describe durable state, so publish them only @@ -880,8 +885,9 @@ async function updateLoadedJob(params: { id: string; patch: CronJobPatch; precondition?: CronUpdatePrecondition; + opts?: CronUpdateOptions; }) { - const { state, id, patch, precondition } = params; + const { state, id, patch, precondition, opts } = params; warnIfDisabled(state, "update"); // Mirrors the add-time boundary: no caller may patch a job into (or edit) // the system-owned heartbeat payload; the gateway converges via add only. @@ -907,6 +913,7 @@ async function updateLoadedJob(params: { defaultAgentId: state.deps.defaultAgentId, scheduleValidationNowMs: now, cronConfig: state.deps.cronConfig, + scheduledToolPolicy: opts?.scheduledToolPolicy, }); if (patch.agentId !== undefined) { const agentId = resolveEffectiveJobAgentId(nextJob, resolveCurrentDefaultAgentId(state)); @@ -930,8 +937,13 @@ async function updateLoadedJob(params: { } /** Updates a cron job patch in-place, recomputes affected schedule state, and persists it. */ -export async function update(state: CronServiceState, id: string, patch: CronJobPatch) { - return await locked(state, async () => await updateLoadedJob({ state, id, patch })); +export async function update( + state: CronServiceState, + id: string, + patch: CronJobPatch, + opts?: CronUpdateOptions, +) { + return await locked(state, async () => await updateLoadedJob({ state, id, patch, opts })); } /** Updates a cron job only after a store-locked caller precondition passes. */ @@ -940,8 +952,12 @@ export async function updateWithPrecondition( id: string, patch: CronJobPatch, precondition: CronUpdatePrecondition, + opts?: CronUpdateOptions, ) { - return await locked(state, async () => await updateLoadedJob({ state, id, patch, precondition })); + return await locked( + state, + async () => await updateLoadedJob({ state, id, patch, precondition, opts }), + ); } /** Removes a cron job by id and re-arms the timer when the in-memory store changes. */ diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index c3023958a772..a613fb10ea14 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -4,6 +4,7 @@ import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heart import type { CommandLaneTaskMarker } from "../../process/command-queue.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import type { CronActiveJobMarker } from "../active-jobs.js"; +import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import type { QuarantinedCronConfigJob } from "../store.js"; import type { CronTriggerEvaluationResult, @@ -360,8 +361,14 @@ export type CronAddOptions = { enabledExplicit?: boolean; /** Gateway-owned system payloads (heartbeat monitors) require this opt-in. */ systemOwned?: boolean; + /** Authenticated caller provenance stamped by the service, never public input. */ + scheduledToolPolicy?: CronScheduledToolPolicy; }; /** Normalized patch input accepted by cron service updates. */ export type CronUpdateInput = CronJobPatch; +/** Authenticated caller provenance used only when a tool policy is explicitly adopted. */ +export type CronUpdateOptions = { + scheduledToolPolicy?: CronScheduledToolPolicy; +}; /** Cron-store-locked guard evaluated against the current job before an update applies. */ export type CronUpdatePrecondition = (job: CronJob, nowMs: number) => void | Promise; diff --git a/src/cron/store/row-codec.schedule.test.ts b/src/cron/store/row-codec.schedule.test.ts index a130803368e3..049f9e9321d9 100644 --- a/src/cron/store/row-codec.schedule.test.ts +++ b/src/cron/store/row-codec.schedule.test.ts @@ -29,6 +29,32 @@ describe("schedule column codec round-trip", () => { }); }); + it("round-trips scheduled authority through the additive job_json envelope", () => { + const job = projectCronJobThroughStorageCodec( + makeCronJob({ + owner: { + agentId: "main", + sessionKey: "agent:main:discord:group:ops", + accountId: "work", + }, + payload: { kind: "agentTurn", message: "run", toolsAllow: ["write"] }, + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }, + }), + ); + + expect(job.scheduledToolPolicy).toEqual({ + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", + }); + }); + it("round-trips pacing through the additive job_json envelope", () => { const job = projectCronJobThroughStorageCodec( makeCronJob({ pacing: { min: "15m", max: "4h" } }), diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index 98edb8463c81..cb5dc869f0d1 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -7,6 +7,7 @@ import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js"; import { normalizeCronJobInput } from "../normalize.js"; import { getInvalidPersistedCronJobReason } from "../persisted-shape.js"; import { tryCronScheduleIdentity } from "../schedule-identity.js"; +import { normalizeCronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import type { CronJob, CronJobState, CronPacing, CronSchedule, CronStoreFile } from "../types.js"; import { bindDeliveryColumns, deliveryFromRow } from "./delivery-codec.js"; import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js"; @@ -283,6 +284,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null { const failureAlert = failureAlertFromRow(row); const trigger = triggerFromRow(row); const pacing = pacingFromRow(row); + const scheduledToolPolicy = normalizeCronScheduledToolPolicy(jobJson.scheduledToolPolicy); if (!schedule || !payload) { return null; } @@ -300,6 +302,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null { }, } : {}), + ...(scheduledToolPolicy ? { scheduledToolPolicy } : {}), name: row.name, ...(row.description ? { description: row.description } : {}), enabled: row.enabled !== 0, diff --git a/src/cron/trigger-script.test.ts b/src/cron/trigger-script.test.ts index 4c3406a686cd..275ad88fcfd6 100644 --- a/src/cron/trigger-script.test.ts +++ b/src/cron/trigger-script.test.ts @@ -305,7 +305,7 @@ describe("cron trigger script evaluator", () => { ]); }); - it("forwards the owner session and invalidates cached authority when it changes", async () => { + it("forwards scheduled provenance and invalidates cached authority when it changes", async () => { const config = {} as OpenClawConfig; const prepareRuntime = vi.fn(async (_params: PrepareParams) => createPreparedRuntime(config)); const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); @@ -320,18 +320,28 @@ describe("cron trigger script evaluator", () => { script: "return result", state: null, toolsAllow: ["write"], - ownerSessionKey, - ownerAccountId, + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey, + ownerAccountId, + }, }); } - expect(prepareRuntime.mock.calls.map(([params]) => params.ownerSessionKey)).toEqual([ - "agent:main:discord:group:a", - "agent:main:discord:group:b", - ]); - expect(prepareRuntime.mock.calls.map(([params]) => params.ownerAccountId)).toEqual([ - "alpha", - "beta", + expect(prepareRuntime.mock.calls.map(([params]) => params.scheduledToolPolicy)).toEqual([ + { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:a", + ownerAccountId: "alpha", + }, + { + version: 1, + mode: "account", + ownerSessionKey: "agent:main:discord:group:b", + ownerAccountId: "beta", + }, ]); }); diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts index 8dae6db642f7..158fec5d2945 100644 --- a/src/cron/trigger-script.ts +++ b/src/cron/trigger-script.ts @@ -28,7 +28,10 @@ import { } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js"; import { resolveSandboxContext } from "../agents/sandbox.js"; -import { resolveScheduledToolPolicyContext } from "../agents/scheduled-tool-policy.js"; +import { + resolveScheduledToolPolicyContext, + type ScheduledToolPolicyContext, +} from "../agents/scheduled-tool-policy.js"; import { createToolSearchCatalogRef, registerHeadlessToolSearchCatalog, @@ -82,8 +85,7 @@ type PrepareTriggerRuntime = (params: { jobId: string; agentId?: string; toolsAllow?: string[]; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: ScheduledToolPolicyContext; signal?: AbortSignal; }) => Promise; @@ -109,8 +111,7 @@ async function prepareTriggerRuntime(params: { jobId: string; agentId?: string; toolsAllow?: string[]; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: ScheduledToolPolicyContext; signal?: AbortSignal; }): Promise { params.signal?.throwIfAborted(); @@ -180,8 +181,7 @@ async function prepareTriggerRuntime(params: { inheritRuntimeToolAllowlist: Boolean(toolPlan.runtimeToolAllowlist), scheduledToolPolicy: resolveScheduledToolPolicyContext({ toolsAllow: params.toolsAllow, - ownerSessionKey: params.ownerSessionKey, - ownerAccountId: params.ownerAccountId, + scheduledToolPolicy: params.scheduledToolPolicy, }), toolConstructionPlan: toolPlan.codingToolConstructionPlan, }) @@ -351,8 +351,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { requestedAgentId?: string; agentId: string; toolsAllow?: string[]; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: ScheduledToolPolicyContext; toolsAllowKey: string; signal: AbortSignal; }): Promise => { @@ -387,8 +386,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { jobId: request.jobId, agentId: request.requestedAgentId, toolsAllow: request.toolsAllow, - ownerSessionKey: request.ownerSessionKey, - ownerAccountId: request.ownerAccountId, + scheduledToolPolicy: request.scheduledToolPolicy, signal: request.signal, }); const entry: TriggerRuntimeCacheEntry = { @@ -414,8 +412,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { agentId?: string; script: string; toolsAllow?: string[]; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: ScheduledToolPolicyContext; abortSignal?: AbortSignal; wallClockMs: number; maxToolCalls: number; @@ -435,8 +432,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { const agentId = resolveTriggerAgentId(runtimeConfig, params.agentId); const toolsAllowKey = JSON.stringify([ params.toolsAllow ?? null, - params.ownerSessionKey?.trim() || null, - params.ownerAccountId?.trim() || null, + params.scheduledToolPolicy ?? null, ]); const runtime = await resolveCachedRuntime({ runtimeConfig, @@ -444,8 +440,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { requestedAgentId: params.agentId, agentId, toolsAllow: params.toolsAllow, - ownerSessionKey: params.ownerSessionKey, - ownerAccountId: params.ownerAccountId, + scheduledToolPolicy: params.scheduledToolPolicy, toolsAllowKey, signal: evaluationScope.signal, }); @@ -612,8 +607,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) { state: unknown; streamBatch?: string; toolsAllow?: string[]; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: ScheduledToolPolicyContext; abortSignal?: AbortSignal; }): Promise => { if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) { @@ -640,8 +634,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) { state: unknown; streamBatch?: string; toolsAllow?: string[]; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: ScheduledToolPolicyContext; timeoutSeconds?: number; toolBudget?: number; abortSignal?: AbortSignal; diff --git a/src/cron/types.ts b/src/cron/types.ts index 8aac667f0dba..be7b2842064d 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -3,6 +3,7 @@ import type { FailoverReason } from "../agents/embedded-agent-helpers/types.js"; import type { EmbeddedAgentExecutionPhase } from "../agents/embedded-agent-runner/execution-phase.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { HookExternalContentSource } from "../security/external-content.js"; +import type { CronScheduledToolPolicy } from "./scheduled-tool-policy.js"; import type { CronJobBase, CronPacing } from "./types-shared.js"; export type { CronPacing } from "./types-shared.js"; @@ -466,6 +467,8 @@ export type CronJob = CronJobBase< /** Authenticated account that created this scheduled authority envelope. */ accountId?: string; }; + /** Server-authored provenance for requester-scoped scheduled tool authority. */ + scheduledToolPolicy?: CronScheduledToolPolicy; trigger?: CronTrigger; state: CronJobState; }; @@ -479,7 +482,10 @@ export type CronStoreFile = { type CronJobStateInput = Partial>; /** Create input accepted by cron APIs before id/timestamps/state are assigned. */ -export type CronJobCreate = Omit & { +export type CronJobCreate = Omit< + CronJob, + "id" | "createdAtMs" | "updatedAtMs" | "state" | "scheduledToolPolicy" +> & { /** Internal callers can reserve a durable id before creation; public cron.add omits this. */ id?: string; state?: CronJobStateInput; @@ -498,6 +504,7 @@ export type CronJobPatch = Partial< | "declarationKey" | "displayName" | "owner" + | "scheduledToolPolicy" | "pacing" > > & { diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index e39bdcd443b2..0e6e3b034540 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -184,7 +184,7 @@ export class McpLoopbackToolCache { // Unset (full scope) must never share a cache row with an empty // allowlist (deny-all), so the marker distinguishes presence. params.toolsAllow ? `allow:${[...new Set(params.toolsAllow)].toSorted().join(",")}` : "", - params.scheduledToolPolicy?.ownerSessionKey ?? "", + JSON.stringify(params.scheduledToolPolicy ?? null), params.nodeExecAllowed === true ? "node-exec" : "", params.execSession?.execHost ?? "", params.execSession?.execSecurity ?? "", diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index eb724707e0c8..3e9c4144a898 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -26,6 +26,7 @@ import { resolveCronDeliveryPlan, sendCronAnnouncePayloadStrict } from "../cron/ import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; import { resolveCronJobBoundSessionKeys } from "../cron/job-session-bindings.js"; import { toPublicCronJob } from "../cron/public-job.js"; +import { resolveCronScheduledToolPolicy } from "../cron/scheduled-tool-policy.js"; import { CronService, type CronEvent } from "../cron/service.js"; import { abortActiveCronTaskRuns, @@ -606,8 +607,11 @@ export function buildGatewayCronService(params: { state, streamBatch, toolsAllow: job.payload.toolsAllow, - ownerSessionKey: job.owner?.sessionKey, - ownerAccountId: job.owner?.accountId, + scheduledToolPolicy: resolveCronScheduledToolPolicy({ + toolsAllow: job.payload.toolsAllow, + scheduledToolPolicy: job.scheduledToolPolicy, + owner: job.owner, + }), abortSignal, }), } @@ -838,8 +842,11 @@ export function buildGatewayCronService(params: { state: job.state.triggerState, streamBatch, toolsAllow: job.payload.toolsAllow, - ownerSessionKey: job.owner?.sessionKey, - ownerAccountId: job.owner?.accountId, + scheduledToolPolicy: resolveCronScheduledToolPolicy({ + toolsAllow: job.payload.toolsAllow, + scheduledToolPolicy: job.scheduledToolPolicy, + owner: job.owner, + }), timeoutSeconds: job.payload.timeoutSeconds, toolBudget: job.payload.toolBudget, abortSignal, diff --git a/src/gateway/server-methods/agent-handler-helpers.ts b/src/gateway/server-methods/agent-handler-helpers.ts index f804dcc3d88e..ad2f163fa7b4 100644 --- a/src/gateway/server-methods/agent-handler-helpers.ts +++ b/src/gateway/server-methods/agent-handler-helpers.ts @@ -12,6 +12,7 @@ import { } from "../../config/sessions.js"; import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js"; import type { PluginHookSessionEndReason } from "../../plugins/hook-types.js"; import { AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE, @@ -38,8 +39,7 @@ export type RestoredCronContinuation = { thinking?: string; toolsAllow?: string[]; toolsAllowIsDefault?: boolean; - ownerSessionKey?: string; - ownerAccountId?: string; + scheduledToolPolicy?: CronScheduledToolPolicy; cliSessionBindingFacts?: { extraSystemPromptStatic?: string; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index aff93582aadc..b817980a8bac 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -354,8 +354,7 @@ export function startAgentRunExecution(params: { scheduledToolPolicy: params.restoredCronContinuation ? resolveScheduledToolPolicyContext({ toolsAllow: params.restoredCronContinuation.toolsAllow, - ownerSessionKey: params.restoredCronContinuation.ownerSessionKey, - ownerAccountId: params.restoredCronContinuation.ownerAccountId, + scheduledToolPolicy: params.restoredCronContinuation.scheduledToolPolicy, }) : undefined, requireExplicitMessageTarget: diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts index e6d1d2ef0f6d..6085f86a971a 100644 --- a/src/gateway/server-methods/agent-session-persist.ts +++ b/src/gateway/server-methods/agent-session-persist.ts @@ -20,6 +20,7 @@ import { } from "../../config/sessions/session-accessor.js"; import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeCronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js"; import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { recordSessionCreated } from "../../sessions/session-state-events.js"; @@ -227,11 +228,12 @@ export async function persistAgentSessionPhase(params: { ...(freshEntry.thinkingLevel ? { thinking: freshEntry.thinkingLevel } : {}), ...(marker.toolsAllow !== undefined ? { toolsAllow: [...marker.toolsAllow] } : {}), ...(marker.toolsAllowIsDefault === true ? { toolsAllowIsDefault: true } : {}), - ...(marker.ownerSessionKey?.trim() - ? { ownerSessionKey: marker.ownerSessionKey.trim() } - : {}), - ...(marker.ownerAccountId?.trim() - ? { ownerAccountId: marker.ownerAccountId.trim() } + ...(normalizeCronScheduledToolPolicy(marker.scheduledToolPolicy) + ? { + scheduledToolPolicy: normalizeCronScheduledToolPolicy( + marker.scheduledToolPolicy, + ), + } : {}), ...(marker.cliSessionBindingFacts ? { cliSessionBindingFacts: { ...marker.cliSessionBindingFacts } } diff --git a/src/gateway/server-methods/cron-caller-scope.ts b/src/gateway/server-methods/cron-caller-scope.ts index 71873521aae5..ab8453a6102c 100644 --- a/src/gateway/server-methods/cron-caller-scope.ts +++ b/src/gateway/server-methods/cron-caller-scope.ts @@ -1,3 +1,8 @@ +import { + createAccountCronScheduledToolPolicy, + createTrustedCronScheduledToolPolicy, + type CronScheduledToolPolicy, +} from "../../cron/scheduled-tool-policy.js"; import type { CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { normalizeAccountId } from "../../routing/account-id.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; @@ -26,6 +31,27 @@ export function readCronCallerScope( }; } +/** Converts the authenticated gateway caller into server-only scheduled authority provenance. */ +export function resolveCronScheduledToolPolicyForCaller( + callerScope: CronCallerScope | undefined, +): CronScheduledToolPolicy { + if (!callerScope) { + return createTrustedCronScheduledToolPolicy(); + } + const policy = callerScope.sessionKey + ? createAccountCronScheduledToolPolicy({ + ownerSessionKey: callerScope.sessionKey, + ownerAccountId: callerScope.accountId, + }) + : undefined; + if (!policy) { + // An agent-runtime caller cannot be promoted to operator authority merely + // because its signed runtime envelope omitted a session identity. + throw new TypeError("agent-runtime cron mutations require an authenticated session identity"); + } + return policy; +} + function resolveCronJobEffectiveAgentId(job: CronJob, defaultAgentId?: string): string { return normalizeAgentId(job.agentId ?? defaultAgentId ?? DEFAULT_AGENT_ID); } diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index c57b24d8a64c..0b5650b77b25 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -60,6 +60,7 @@ import { cronJobMatchesCallerScope, cronPatchSessionRefsMatchCaller, readCronCallerScope, + resolveCronScheduledToolPolicyForCaller, type CronCallerScope, } from "./cron-caller-scope.js"; import { isCronInvalidRequestError } from "./cron-error-classification.js"; @@ -746,6 +747,9 @@ export const cronHandlers: GatewayRequestHandlers = { callerScope, defaultAgentId: context.cron.getDefaultAgentId(), }), + ...(cronJobUsesToolRuntime(jobCreate) + ? { scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope) } + : {}), }); } catch (err) { if ( @@ -901,38 +905,47 @@ export const cronHandlers: GatewayRequestHandlers = { } let job: Awaited>; try { - job = await context.cron.updateWithPrecondition(jobId, patch, async (lockedJob) => { - if ( - !cronJobMatchesCallerScope({ - job: lockedJob, - callerScope, + job = await context.cron.updateWithPrecondition( + jobId, + patch, + async (lockedJob) => { + if ( + !cronJobMatchesCallerScope({ + job: lockedJob, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }) + ) { + throw new Error(`unknown cron job id: ${jobId}`); + } + if (p.expectedConfigRevision !== undefined) { + const actualConfigRevision = resolveCronJobConfigRevision(lockedJob); + if (actualConfigRevision !== p.expectedConfigRevision) { + throw new CronJobConfigRevisionConflictError( + p.expectedConfigRevision, + actualConfigRevision, + ); + } + } + const nextJob = await assertValidCronUpdatePatch({ + cfg, defaultAgentId: context.cron.getDefaultAgentId(), - }) - ) { - throw new Error(`unknown cron job id: ${jobId}`); - } - if (p.expectedConfigRevision !== undefined) { - const actualConfigRevision = resolveCronJobConfigRevision(lockedJob); - if (actualConfigRevision !== p.expectedConfigRevision) { - throw new CronJobConfigRevisionConflictError( - p.expectedConfigRevision, - actualConfigRevision, + currentJob: lockedJob, + patch, + }); + if ( + cronPatchTouchesToolRuntime(patch) && + requiresExplicitAgentRuntimeToolsAllow({ job: nextJob, callerScope }) + ) { + throw new TypeError( + "agent-runtime tool jobs require an explicit payload.toolsAllow cap", ); } - } - 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"); - } - }); + }, + cronPatchTouchesToolRuntime(patch) + ? { scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope) } + : undefined, + ); } catch (err) { if (err instanceof CronJobConfigRevisionConflictError) { respond( diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index bbe4fa03ea68..69a693277bd6 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -123,6 +123,7 @@ function createCronContext(currentJobs?: CronJob | CronJob[]) { id: string, patch: Partial, precondition: (job: CronJob, nowMs: number) => void | Promise, + _opts?: unknown, ) => { const job = jobs.find((candidate) => candidate.id === id); if (!job) { @@ -1107,6 +1108,12 @@ describe("cron method validation", () => { accountId: "default", }); const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options"); + expect(options.scheduledToolPolicy).toEqual({ + version: 1, + mode: "account", + ownerSessionKey: "agent:ops:main", + ownerAccountId: "default", + }); const matchesExisting = options.matchesExisting as ((job: CronJob) => boolean) | undefined; expect(matchesExisting?.(createCronJob({ agentId: "ops" }))).toBe(true); expect(matchesExisting?.(createCronJob({ agentId: "worker" }))).toBe(false); @@ -1235,6 +1242,7 @@ describe("cron method validation", () => { expect(requireCronAddPayload(context).owner).toEqual(owner); const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options"); + expect(options.scheduledToolPolicy).toEqual({ version: 1, mode: "trusted" }); const matchesExisting = options.matchesExisting as ((job: CronJob) => boolean) | undefined; expect(matchesExisting?.(createCronJob({ owner }))).toBe(true); expect( @@ -1562,6 +1570,7 @@ describe("cron method validation", () => { ); expect(context.cron.update).toHaveBeenCalledWith("cron-1", { enabled: false }); + expect(context.cron.updateWithPrecondition.mock.calls[0]?.[3]).toBeUndefined(); expectCronSuccess(respond); }); @@ -1602,6 +1611,37 @@ describe("cron method validation", () => { expectCronSuccess(respond); }); + it("passes authenticated provenance for an explicit agent-runtime tool edit", async () => { + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { + payload: { kind: "agentTurn", message: "updated", toolsAllow: ["write"] }, + }, + }, + createCronJob({ + agentId: "ops", + owner: { + agentId: "ops", + sessionKey: "agent:ops:main", + accountId: "default", + }, + payload: { kind: "agentTurn", message: "legacy", toolsAllow: ["read"] }, + }), + { client: callerClient("ops") }, + ); + + expect(context.cron.updateWithPrecondition.mock.calls[0]?.[3]).toEqual({ + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:ops:main", + ownerAccountId: "default", + }, + }); + 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 36b245cd8a7c..aa3134a24719 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -26,7 +26,12 @@ type CreateOpenClawCodingToolsArg = { workspaceDir?: string; cwd?: string; wrapBeforeToolCallHook?: boolean; - scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string }; + scheduledToolPolicy?: { + version: 1; + mode: "account"; + ownerSessionKey: string; + ownerAccountId: string; + }; }; type LazyExecToolDefaults = { @@ -162,6 +167,8 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"], mediatedToolNames: ["write"], scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:qa-channel:group:ops", ownerAccountId: "default", }, @@ -177,6 +184,8 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { cwd: "/workspace/task", wrapBeforeToolCallHook: false, scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:qa-channel:group:ops", ownerAccountId: "default", }, diff --git a/src/gateway/worker-environments/worker-tool-authority.test.ts b/src/gateway/worker-environments/worker-tool-authority.test.ts index 6e0d035b0045..c74d93eafb0e 100644 --- a/src/gateway/worker-environments/worker-tool-authority.test.ts +++ b/src/gateway/worker-environments/worker-tool-authority.test.ts @@ -61,6 +61,8 @@ describe("resolveWorkerToolAuthority", () => { senderId: "guest", toolsAllow: ["read", "write", "exec"], scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:whatsapp:group:team", ownerAccountId: "default", }, @@ -89,6 +91,8 @@ describe("resolveWorkerToolAuthority", () => { messageProvider: "whatsapp", toolsAllow: ["write"], scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:whatsapp:group:team", ownerAccountId: "default", }, diff --git a/src/gateway/worker-environments/worker-turn-payload.test.ts b/src/gateway/worker-environments/worker-turn-payload.test.ts index 0c8b062f7694..5571dfe7decd 100644 --- a/src/gateway/worker-environments/worker-turn-payload.test.ts +++ b/src/gateway/worker-environments/worker-turn-payload.test.ts @@ -23,6 +23,8 @@ describe("assertSupportedTurn", () => { }, toolsAllow: ["write"], scheduledToolPolicy: { + version: 1, + mode: "account", ownerSessionKey: "agent:main:discord:group:ops", ownerAccountId: "default", }, diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index 17df3c04c8ca..df0e1b96e602 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -403,7 +403,7 @@ describe("scripts/test-docker-all scheduler", () => { const summary = JSON.parse(readFileSync(path.join(logDir, "summary.json"), "utf8")); expect(summary.status).toBe("passed"); expect(summary.lanes).toEqual([]); - expect(summary.omittedUnsupportedLanes).toHaveLength(11); + expect(summary.omittedUnsupportedLanes).toHaveLength(12); expect(summary.omittedUnsupportedLanes).toContain("published-upgrade-survivor"); expect(summary.omittedUnsupportedLanes).toContain( "published-upgrade-survivor-versioned-runtime-deps", diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 6847c86a538c..541c7a8255a3 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -795,6 +795,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-meeting-transcripts-sqlite", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", + "published-upgrade-survivor-2026.4.29-cron-scheduled-authority", ]); }); @@ -832,6 +833,7 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.omittedUnsupportedLanes).toEqual([ "published-upgrade-survivor-2026.6.11-acpx-openclaw-tools-bridge", "published-upgrade-survivor-2026.6.11-meeting-transcripts-sqlite", + "published-upgrade-survivor-2026.6.11-cron-scheduled-authority", ]); }); @@ -870,6 +872,7 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.omittedUnsupportedLanes).toEqual([ "published-upgrade-survivor-2026.6.11-acpx-openclaw-tools-bridge", "published-upgrade-survivor-2026.6.11-meeting-transcripts-sqlite", + "published-upgrade-survivor-2026.6.11-cron-scheduled-authority", ]); }); @@ -885,7 +888,7 @@ describe("scripts/lib/docker-e2e-plan", () => { }); expect(plan.lanes).toEqual([]); - expect(plan.omittedUnsupportedLanes).toHaveLength(11); + expect(plan.omittedUnsupportedLanes).toHaveLength(12); expect(plan.omittedUnsupportedLanes).toContain("published-upgrade-survivor-2026.6.11"); expect(plan.omittedUnsupportedLanes).toContain( "published-upgrade-survivor-2026.6.11-versioned-runtime-deps", @@ -930,7 +933,7 @@ describe("scripts/lib/docker-e2e-plan", () => { }); expect(plan.lanes.map((lane) => lane.name)).toEqual(["plugin-binding-command-escape"]); - expect(plan.omittedUnsupportedLanes).toHaveLength(11); + expect(plan.omittedUnsupportedLanes).toHaveLength(12); expect(plan.omittedUnsupportedLanes).toContain("published-upgrade-survivor"); expect(plan.omittedUnsupportedLanes).toContain( "published-upgrade-survivor-versioned-runtime-deps", @@ -1066,6 +1069,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-meeting-transcripts-sqlite", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", + "published-upgrade-survivor-2026.4.29-cron-scheduled-authority", "published-upgrade-survivor-2026.4.22", "published-upgrade-survivor-2026.4.22-acpx-openclaw-tools-bridge", "published-upgrade-survivor-2026.4.22-feishu-channel", @@ -1076,6 +1080,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.22-tilde-log-path", "published-upgrade-survivor-2026.4.22-meeting-transcripts-sqlite", "published-upgrade-survivor-2026.4.22-versioned-runtime-deps", + "published-upgrade-survivor-2026.4.22-cron-scheduled-authority", "published-upgrade-survivor-2026.4.21", "published-upgrade-survivor-2026.4.21-feishu-channel", "published-upgrade-survivor-2026.4.21-bootstrap-persona", @@ -1085,6 +1090,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.21-tilde-log-path", "published-upgrade-survivor-2026.4.21-meeting-transcripts-sqlite", "published-upgrade-survivor-2026.4.21-versioned-runtime-deps", + "published-upgrade-survivor-2026.4.21-cron-scheduled-authority", "published-upgrade-survivor-2026.3.13", "published-upgrade-survivor-2026.3.13-feishu-channel", "published-upgrade-survivor-2026.3.13-bootstrap-persona", @@ -1094,6 +1100,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.3.13-tilde-log-path", "published-upgrade-survivor-2026.3.13-meeting-transcripts-sqlite", "published-upgrade-survivor-2026.3.13-versioned-runtime-deps", + "published-upgrade-survivor-2026.3.13-cron-scheduled-authority", ]); });