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 4c76b7647dfc..24b98f28d9dd 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -460,7 +460,10 @@ describe("Codex app-server dynamic tool build", () => { sourceTool: "subagent_announce", }; params.trustedInternalHandoff = true; - params.scheduledToolPolicy = { ownerSessionKey: "agent:main:discord:group:ops" }; + params.scheduledToolPolicy = { + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "default", + }; let receivedOptions: unknown; setOpenClawCodingToolsFactoryForTests((options) => { receivedOptions = options; diff --git a/extensions/copilot/src/tool-bridge.test.ts b/extensions/copilot/src/tool-bridge.test.ts index 3758062334e1..f0e71b1a7373 100644 --- a/extensions/copilot/src/tool-bridge.test.ts +++ b/extensions/copilot/src/tool-bridge.test.ts @@ -727,7 +727,10 @@ describe("createCopilotToolBridge", () => { jobId: "job-1", memoryFlushWritePath: ".memory/append.md", toolsAllow: ["read", "edit"], - scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "default", + }, } as never, createOpenClawCodingTools, modelId: "gpt-4o", @@ -745,6 +748,7 @@ describe("createCopilotToolBridge", () => { expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]); expect(opts.scheduledToolPolicy).toEqual({ ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "default", }); }); diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index ab493be8ed14..0f98461ab176 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -325,6 +325,7 @@ import { WorkerTranscriptCommitResultSchema, WorkerTranscriptMessageSchema, WORKER_HEARTBEAT_INTERVAL_MS, + WORKER_LAUNCH_V2_PROTOCOL_FEATURE, WORKER_LIVE_EVENT_PROTOCOL_FEATURE, WORKER_PROTOCOL_FEATURES, WORKER_PROTOCOL_MAX_FEATURE_LENGTH, @@ -1070,6 +1071,7 @@ export { WorkerTranscriptCommitResultSchema, WorkerTranscriptMessageSchema, WORKER_HEARTBEAT_INTERVAL_MS, + WORKER_LAUNCH_V2_PROTOCOL_FEATURE, WORKER_LIVE_EVENT_PROTOCOL_FEATURE, WORKER_PROTOCOL_FEATURES, WORKER_PROTOCOL_MAX_FEATURE_LENGTH, diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index 97699e36a570..0205b71adac0 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -130,6 +130,7 @@ const CronDisplayNameSchema = Type.String({ minLength: 1, maxLength: 200, patter const CronOwnerSchema = closedObject({ agentId: Type.Optional(NonEmptyString), sessionKey: Type.Optional(NonEmptyString), + accountId: Type.Optional(NonEmptyString), }); const CronAnnounceChannelSchema = Type.Union([Type.Literal("last"), NonBlankString]); const CronFailoverReasonSchema = Type.Union([ diff --git a/packages/gateway-protocol/src/schema/worker-admission.test.ts b/packages/gateway-protocol/src/schema/worker-admission.test.ts index c3a02e4e373f..bfec5cab79a4 100644 --- a/packages/gateway-protocol/src/schema/worker-admission.test.ts +++ b/packages/gateway-protocol/src/schema/worker-admission.test.ts @@ -11,6 +11,7 @@ import { WorkerProtocolCloseReasonSchema, WorkerTranscriptCommitRequestFrameSchema, WorkerTranscriptCommitResponseFrameSchema, + WORKER_LAUNCH_V2_PROTOCOL_FEATURE, WORKER_PROTOCOL_FEATURES, WORKER_RPC_SET_VERSION, WORKER_TRANSCRIPT_MAX_JSON_DEPTH, @@ -296,6 +297,7 @@ describe("worker protocol schemas", () => { it("validates the additive live-event protocol", () => { expect(WORKER_RPC_SET_VERSION).toBe(1); expect(WORKER_PROTOCOL_FEATURES).toContain("worker-live-event-v1"); + expect(WORKER_PROTOCOL_FEATURES).toContain(WORKER_LAUNCH_V2_PROTOCOL_FEATURE); for (const validEvent of [ assistant, event("thinking", { text: "x", delta: "x" }), diff --git a/packages/gateway-protocol/src/schema/worker-admission.ts b/packages/gateway-protocol/src/schema/worker-admission.ts index d6d827506f4a..02fe038aafcb 100644 --- a/packages/gateway-protocol/src/schema/worker-admission.ts +++ b/packages/gateway-protocol/src/schema/worker-admission.ts @@ -35,10 +35,12 @@ export const WORKER_PROTOCOL_METHODS = [ ] as const; export const WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE = "worker-transcript-commit-v1"; export const WORKER_LIVE_EVENT_PROTOCOL_FEATURE = "worker-live-event-v1"; +export const WORKER_LAUNCH_V2_PROTOCOL_FEATURE = "worker-launch-v2"; export const WORKER_PROTOCOL_FEATURES = [ "worker-heartbeat-v1", WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE, WORKER_LIVE_EVENT_PROTOCOL_FEATURE, + WORKER_LAUNCH_V2_PROTOCOL_FEATURE, "worker-inference-v1", ] as const; export const WORKER_PROTOCOL_MAX_METHOD_LENGTH = 64; diff --git a/src/agents/agent-tools.policy.test.ts b/src/agents/agent-tools.policy.test.ts index be585792cdf0..f1b007fed0a0 100644 --- a/src/agents/agent-tools.policy.test.ts +++ b/src/agents/agent-tools.policy.test.ts @@ -30,6 +30,20 @@ vi.mock("../channels/plugins/session-conversation.js", () => ({ }), })); +vi.mock("../channels/plugins/index.js", () => ({ + getLoadedChannelPlugin: () => ({ + config: { + listAccountIds: (config: OpenClawConfig) => [ + "default", + ...Object.keys( + (config.channels?.whatsapp as { accounts?: Record } | undefined) + ?.accounts ?? {}, + ), + ], + }, + }), +})); + async function writeSessionEntries( storePath: string, entries: Record, @@ -206,6 +220,38 @@ describe("resolveGroupToolPolicy group context validation", () => { expect(policy).toEqual({ allow: ["read"] }); }); + + it("fails closed when scheduled authority names a removed account", () => { + expect( + resolveGroupToolPolicy({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:safe-room", + accountId: "removed", + requireConfiguredAccount: true, + }), + ).toEqual({ allow: [] }); + }); + + it("resolves scheduled group policy for a still-configured named account", () => { + const accountCfg = { + ...cfg, + channels: { + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { work: {} }, + }, + }, + } as OpenClawConfig; + + expect( + resolveGroupToolPolicy({ + config: accountCfg, + sessionKey: "agent:main:whatsapp:group:safe-room", + accountId: "work", + requireConfiguredAccount: true, + }), + ).toEqual({ allow: ["read"] }); + }); }); describe("resolveSubagentToolPolicyForSession", () => { diff --git a/src/agents/agent-tools.policy.ts b/src/agents/agent-tools.policy.ts index 04c4d0a529da..865601c55b58 100644 --- a/src/agents/agent-tools.policy.ts +++ b/src/agents/agent-tools.policy.ts @@ -14,6 +14,7 @@ import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { logWarn } from "../logger.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { parseRawSessionConversationRef, @@ -466,6 +467,8 @@ export function resolveGroupToolPolicy(params: { groupChannel?: string | null; groupSpace?: string | null; accountId?: string | null; + /** Scheduled authority must not fall back from a removed named account. */ + requireConfiguredAccount?: boolean; senderPolicyMode?: "always" | "never"; senderId?: string | null; senderName?: string | null; @@ -489,13 +492,13 @@ export function resolveGroupToolPolicy(params: { ...(spawnedContext.groupIds ?? []), ...buildScopedGroupIdCandidates(trustedGroup.groupId), ]); - if (groupIds.length === 0) { - return undefined; - } const channelRaw = sessionContext.channel ?? spawnedContext.channel ?? params.messageProvider; const channel = normalizeMessageChannel(channelRaw); + const accountId = normalizeAccountId(params.accountId); if (!channel) { - return undefined; + return params.requireConfiguredAccount && accountId !== DEFAULT_ACCOUNT_ID + ? { allow: [] } + : undefined; } let plugin; try { @@ -503,13 +506,32 @@ export function resolveGroupToolPolicy(params: { } catch { plugin = undefined; } + if (params.requireConfiguredAccount && accountId !== DEFAULT_ACCOUNT_ID) { + let configured: boolean; + try { + configured = + plugin?.config + .listAccountIds(params.config) + .some((candidate) => normalizeAccountId(candidate) === accountId) === true; + } catch { + configured = false; + } + if (!configured) { + // A named creator account is an authority boundary, not a fallback hint. + // If it disappears, deny the scheduled surface instead of selecting default config. + return { allow: [] }; + } + } + if (groupIds.length === 0) { + return undefined; + } for (const groupId of groupIds) { const toolsConfig = plugin?.groups?.resolveToolPolicy?.({ cfg: params.config, groupId, groupChannel: trustedGroup.dropped ? null : params.groupChannel, groupSpace: trustedGroup.dropped ? null : params.groupSpace, - accountId: params.accountId, + accountId, senderPolicyMode: params.senderPolicyMode, senderId: params.senderId, senderName: params.senderName, @@ -527,7 +549,7 @@ export function resolveGroupToolPolicy(params: { messageProvider: channel, groupId: groupIds[0], groupIdCandidates: groupIds.slice(1), - accountId: params.accountId, + accountId, senderPolicyMode: params.senderPolicyMode, senderId: params.senderId, senderName: params.senderName, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 593be75e4188..1034ba8acf49 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -2434,7 +2434,10 @@ describe("prepareCliRunContext", () => { provider: "native-cli", runId: "run-test-loopback-prompt-tools", config: createCliBackendConfig({ bundleMcp: true }), - scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:worker:discord:group:ops", + ownerAccountId: "default", + }, cliSessionBinding: { sessionId: "cli-session", promptToolNamesHash: "old-tool-surface", @@ -2461,7 +2464,10 @@ describe("prepareCliRunContext", () => { workspaceDir: expect.any(String), modelProvider: "native-cli", modelId: "test-model", - scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:worker:discord:group:ops", + ownerAccountId: "default", + }, }); expect(context.systemPrompt).toContain("## Memory Recall"); expect(context.systemPrompt).toContain("tools=memory_search"); @@ -2982,7 +2988,10 @@ describe("prepareCliRunContext", () => { sessionKey: "agent:main:main", provider: "claude-cli", toolsAllow: ["write"], - scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "default", + }, }); cleanup = context.preparedBackend.cleanup; @@ -2997,11 +3006,15 @@ describe("prepareCliRunContext", () => { ]); expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.scheduledToolPolicy).toEqual({ ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "default", }); expect(resolveMcpLoopbackPolicyTools).toHaveBeenCalledWith( expect.objectContaining({ toolsAllow: ["write"], - scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "default", + }, }), ); const projected = resolveMcpLoopbackPolicyTools.mock.calls[0]?.[0]; diff --git a/src/agents/conversation-capability-profile.test.ts b/src/agents/conversation-capability-profile.test.ts index de03302e8994..cc61f7149c2c 100644 --- a/src/agents/conversation-capability-profile.test.ts +++ b/src/agents/conversation-capability-profile.test.ts @@ -196,7 +196,10 @@ describe("resolveConversationCapabilityProfile", () => { const legacy = resolveConversationCapabilityProfile(baseParams); const scheduled = resolveConversationCapabilityProfile({ ...baseParams, - scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:whatsapp:group:team", + ownerAccountId: "default", + }, }); expect(legacy.policy.senderPolicy).toEqual({ deny: ["write"] }); diff --git a/src/agents/conversation-capability-profile.ts b/src/agents/conversation-capability-profile.ts index 6a922246e52c..cf725554e1ba 100644 --- a/src/agents/conversation-capability-profile.ts +++ b/src/agents/conversation-capability-profile.ts @@ -222,7 +222,7 @@ export function resolveConversationCapabilityProfile( groupId: trustedGroup.groupId, groupChannel: trustedGroupChannel, groupSpace: trustedGroupSpace, - accountId: params.agentAccountId, + accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, @@ -231,6 +231,7 @@ export function resolveConversationCapabilityProfile( trustedInternalHandoff: params.trustedInternalHandoff, senderPolicyMode: params.scheduledToolPolicy || isOwnerInternalSession ? "never" : "always", groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey, + requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy), }); 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 a14cb9f9891c..f85be329774d 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -693,7 +693,8 @@ function resolvePluginHarnessToolPolicies( groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, - accountId: params.agentAccountId, + accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId, + requireConfiguredAccount: Boolean(params.scheduledToolPolicy), senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, diff --git a/src/agents/openclaw-tools.gateway-caller-identity.test.ts b/src/agents/openclaw-tools.gateway-caller-identity.test.ts index f306305f102a..239a95d8c3a8 100644 --- a/src/agents/openclaw-tools.gateway-caller-identity.test.ts +++ b/src/agents/openclaw-tools.gateway-caller-identity.test.ts @@ -75,4 +75,29 @@ describe("createOpenClawTools Gateway caller identity", () => { }, ]); }); + + it("uses scheduled creator account authority without changing live delivery routing", async () => { + mocks.observedIdentities.length = 0; + + const tool = requireTool("synthetic_direct_cron_plugin", { + agentChannel: "discord", + agentTo: "channel:123", + agentAccountId: "delivery-account", + scheduledToolPolicy: { + ownerSessionKey: "agent:main:discord:channel:creator", + ownerAccountId: "creator-account", + }, + }); + await tool.execute("tool-call-scheduled", {}); + + expect(mocks.observedIdentities).toEqual([ + { + agentId: "main", + sessionKey: "agent:main:discord:channel:123", + turnSourceChannel: "discord", + turnSourceTo: "channel:123", + turnSourceAccountId: "creator-account", + }, + ]); + }); }); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index b5bf10eb7f3f..d34a0ec881a4 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -262,6 +262,10 @@ export function createOpenClawTools( accountId: options?.agentAccountId, threadId: options?.agentThreadId, }); + // Scheduled turns keep delivery routing live, but Gateway authorization remains bound to the + // authenticated creator account captured in the immutable scheduled authority envelope. + const gatewayCallerAccountId = + options?.scheduledToolPolicy?.ownerAccountId ?? options?.agentAccountId; const runtimeWebTools = getActiveRuntimeWebToolsMetadata(); const sandbox = options?.sandboxRoot && options?.sandboxFsBridge @@ -495,6 +499,7 @@ export function createOpenClawTools( ]), createCronTool({ agentSessionKey: options?.agentSessionKey, + agentAccountId: gatewayCallerAccountId, currentDeliveryContext: { channel: options?.agentChannel, to: options?.currentChannelId ?? options?.agentTo, @@ -743,7 +748,10 @@ export function createOpenClawTools( options?.recordToolPrepStage?.("openclaw-tools:client-capabilities"); const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId; - const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper(hookAgentId, options); + const wrapGatewayCallerIdentity = createGatewayToolCallerWrapper( + hookAgentId, + options ? { ...options, agentAccountId: gatewayCallerAccountId } : options, + ); if (options?.wrapBeforeToolCallHook === false) { return allTools.map(wrapGatewayCallerIdentity); diff --git a/src/agents/requester-tool-policy.ts b/src/agents/requester-tool-policy.ts index fd539064e4a1..ea76855e4812 100644 --- a/src/agents/requester-tool-policy.ts +++ b/src/agents/requester-tool-policy.ts @@ -62,6 +62,8 @@ type RequesterToolPolicyParams = { senderPolicyMode?: SenderPolicyMode; /** Group session selected by a trusted scheduled authority envelope. */ groupPolicySessionKey?: string; + /** Fail closed when scheduled authority names a removed non-default account. */ + requireConfiguredGroupAccount?: boolean; }; function policyFromEnvelope( @@ -194,6 +196,7 @@ export function resolveRequesterToolPolicies( groupChannel: params.groupChannel, groupSpace: params.groupSpace, accountId: params.accountId, + requireConfiguredAccount: params.requireConfiguredGroupAccount, 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 9cc994ca61ce..737e8441f439 100644 --- a/src/agents/scheduled-tool-policy.test.ts +++ b/src/agents/scheduled-tool-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveScheduledToolPolicyContext } from "./scheduled-tool-policy.js"; describe("resolveScheduledToolPolicyContext", () => { - it("requires both a persisted cap and a trusted owner session", () => { + it("requires a persisted cap and a trusted owner session/account pair", () => { expect( resolveScheduledToolPolicyContext({ ownerSessionKey: "agent:main:discord:group:ops", @@ -17,6 +17,13 @@ describe("resolveScheduledToolPolicyContext", () => { resolveScheduledToolPolicyContext({ toolsAllow: ["write"], ownerSessionKey: " ", + ownerAccountId: "work", + }), + ).toBeUndefined(); + expect( + resolveScheduledToolPolicyContext({ + toolsAllow: ["write"], + ownerSessionKey: "agent:main:discord:group:ops", }), ).toBeUndefined(); }); @@ -26,7 +33,11 @@ describe("resolveScheduledToolPolicyContext", () => { resolveScheduledToolPolicyContext({ toolsAllow: [], ownerSessionKey: " agent:main:discord:group:ops ", + ownerAccountId: " work ", }), - ).toEqual({ ownerSessionKey: "agent:main:discord:group:ops" }); + ).toEqual({ + 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 d8b973c096a4..01b4436c52b5 100644 --- a/src/agents/scheduled-tool-policy.ts +++ b/src/agents/scheduled-tool-policy.ts @@ -5,16 +5,21 @@ */ export type ScheduledToolPolicyContext = { ownerSessionKey: string; + ownerAccountId: string; }; /** 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; }): ScheduledToolPolicyContext | undefined { if (params.toolsAllow === undefined) { return undefined; } const ownerSessionKey = params.ownerSessionKey?.trim(); - return ownerSessionKey ? { ownerSessionKey } : undefined; + 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; } diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 4c5b1cd1dcd4..e6d48d035070 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -22,6 +22,7 @@ vi.mock("../../config/sessions/delivery-info.js", () => ({ import { GatewayClientRequestError } from "../../gateway/client.js"; import { buildAgentPeerSessionKey } from "../../routing/session-key.js"; import { createCronTool } from "./cron-tool.js"; +import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js"; describe("cron tool", () => { type SchemaLike = { @@ -1551,6 +1552,31 @@ describe("cron tool", () => { expect(sessionKey).toBe(callerSessionKey); }); + it("forwards authenticated source account separately from delivery account", async () => { + let identity: ReturnType = undefined; + const tool = createCronTool( + { + agentSessionKey: "agent:main:discord:channel:ops", + agentAccountId: "source-account", + currentDeliveryContext: { accountId: "delivery-account" }, + }, + { + callGatewayTool: async () => { + identity = getGatewayToolCallerIdentity(); + return { enabled: true, jobs: 0 } as T; + }, + }, + ); + + await tool.execute("call-source-account", { action: "status" }); + + expect(identity).toMatchObject({ + agentId: "main", + sessionKey: "agent:main:discord:channel:ops", + turnSourceAccountId: "source-account", + }); + }); + it("preserves explicit job.sessionKey on add", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index b7dc24a3b746..a2a16949bc91 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -792,7 +792,11 @@ Restricted isolated runs may only self status/list, current get/runs/remove, and const callerScope = resolveCronToolCallerScope(opts, runtimeConfig); const callerIdentity = callerScope && opts?.agentSessionKey?.trim() - ? { agentId: callerScope.agentId, sessionKey: opts.agentSessionKey.trim() } + ? { + agentId: callerScope.agentId, + sessionKey: opts.agentSessionKey.trim(), + turnSourceAccountId: opts.agentAccountId, + } : undefined; return await withGatewayToolCallerIdentity(callerIdentity, async () => { diff --git a/src/agents/tools/cron-tool.types.ts b/src/agents/tools/cron-tool.types.ts index dc7caf95fbc8..647b540efa54 100644 --- a/src/agents/tools/cron-tool.types.ts +++ b/src/agents/tools/cron-tool.types.ts @@ -11,6 +11,8 @@ export type CronCreatorToolAllowlistEntry = export type CronToolOptions = { agentSessionKey?: string; + /** Authenticated source account; authority must not be inferred from delivery. */ + agentAccountId?: string; currentDeliveryContext?: DeliveryContext; /** * Effective tool surface visible to the caller that created or edited a cron job. diff --git a/src/agents/web-search-tool-policy.ts b/src/agents/web-search-tool-policy.ts index 481b6b911ac9..fb822c7c48e6 100644 --- a/src/agents/web-search-tool-policy.ts +++ b/src/agents/web-search-tool-policy.ts @@ -69,7 +69,8 @@ export function resolveWebSearchToolPolicy( groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, - accountId: params.agentAccountId, + accountId: params.scheduledToolPolicy?.ownerAccountId ?? params.agentAccountId, + requireConfiguredAccount: Boolean(params.scheduledToolPolicy), senderPolicyMode: params.scheduledToolPolicy ? ("never" as const) : ("always" as const), }; const senderPolicyParams = { @@ -88,6 +89,7 @@ export function resolveWebSearchToolPolicy( trustedInternalHandoff: params.trustedInternalHandoff, senderPolicyMode: params.scheduledToolPolicy ? "never" : "always", groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey, + requireConfiguredGroupAccount: Boolean(params.scheduledToolPolicy), }); const persistentGroupPolicy = requesterPolicies.delegated ? undefined diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 5db7521e1cfa..b8e3f9f88b3b 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -388,6 +388,8 @@ export type SessionEntry = SessionRestartRecoveryState & toolsAllowIsDefault?: boolean; /** Server-stamped creator session used to resolve sender-independent group policy. */ ownerSessionKey?: string; + /** Server-stamped creator account paired with ownerSessionKey. */ + ownerAccountId?: string; 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 490db52b3afd..82de4f208756 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -277,6 +277,7 @@ function createCronPromptExecutor(params: { const scheduledToolPolicy = resolveScheduledToolPolicyContext({ toolsAllow: params.agentPayload?.toolsAllow, ownerSessionKey: params.job.owner?.sessionKey, + ownerAccountId: params.job.owner?.accountId, }); 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 251c17cc9c09..cb678f3a78a3 100644 --- a/src/cron/isolated-agent/run-session-state.test.ts +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -146,6 +146,7 @@ describe("createPersistCronSessionEntry", () => { toolsAllow: ["image_generate", "write"], toolsAllowIsDefault: true, ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "work", persistSessionEntry, }); @@ -172,6 +173,7 @@ describe("createPersistCronSessionEntry", () => { toolsAllow: ["image_generate", "write"], toolsAllowIsDefault: true, 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 162fa1cb101b..73c2119b5fd2 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -204,6 +204,7 @@ export function createCronRunContinuationSession(params: { toolsAllow?: string[]; toolsAllowIsDefault?: boolean; ownerSessionKey?: string; + ownerAccountId?: string; cliSessionBindingFacts?: { extraSystemPromptStatic?: string; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; @@ -216,8 +217,13 @@ export function createCronRunContinuationSession(params: { phase: "running" as const, ...(params.toolsAllow !== undefined ? { toolsAllow: [...params.toolsAllow] } : {}), ...(params.toolsAllowIsDefault === true ? { toolsAllowIsDefault: true } : {}), - ...(params.toolsAllow !== undefined && params.ownerSessionKey?.trim() - ? { ownerSessionKey: params.ownerSessionKey.trim() } + ...(params.toolsAllow !== undefined && + params.ownerSessionKey?.trim() && + params.ownerAccountId?.trim() + ? { + ownerSessionKey: params.ownerSessionKey.trim(), + ownerAccountId: params.ownerAccountId.trim(), + } : {}), ...(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 ba5877dfeed3..ba4fee3e9735 100644 --- a/src/cron/isolated-agent/run.tools-allow.test.ts +++ b/src/cron/isolated-agent/run.tools-allow.test.ts @@ -34,7 +34,11 @@ function makeParams() { sessionTarget: "isolated", payload: { kind: "agentTurn", message: "check allowed tools" }, delivery: { mode: "none" }, - owner: { agentId: "main", sessionKey: "agent:main:whatsapp:group:team" }, + owner: { + agentId: "main", + sessionKey: "agent:main:whatsapp:group:team", + accountId: "default", + }, } as never, message: "check allowed tools", sessionKey: "cron:tools-allow", @@ -77,13 +81,13 @@ function makeParamsWithDefaultToolsAllow(toolsAllow: string[]) { function requireEmbeddedAgentCall(): { jobId?: string; toolsAllow?: string[]; - scheduledToolPolicy?: { ownerSessionKey: string }; + scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string }; } { const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as | { jobId?: string; toolsAllow?: string[]; - scheduledToolPolicy?: { ownerSessionKey: string }; + scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string }; } | undefined; if (!call) { @@ -134,6 +138,21 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { }, ); + it( + "keeps capped accountless legacy jobs on the ordinary sender-policy path", + { timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS }, + async () => { + const params = makeParamsWithToolsAllow(["cron"]); + delete (params.job as { owner?: { accountId?: string } }).owner?.accountId; + + await runCronIsolatedAgentTurn(params); + + const call = requireEmbeddedAgentCall(); + expect(call.toolsAllow).toEqual(["cron"]); + expect(call.scheduledToolPolicy).toBeUndefined(); + }, + ); + it( "passes through isolated cron toolsAllow=cron self-removal path", { timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS }, @@ -146,6 +165,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { expect(call.toolsAllow).toEqual(["cron"]); expect(call.scheduledToolPolicy).toEqual({ 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 0810a5bd6658..04f7a1c481bb 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1127,6 +1127,7 @@ async function prepareCronRunContext(params: { toolsAllow: agentPayload?.toolsAllow, toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault, ownerSessionKey: input.job.owner?.sessionKey, + ownerAccountId: input.job.owner?.accountId, cliSessionBindingFacts: { sourceReplyDeliveryMode: sourceDelivery.sourceReplyDeliveryMode, requireExplicitMessageTarget: sourceDelivery.messageTool.requireExplicitTarget, diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 43243ad15562..d71214f3aac3 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -6,6 +6,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalAccountId } from "../routing/account-id.js"; import { sanitizeAgentId } from "../routing/session-key.js"; import { isRecord } from "../utils.js"; import { shouldDefaultCronDeliveryToAnnounce } from "./delivery-defaults.js"; @@ -370,10 +371,14 @@ export function normalizeCronJobInput( if (isRecord(base.owner)) { const agentId = normalizeOptionalString(base.owner.agentId); const sessionKey = normalizeOptionalString(base.owner.sessionKey); - if (agentId || sessionKey) { + const accountId = normalizeOptionalAccountId( + typeof base.owner.accountId === "string" ? base.owner.accountId : undefined, + ); + if (agentId || sessionKey || accountId) { next.owner = { ...(agentId ? { agentId: sanitizeAgentId(agentId) } : {}), ...(sessionKey ? { sessionKey } : {}), + ...(accountId ? { accountId } : {}), }; } else { delete next.owner; diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 021a7916625b..51a8bed2a22c 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -7,6 +7,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { resolveCronTriggerMinIntervalMs } from "../../config/cron-limits.js"; import type { CronConfig } from "../../config/types.cron.js"; +import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { compileSafeRegexDetailed } from "../../security/safe-regex.js"; import { isCronJobActive } from "../active-jobs.js"; @@ -1097,15 +1098,17 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo } const ownerAgentId = normalizeOptionalAgentId(input.owner?.agentId); const ownerSessionKey = normalizeOptionalString(input.owner?.sessionKey); + const ownerAccountId = normalizeOptionalAccountId(input.owner?.accountId); const job: CronJob = { id, ...(declarationKey ? { declarationKey } : {}), ...(displayName ? { displayName } : {}), - ...(ownerAgentId || ownerSessionKey + ...(ownerAgentId || ownerSessionKey || ownerAccountId ? { owner: { ...(ownerAgentId ? { agentId: ownerAgentId } : {}), ...(ownerSessionKey ? { sessionKey: ownerSessionKey } : {}), + ...(ownerAccountId ? { accountId: ownerAccountId } : {}), }, } : {}), diff --git a/src/cron/store/row-codec.schedule.test.ts b/src/cron/store/row-codec.schedule.test.ts index 2ae5ca62c257..a130803368e3 100644 --- a/src/cron/store/row-codec.schedule.test.ts +++ b/src/cron/store/row-codec.schedule.test.ts @@ -11,6 +11,24 @@ function roundTrip(schedule: CronSchedule): CronSchedule | null { } describe("schedule column codec round-trip", () => { + it("round-trips the creator account through the additive job_json envelope", () => { + const job = projectCronJobThroughStorageCodec( + makeCronJob({ + owner: { + agentId: "main", + sessionKey: "agent:main:discord:group:ops", + accountId: "work", + }, + }), + ); + + expect(job.owner).toEqual({ + agentId: "main", + sessionKey: "agent:main:discord:group:ops", + accountId: "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 d5f2b426e1c3..98edb8463c81 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; +import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js"; import { normalizeCronJobInput } from "../normalize.js"; import { getInvalidPersistedCronJobReason } from "../persisted-shape.js"; @@ -271,6 +272,11 @@ function pacingFromRow(row: CronJobRow): CronPacing | undefined { } function rowToCronJob(row: CronJobRow): CronJob | null { + const jobJson = parseJsonObject>(row.job_json, {}); + const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined; + const ownerAccountId = normalizeOptionalAccountId( + typeof jsonOwner?.accountId === "string" ? jsonOwner.accountId : undefined, + ); const schedule = scheduleFromRow(row); const payload = payloadFromRow(row); const delivery = deliveryFromRow(row); @@ -285,11 +291,12 @@ function rowToCronJob(row: CronJobRow): CronJob | null { id: row.job_id, ...(row.declaration_key ? { declarationKey: row.declaration_key } : {}), ...(row.display_name ? { displayName: row.display_name } : {}), - ...(row.owner_agent_id || row.owner_session_key + ...(row.owner_agent_id || row.owner_session_key || ownerAccountId ? { owner: { ...(row.owner_agent_id ? { agentId: row.owner_agent_id } : {}), ...(row.owner_session_key ? { sessionKey: row.owner_session_key } : {}), + ...(ownerAccountId ? { accountId: ownerAccountId } : {}), }, } : {}), diff --git a/src/cron/trigger-script.test.ts b/src/cron/trigger-script.test.ts index 2924702d08d3..4c3406a686cd 100644 --- a/src/cron/trigger-script.test.ts +++ b/src/cron/trigger-script.test.ts @@ -311,13 +311,17 @@ describe("cron trigger script evaluator", () => { const runHeadless = vi.fn(async () => completed({ value: { fire: false } })); const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless }); - for (const ownerSessionKey of ["agent:main:discord:group:a", "agent:main:discord:group:b"]) { + for (const [ownerSessionKey, ownerAccountId] of [ + ["agent:main:discord:group:a", "alpha"], + ["agent:main:discord:group:b", "beta"], + ] as const) { await evaluate({ jobId: "job-owner-session", script: "return result", state: null, toolsAllow: ["write"], ownerSessionKey, + ownerAccountId, }); } @@ -325,6 +329,10 @@ describe("cron trigger script evaluator", () => { "agent:main:discord:group:a", "agent:main:discord:group:b", ]); + expect(prepareRuntime.mock.calls.map(([params]) => params.ownerAccountId)).toEqual([ + "alpha", + "beta", + ]); }); it.each([ diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts index 7634defb0f1a..8dae6db642f7 100644 --- a/src/cron/trigger-script.ts +++ b/src/cron/trigger-script.ts @@ -83,6 +83,7 @@ type PrepareTriggerRuntime = (params: { agentId?: string; toolsAllow?: string[]; ownerSessionKey?: string; + ownerAccountId?: string; signal?: AbortSignal; }) => Promise; @@ -109,6 +110,7 @@ async function prepareTriggerRuntime(params: { agentId?: string; toolsAllow?: string[]; ownerSessionKey?: string; + ownerAccountId?: string; signal?: AbortSignal; }): Promise { params.signal?.throwIfAborted(); @@ -179,6 +181,7 @@ async function prepareTriggerRuntime(params: { scheduledToolPolicy: resolveScheduledToolPolicyContext({ toolsAllow: params.toolsAllow, ownerSessionKey: params.ownerSessionKey, + ownerAccountId: params.ownerAccountId, }), toolConstructionPlan: toolPlan.codingToolConstructionPlan, }) @@ -349,6 +352,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { agentId: string; toolsAllow?: string[]; ownerSessionKey?: string; + ownerAccountId?: string; toolsAllowKey: string; signal: AbortSignal; }): Promise => { @@ -384,6 +388,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { agentId: request.requestedAgentId, toolsAllow: request.toolsAllow, ownerSessionKey: request.ownerSessionKey, + ownerAccountId: request.ownerAccountId, signal: request.signal, }); const entry: TriggerRuntimeCacheEntry = { @@ -410,6 +415,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { script: string; toolsAllow?: string[]; ownerSessionKey?: string; + ownerAccountId?: string; abortSignal?: AbortSignal; wallClockMs: number; maxToolCalls: number; @@ -430,6 +436,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { const toolsAllowKey = JSON.stringify([ params.toolsAllow ?? null, params.ownerSessionKey?.trim() || null, + params.ownerAccountId?.trim() || null, ]); const runtime = await resolveCachedRuntime({ runtimeConfig, @@ -438,6 +445,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { agentId, toolsAllow: params.toolsAllow, ownerSessionKey: params.ownerSessionKey, + ownerAccountId: params.ownerAccountId, toolsAllowKey, signal: evaluationScope.signal, }); @@ -605,6 +613,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) { streamBatch?: string; toolsAllow?: string[]; ownerSessionKey?: string; + ownerAccountId?: string; abortSignal?: AbortSignal; }): Promise => { if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) { @@ -632,6 +641,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) { streamBatch?: string; toolsAllow?: string[]; ownerSessionKey?: string; + ownerAccountId?: string; timeoutSeconds?: number; toolBudget?: number; abortSignal?: AbortSignal; diff --git a/src/cron/types.ts b/src/cron/types.ts index 14368e5e1d18..8aac667f0dba 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -463,6 +463,8 @@ export type CronJob = CronJobBase< owner?: { agentId?: string; sessionKey?: string; + /** Authenticated account that created this scheduled authority envelope. */ + accountId?: string; }; trigger?: CronTrigger; state: CronJobState; diff --git a/src/gateway/agent-runtime-identity-token.test.ts b/src/gateway/agent-runtime-identity-token.test.ts index fdcd8ad2c095..969aef6bcabe 100644 --- a/src/gateway/agent-runtime-identity-token.test.ts +++ b/src/gateway/agent-runtime-identity-token.test.ts @@ -66,6 +66,23 @@ describe("agent runtime identity token", () => { }); }); + it("round-trips the authenticated turn-source account", async () => { + useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + const token = await runtimeToken.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "session-1", + turnSourceAccountId: " Work ", + }); + + await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toEqual({ + kind: "agentRuntime", + agentId: "main", + sessionKey: "session-1", + turnSourceAccountId: "work", + }); + }); + it("does not mint local credentials while rejecting invalid presented tokens", async () => { const home = useTempHome(); const runtimeToken = await importRuntimeTokenModule(); diff --git a/src/gateway/agent-runtime-identity-token.ts b/src/gateway/agent-runtime-identity-token.ts index 1f658cd22467..1730b59550c5 100644 --- a/src/gateway/agent-runtime-identity-token.ts +++ b/src/gateway/agent-runtime-identity-token.ts @@ -6,6 +6,7 @@ import { normalizeChatType } from "../channels/chat-type.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { InternalChannelThreadingToolContext } from "../channels/threading-tool-context-internal.js"; import { ensureExecApprovalsSnapshot, loadExecApprovalsAsync } from "../infra/exec-approvals.js"; +import { normalizeOptionalAccountId } from "../routing/account-id.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import type { AgentRuntimeMessageActionContext } from "./message-action-turn-capability.js"; @@ -18,6 +19,7 @@ export type AgentRuntimeIdentity = { kind: "agentRuntime"; agentId: string; sessionKey: string; + turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; }; @@ -25,6 +27,7 @@ type AgentRuntimeIdentityTokenPayload = { kind: typeof AGENT_RUNTIME_IDENTITY_TOKEN_KIND; agentId: string; sessionKey: string; + turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; }; @@ -160,6 +163,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP kind?: unknown; agentId?: unknown; sessionKey?: unknown; + turnSourceAccountId?: unknown; messageActionContext?: unknown; }; if ( @@ -171,6 +175,9 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP } const agentId = normalizeAgentId(raw.agentId); const sessionKey = raw.sessionKey.trim(); + const turnSourceAccountId = normalizeOptionalAccountId( + typeof raw.turnSourceAccountId === "string" ? raw.turnSourceAccountId : undefined, + ); if (!agentId || !sessionKey) { return undefined; } @@ -185,6 +192,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId, sessionKey, + ...(turnSourceAccountId ? { turnSourceAccountId } : {}), ...(messageActionContext ? { messageActionContext } : {}), }; } catch { @@ -196,6 +204,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP export async function mintAgentRuntimeIdentityToken(params: { agentId: string; sessionKey: string; + turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; }): Promise { if ( @@ -215,10 +224,12 @@ export async function mintAgentRuntimeIdentityToken(params: { ), } : undefined; + const turnSourceAccountId = normalizeOptionalAccountId(params.turnSourceAccountId); const payload = encodePayload({ kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId: normalizeAgentId(params.agentId), sessionKey: params.sessionKey.trim(), + ...(turnSourceAccountId ? { turnSourceAccountId } : {}), ...(messageActionContext ? { messageActionContext } : {}), }); const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload); @@ -250,6 +261,7 @@ export async function verifyAgentRuntimeIdentityToken( kind: "agentRuntime", agentId: payload.agentId, sessionKey: payload.sessionKey, + ...(payload.turnSourceAccountId ? { turnSourceAccountId: payload.turnSourceAccountId } : {}), ...(payload.messageActionContext ? { messageActionContext: payload.messageActionContext } : {}), }; } diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index b5140108bf45..eb724707e0c8 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -607,6 +607,7 @@ export function buildGatewayCronService(params: { streamBatch, toolsAllow: job.payload.toolsAllow, ownerSessionKey: job.owner?.sessionKey, + ownerAccountId: job.owner?.accountId, abortSignal, }), } @@ -838,6 +839,7 @@ export function buildGatewayCronService(params: { streamBatch, toolsAllow: job.payload.toolsAllow, ownerSessionKey: job.owner?.sessionKey, + ownerAccountId: job.owner?.accountId, 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 ca816c103c9c..f804dcc3d88e 100644 --- a/src/gateway/server-methods/agent-handler-helpers.ts +++ b/src/gateway/server-methods/agent-handler-helpers.ts @@ -39,6 +39,7 @@ export type RestoredCronContinuation = { toolsAllow?: string[]; toolsAllowIsDefault?: boolean; ownerSessionKey?: string; + ownerAccountId?: string; 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 3d0205e7c1fa..aff93582aadc 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -355,6 +355,7 @@ export function startAgentRunExecution(params: { ? resolveScheduledToolPolicyContext({ toolsAllow: params.restoredCronContinuation.toolsAllow, ownerSessionKey: params.restoredCronContinuation.ownerSessionKey, + ownerAccountId: params.restoredCronContinuation.ownerAccountId, }) : undefined, requireExplicitMessageTarget: diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts index 4c50cf151214..e6d1d2ef0f6d 100644 --- a/src/gateway/server-methods/agent-session-persist.ts +++ b/src/gateway/server-methods/agent-session-persist.ts @@ -230,6 +230,9 @@ export async function persistAgentSessionPhase(params: { ...(marker.ownerSessionKey?.trim() ? { ownerSessionKey: marker.ownerSessionKey.trim() } : {}), + ...(marker.ownerAccountId?.trim() + ? { ownerAccountId: marker.ownerAccountId.trim() } + : {}), ...(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 ce967ca328cd..83d14899f754 100644 --- a/src/gateway/server-methods/cron-caller-scope.ts +++ b/src/gateway/server-methods/cron-caller-scope.ts @@ -1,4 +1,5 @@ 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"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import type { GatewayClient } from "./types.js"; @@ -7,6 +8,7 @@ export type CronCallerScope = { kind: "agentTool"; agentId: string; sessionKey?: string; + accountId: string; }; export function readCronCallerScope( @@ -20,6 +22,7 @@ export function readCronCallerScope( kind: "agentTool", agentId: normalizeAgentId(identity.agentId), sessionKey: identity.sessionKey?.trim() || undefined, + accountId: normalizeAccountId(identity.turnSourceAccountId), }; } @@ -75,11 +78,20 @@ export function cronJobMatchesCallerScope(params: { if (isOperatorCommandCronJob(params.job)) { return false; } + const ownerAccountId = params.job.owner?.accountId; + // Operator-created records may name an account without an owner agent; account ownership is + // therefore an independent boundary, not a refinement of ownerAgentId. + if (ownerAccountId && normalizeAccountId(ownerAccountId) !== params.callerScope.accountId) { + return false; + } // Declarative jobs retain their stamped owner when an operator retargets execution. // Ownerless jobs predate attribution, so keep their routing-based visibility. const ownerAgentId = resolveCronJobOwnerAgentId(params.job); if (ownerAgentId) { - return ownerAgentId === params.callerScope.agentId; + if (ownerAgentId !== params.callerScope.agentId) { + return false; + } + return true; } if ( resolveCronJobEffectiveAgentId(params.job, params.defaultAgentId) !== params.callerScope.agentId @@ -154,6 +166,7 @@ export function applyCronCreateCallerScopeDefault( owner: { agentId: callerScope.agentId, ...(callerScope.sessionKey ? { sessionKey: callerScope.sessionKey } : {}), + accountId: callerScope.accountId, }, }; } diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index a8d6c73a1574..461e1af2f0d3 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -263,7 +263,7 @@ function createCronJob(overrides: Partial = {}): CronJob { }; } -function callerClient(agentId: string): GatewayClient { +function callerClient(agentId: string, accountId?: string): GatewayClient { return { connect: {} as GatewayClient["connect"], internal: { @@ -271,6 +271,7 @@ function callerClient(agentId: string): GatewayClient { kind: "agentRuntime", agentId, sessionKey: `agent:${agentId}:main`, + ...(accountId ? { turnSourceAccountId: accountId } : {}), }, }, }; @@ -1100,7 +1101,11 @@ describe("cron method validation", () => { ); const payload = requireCronAddPayload(context); - expect(payload.owner).toEqual({ agentId: "ops", sessionKey: "agent:ops:main" }); + expect(payload.owner).toEqual({ + agentId: "ops", + sessionKey: "agent:ops:main", + accountId: "default", + }); const options = requireRecord(context.cron.add.mock.calls[0]?.[1], "cron.add options"); const matchesExisting = options.matchesExisting as ((job: CronJob) => boolean) | undefined; expect(matchesExisting?.(createCronJob({ agentId: "ops" }))).toBe(true); @@ -1108,12 +1113,33 @@ describe("cron method validation", () => { expect(matchesExisting?.(createCronJob({ agentId: "worker", owner: { agentId: "ops" } }))).toBe( true, ); + expect( + matchesExisting?.( + createCronJob({ + agentId: "worker", + owner: { agentId: "ops", accountId: "work" }, + }), + ), + ).toBe(false); expect(matchesExisting?.(createCronJob({ agentId: "ops", owner: { agentId: "worker" } }))).toBe( false, ); expectCronSuccess(respond); }); + it("stamps declaration ownership from the authenticated source account", async () => { + const { context, respond } = await invokeCronAdd(agentTurnCronParams(), { + client: callerClient("ops", "work"), + }); + + expect(requireCronAddPayload(context).owner).toEqual({ + agentId: "ops", + sessionKey: "agent:ops:main", + accountId: "work", + }); + expectCronSuccess(respond); + }); + it("keeps scoped read access with the stamped owner after operator retargeting", async () => { const job = createCronJob({ agentId: "worker", @@ -1134,6 +1160,73 @@ describe("cron method validation", () => { ); }); + it("keeps new jobs scoped to their creator account while preserving accountless legacy jobs", async () => { + const workOwned = createCronJob({ + owner: { agentId: "ops", sessionKey: "agent:ops:main", accountId: "work" }, + }); + const context = createCronContext(workOwned); + + const wrongAccount = await invokeCron( + "cron.list", + { compact: true }, + { context, client: callerClient("ops", "default") }, + ); + expect(wrongAccount.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 0, jobs: [] }), + undefined, + ); + + const matchingAccount = await invokeCron( + "cron.list", + { compact: true }, + { context, client: callerClient("ops", "work") }, + ); + expect(matchingAccount.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 1 }), + undefined, + ); + + const legacyContext = createCronContext( + createCronJob({ owner: { agentId: "ops", sessionKey: "agent:ops:main" } }), + ); + const legacy = await invokeCron( + "cron.list", + { compact: true }, + { context: legacyContext, client: callerClient("ops", "other") }, + ); + expect(legacy.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 1 }), + undefined, + ); + + const accountOnlyContext = createCronContext( + createCronJob({ agentId: "ops", owner: { accountId: "work" } }), + ); + const accountOnlyWrong = await invokeCron( + "cron.list", + { compact: true }, + { context: accountOnlyContext, client: callerClient("ops", "other") }, + ); + expect(accountOnlyWrong.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 0, jobs: [] }), + undefined, + ); + const accountOnlyMatch = await invokeCron( + "cron.list", + { compact: true }, + { context: accountOnlyContext, client: callerClient("ops", "work") }, + ); + expect(accountOnlyMatch.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 1 }), + undefined, + ); + }); + it("keeps explicit declaration ownership for operator callers", async () => { const owner = { agentId: "ops", sessionKey: "agent:ops:main" }; const { context, respond } = await invokeCronAdd( diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 9f02ffe5e0d3..36b245cd8a7c 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -26,7 +26,7 @@ type CreateOpenClawCodingToolsArg = { workspaceDir?: string; cwd?: string; wrapBeforeToolCallHook?: boolean; - scheduledToolPolicy?: { ownerSessionKey: string }; + scheduledToolPolicy?: { ownerSessionKey: string; ownerAccountId: string }; }; type LazyExecToolDefaults = { @@ -161,7 +161,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { surface: "loopback", excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"], mediatedToolNames: ["write"], - scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:qa-channel:group:ops", + ownerAccountId: "default", + }, }); expect(result.tools.map((tool) => tool.name)).toContain("write"); @@ -173,7 +176,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { workspaceDir: "/workspace", cwd: "/workspace/task", wrapBeforeToolCallHook: false, - scheduledToolPolicy: { ownerSessionKey: "agent:main:qa-channel:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:qa-channel:group:ops", + ownerAccountId: "default", + }, }), ); expect(hoisted.createLazyExecToolMock).not.toHaveBeenCalled(); diff --git a/src/gateway/worker-environments/placement-dispatch-recovery.ts b/src/gateway/worker-environments/placement-dispatch-recovery.ts index 20b93c88f48a..ecde00381721 100644 --- a/src/gateway/worker-environments/placement-dispatch-recovery.ts +++ b/src/gateway/worker-environments/placement-dispatch-recovery.ts @@ -1,3 +1,4 @@ +import { WORKER_LAUNCH_V2_PROTOCOL_FEATURE } from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; import { isUnavailableEnvironment, type WorkerActiveDispatchPlacement, @@ -12,6 +13,15 @@ import { } from "./placement-dispatch-pending-results.js"; import type { WorkerEnvironmentService } from "./service.js"; +function supportsWorkerLaunchV2(environment: ReturnType): boolean { + // A persisted bundle hash can still match a pre-v2 worker when current bundle preparation fails. + // Require the admitted receipt so restart recovery never revives an incompatible launch contract. + return ( + environment?.bootstrapReceipt?.protocolFeatures.includes(WORKER_LAUNCH_V2_PROTOCOL_FEATURE) === + true + ); +} + function sameActiveEnvironment( placement: WorkerActiveDispatchPlacement | WorkerDrainingDispatchPlacement, environment: ReturnType, @@ -25,6 +35,7 @@ function sameActiveEnvironment( environment.ownerEpoch === placement.activeOwnerEpoch && placement.workerBundleHash && environment.bootstrapReceipt?.bundleHash === placement.workerBundleHash && + supportsWorkerLaunchV2(environment) && environment.attachedSessionIds.length === 1 && environment.attachedSessionIds[0] === placement.sessionId, ); @@ -120,6 +131,7 @@ export function createPlacementRecoveryActions(deps: PlacementRecoveryDeps) { environment && expectedBundle && environment.bootstrapReceipt?.bundleHash === expectedBundle && + supportsWorkerLaunchV2(environment) && hasSyncedWorkspace; if (!canResume) { const error = new Error("Interrupted worker dispatch cannot safely resume"); diff --git a/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts b/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts index 8a32cc453e28..37c67473c461 100644 --- a/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts +++ b/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts @@ -1,4 +1,7 @@ -import type { WorkerAdmissionHandshake } from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; +import { + WORKER_LAUNCH_V2_PROTOCOL_FEATURE, + type WorkerAdmissionHandshake, +} from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; import type { WorkerProfile, WorkerSshEndpoint } from "../../plugins/types.js"; import type { WorkerDispatchEnvironmentService } from "./placement-dispatch-failure.js"; import type { createWorkerPlacementDispatchService } from "./placement-dispatch.js"; @@ -86,7 +89,7 @@ export function createDispatchEnvironmentFixtures() { const bootstrapReceipt: WorkerAdmissionHandshake = { bundleHash: BUNDLE_HASH, openclawVersion: "2026.7.2", - protocolFeatures: [], + protocolFeatures: [WORKER_LAUNCH_V2_PROTOCOL_FEATURE], }; const sshEndpoint: WorkerSshEndpoint = { host: "worker.example.test", diff --git a/src/gateway/worker-environments/placement-dispatch-test-harness.ts b/src/gateway/worker-environments/placement-dispatch-test-harness.ts index a050d503fc71..e31aa8e88107 100644 --- a/src/gateway/worker-environments/placement-dispatch-test-harness.ts +++ b/src/gateway/worker-environments/placement-dispatch-test-harness.ts @@ -373,6 +373,15 @@ export function createHarness( markEnvironmentAttachments: (attachedSessionIds: string[]) => { currentEnvironment = { ...attached, attachedSessionIds }; }, + markEnvironmentProtocolFeatures: (protocolFeatures: string[]) => { + if (!currentEnvironment?.bootstrapReceipt) { + throw new Error("worker environment fixture has no bootstrap receipt"); + } + currentEnvironment = { + ...currentEnvironment, + bootstrapReceipt: { ...currentEnvironment.bootstrapReceipt, protocolFeatures }, + }; + }, service, ready, attached, diff --git a/src/gateway/worker-environments/placement-dispatch.test.ts b/src/gateway/worker-environments/placement-dispatch.test.ts index 729864bf6f44..6b9f9f2b54d3 100644 --- a/src/gateway/worker-environments/placement-dispatch.test.ts +++ b/src/gateway/worker-environments/placement-dispatch.test.ts @@ -458,6 +458,23 @@ describe("worker placement dispatch", () => { expect(harness.environments.destroy).not.toHaveBeenCalled(); }); + it("reclaims an active pre-v2 worker instead of adopting its stale launch contract", async () => { + const harness = createHarness(placementStore); + await harness.environments.attachSession({ + environmentId: harness.ready.environmentId, + ownerEpoch: harness.ready.ownerEpoch, + sessionId: REQUEST.sessionId, + }); + harness.placements.seedActive(harness.attached.ownerEpoch); + harness.markEnvironmentProtocolFeatures([]); + + await harness.service.reconcile(); + + expect(harness.placements.current()).toMatchObject({ state: "reclaimed" }); + expect(harness.environments.startTunnel).not.toHaveBeenCalled(); + expect(harness.environments.destroy).toHaveBeenCalledOnce(); + }); + it("reclaims an active placement whose environment is already terminal after restart", async () => { const harness = createHarness(placementStore); harness.placements.seedActive(harness.attached.ownerEpoch); @@ -580,6 +597,21 @@ describe("worker placement dispatch", () => { expect(harness.environments.create).not.toHaveBeenCalled(); }); + it("tears down a starting pre-v2 worker instead of resuming its stale launch contract", async () => { + const harness = createHarness(placementStore); + harness.placements.seedStarting(); + harness.markEnvironmentProtocolFeatures([]); + + await harness.service.reconcile(); + + expect(harness.placements.current()).toMatchObject({ + state: "failed", + recoveryError: "Interrupted worker dispatch cannot safely resume", + }); + expect(harness.environments.startTunnel).not.toHaveBeenCalled(); + expect(harness.environments.destroy).toHaveBeenCalledOnce(); + }); + it("finishes an interrupted drain through reconciliation before failure", async () => { const harness = createHarness(placementStore); harness.placements.seedDraining(harness.attached.ownerEpoch); diff --git a/src/gateway/worker-environments/worker-tool-authority.test.ts b/src/gateway/worker-environments/worker-tool-authority.test.ts index bf9acfb3fa37..6e0d035b0045 100644 --- a/src/gateway/worker-environments/worker-tool-authority.test.ts +++ b/src/gateway/worker-environments/worker-tool-authority.test.ts @@ -60,7 +60,10 @@ describe("resolveWorkerToolAuthority", () => { messageProvider: "whatsapp", senderId: "guest", toolsAllow: ["read", "write", "exec"], - scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:whatsapp:group:team", + ownerAccountId: "default", + }, }), ).toEqual(["read", "write", "apply_patch"]); expect( @@ -85,7 +88,10 @@ describe("resolveWorkerToolAuthority", () => { }, messageProvider: "whatsapp", toolsAllow: ["write"], - scheduledToolPolicy: { ownerSessionKey: "agent:main:whatsapp:group:team" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:whatsapp:group:team", + ownerAccountId: "default", + }, }), ).toEqual([]); }); diff --git a/src/gateway/worker-environments/worker-turn-payload.test.ts b/src/gateway/worker-environments/worker-turn-payload.test.ts index 8d3ef81ba812..0c8b062f7694 100644 --- a/src/gateway/worker-environments/worker-turn-payload.test.ts +++ b/src/gateway/worker-environments/worker-turn-payload.test.ts @@ -22,7 +22,10 @@ describe("assertSupportedTurn", () => { }, }, toolsAllow: ["write"], - scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" }, + scheduledToolPolicy: { + ownerSessionKey: "agent:main:discord:group:ops", + ownerAccountId: "default", + }, } as SessionPlacementTurnParams), ).toEqual({ provider: "openai", model: "gpt-5.4" }); });