From 01565bdc475a1c8afd785f4b0be27cdab8d2a5dc Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:26:24 -0600 Subject: [PATCH] fix(agents): make requested threads sidebar-visible (#118073) * fix(agents): make requested threads sidebar-visible * fix(agents): preserve visible spawn authority --- docs/tools/subagents.md | 2 +- src/agents/requester-tool-policy.test.ts | 85 +++++++++ src/agents/requester-tool-policy.ts | 6 + src/agents/subagent-capabilities.ts | 53 +++++- src/agents/tool-description-presets.ts | 2 +- .../tools/gateway-session-spawn-context.ts | 16 ++ .../tools/gateway.runtime-identity.test.ts | 32 ++++ src/agents/tools/gateway.ts | 7 +- src/agents/tools/in-process-gateway.test.ts | 40 +++++ src/agents/tools/in-process-gateway.ts | 21 ++- src/agents/tools/sessions-spawn-tool.test.ts | 43 ++++- src/agents/tools/sessions-spawn-visible.ts | 18 +- .../agent-runtime-identity-token.test.ts | 31 ++++ src/gateway/agent-runtime-identity-token.ts | 50 ++++++ src/gateway/server-chat.agent-events.test.ts | 27 ++- src/gateway/server-chat.ts | 15 +- .../session-creation-provenance.ts | 29 ++- src/gateway/server-methods/sessions-create.ts | 28 ++- src/gateway/server.sessions.create.test.ts | 169 ++++++++++++++++++ .../server.sessions.reset-models.test.ts | 4 + src/gateway/session-create-service.ts | 52 ++++++ src/gateway/session-reset-service.ts | 4 + .../codex-dynamic-tools.discord-group.json | 4 +- .../codex-dynamic-tools.telegram-direct.json | 4 +- .../discord-group-codex-message-tool.md | 8 +- .../telegram-direct-codex-message-tool.md | 8 +- .../telegram-heartbeat-codex-tool.md | 8 +- 27 files changed, 707 insertions(+), 59 deletions(-) create mode 100644 src/agents/tools/gateway-session-spawn-context.ts diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index d1868c61bec6..b9d94fb1fdf5 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -255,7 +255,7 @@ their latest assistant turn back to the requester; external delivery stays with the parent/requester agent. -With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, `lightContext`, `attachments`, and `attachAs` are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. Visible spawning is rejected when the requester was itself spawned with an inherited tool allowlist or denylist; that restriction is fixed at spawn time and has no config override. Session listing and addressing obey `tools.sessions.visibility`; the default `tree` scope covers the current session and its own spawn subtree. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior. +With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. Use this mode when the user asks to create or open a thread that should appear in the sidebar. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, `lightContext`, `attachments`, and `attachAs` are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. The new dashboard child inherits the requester's effective tool-policy ceiling before its first turn. Session listing and addressing obey `tools.sessions.visibility`; the default `tree` scope covers the current session and its own spawn subtree. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior. ### Task names and targeting diff --git a/src/agents/requester-tool-policy.test.ts b/src/agents/requester-tool-policy.test.ts index 51a706416875..bc767dad9da0 100644 --- a/src/agents/requester-tool-policy.test.ts +++ b/src/agents/requester-tool-policy.test.ts @@ -101,6 +101,36 @@ describe("resolveRequesterToolPolicies", () => { expect(result.subagentPolicy).toBeDefined(); }); + it("uses a persisted projection for a spawn-owned dashboard child", async () => { + const parentSessionKey = "agent:main:main"; + const childSessionKey = "agent:main:dashboard:visible-child"; + await writeSession(childSessionKey, { + spawnedBy: parentSessionKey, + parentSessionKey, + spawnDepth: 1, + inheritedToolPolicyVersion: 1, + inheritedToolAllow: ["read", "sessions_spawn"], + inheritedToolDeny: ["exec"], + }); + + const result = resolveRequesterToolPolicies({ + config: config(), + agentId: "main", + sessionKey: childSessionKey, + spawnedBy: parentSessionKey, + }); + + expect(result.delegated).toBe(true); + expect(result.requesterPolicySource).toBe("persisted-child"); + expect(result.senderPolicy).toBeUndefined(); + expect(result.groupPolicy).toBeUndefined(); + expect(result.inheritedToolPolicy).toEqual({ + allow: ["read", "sessions_spawn"], + deny: ["exec"], + }); + expect(result.subagentPolicy).toBeDefined(); + }); + it("keeps the sender snapshot while applying current non-sender restrictions", async () => { const childSessionKey = "agent:main:subagent:web-search"; await writeSession(childSessionKey, { @@ -417,6 +447,61 @@ describe("resolveRequesterToolPolicies", () => { expect(controllerResult.requesterPolicySource).toBe("current-request"); }); + it("restores a visible dashboard child completion to its immutable owner", async () => { + const controllerSessionKey = "agent:main:discord:direct:alice"; + const completionOwnerSessionKey = "agent:main:main"; + const childSessionKey = "agent:main:dashboard:visible-child"; + await writeSession(childSessionKey, { + spawnedBy: controllerSessionKey, + completionOwnerSessionKey, + spawnDepth: 1, + inheritedToolPolicyVersion: 1, + inheritedToolAllow: ["read", "message"], + inheritedToolDeny: ["exec"], + }); + + const result = resolveRequesterToolPolicies({ + config: config(), + agentId: "main", + sessionKey: completionOwnerSessionKey, + ...completionHandoffFacts(childSessionKey, completionOwnerSessionKey), + inputProvenance: { + kind: "inter_session", + sourceSessionKey: childSessionKey, + sourceTool: "subagent_announce", + }, + }); + + expect(result).toMatchObject({ + delegated: true, + requesterPolicySource: "completion-handoff", + inheritedToolPolicy: { + allow: ["read", "message"], + deny: ["exec"], + }, + }); + }); + + it("does not treat an ordinary dashboard key as a completion authority", async () => { + const childSessionKey = "agent:main:dashboard:operator-thread"; + await writeSession(childSessionKey, { spawnDepth: 0 }); + + const result = resolveRequesterToolPolicies({ + config: config(), + agentId: "main", + sessionKey: "agent:main:main", + ...completionHandoffFacts(childSessionKey, "agent:main:main"), + inputProvenance: { + kind: "inter_session", + sourceSessionKey: childSessionKey, + sourceTool: "subagent_announce", + }, + }); + + expect(result.delegated).toBe(false); + expect(result.requesterPolicySource).toBe("current-request"); + }); + it("walks nested lineage to the projection captured from the target requester", async () => { const requesterSessionKey = "agent:main:discord:direct:alice"; const parentChildSessionKey = "agent:main:subagent:parent-child"; diff --git a/src/agents/requester-tool-policy.ts b/src/agents/requester-tool-policy.ts index 6e9448eb040f..31139d2a79e0 100644 --- a/src/agents/requester-tool-policy.ts +++ b/src/agents/requester-tool-policy.ts @@ -123,8 +123,14 @@ function resolveDelegatedPolicy( return { delegated: false }; } visited.add(currentSessionKey); + // The signed handoff authorizes the one store lookup needed for dashboard + // children; the persisted envelope still has to prove lineage and depth. + const completionStore = resolveSubagentCapabilityStore(currentSessionKey, { + cfg: params.config, + }); const envelope = resolvePersistedSubagentToolPolicyEnvelope(currentSessionKey, { cfg: params.config, + store: completionStore, }); if (!envelope) { return { delegated: false }; diff --git a/src/agents/subagent-capabilities.ts b/src/agents/subagent-capabilities.ts index c8f14c2d8b2c..acecf4fb7906 100644 --- a/src/agents/subagent-capabilities.ts +++ b/src/agents/subagent-capabilities.ts @@ -92,6 +92,20 @@ function shouldInspectStoredSubagentEnvelope(sessionKey: string): boolean { return isSubagentSessionKey(sessionKey) || isAcpSessionKey(sessionKey); } +function isDashboardSessionKey(sessionKey: string): boolean { + return parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true; +} + +function canInspectStoredSubagentEnvelope( + sessionKey: string, + store?: SessionCapabilityStore, +): boolean { + return ( + shouldInspectStoredSubagentEnvelope(sessionKey) || + (Boolean(store) && isDashboardSessionKey(sessionKey)) + ); +} + function isSameAgentSessionStore(leftSessionKey: string, rightSessionKey: string): boolean { const leftAgentId = normalizeOptionalLowercaseString( parseAgentSessionKey(leftSessionKey)?.agentId, @@ -140,7 +154,13 @@ export function resolveSubagentCapabilityStore( if (opts?.store) { return opts.store; } - if (!opts?.cfg || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) { + // Dashboard key shape permits only a store lookup. Callers still require a + // persisted spawn envelope before granting subagent authority. + if ( + !opts?.cfg || + (!shouldInspectStoredSubagentEnvelope(normalizedSessionKey) && + !isDashboardSessionKey(normalizedSessionKey)) + ) { return undefined; } const parsed = parseAgentSessionKey(normalizedSessionKey); @@ -204,7 +224,8 @@ function isStoredSubagentEnvelopeSession( if (isSubagentSessionKey(normalizedSessionKey)) { return true; } - if (!isAcpSessionKey(normalizedSessionKey)) { + const dashboardSession = isDashboardSessionKey(normalizedSessionKey); + if (!isAcpSessionKey(normalizedSessionKey) && !dashboardSession) { return false; } @@ -215,6 +236,14 @@ function isStoredSubagentEnvelopeSession( cfg: params.cfg, store: params.store, }); + if (dashboardSession) { + return ( + typeof entry?.spawnDepth === "number" && + Number.isInteger(entry.spawnDepth) && + entry.spawnDepth >= 1 && + Boolean(normalizeOptionalString(entry.spawnedBy)) + ); + } if ( normalizeSubagentRole(entry?.subagentRole) || normalizeSubagentControlScope(entry?.subagentControlScope) @@ -257,7 +286,10 @@ export function isSubagentEnvelopeSession( if (isSubagentSessionKey(normalizedSessionKey)) { return true; } - if (!isAcpSessionKey(normalizedSessionKey)) { + if (!isAcpSessionKey(normalizedSessionKey) && !isDashboardSessionKey(normalizedSessionKey)) { + return false; + } + if (isDashboardSessionKey(normalizedSessionKey) && !opts?.entry && !opts?.store) { return false; } const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts); @@ -282,7 +314,10 @@ export function resolvePersistedSubagentToolPolicyEnvelope( }, ): PersistedSubagentToolPolicyEnvelope | undefined { const normalizedSessionKey = normalizeOptionalString(sessionKey); - if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) { + if ( + !normalizedSessionKey || + !canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store) + ) { return undefined; } const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts); @@ -382,7 +417,10 @@ export function resolveStoredSubagentInheritedToolDenylist( }, ): string[] { const normalizedSessionKey = normalizeOptionalString(sessionKey); - if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) { + if ( + !normalizedSessionKey || + !canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store) + ) { return []; } const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts); @@ -403,7 +441,10 @@ export function resolveStoredSubagentInheritedToolAllowlist( }, ): string[] { const normalizedSessionKey = normalizeOptionalString(sessionKey); - if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) { + if ( + !normalizedSessionKey || + !canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store) + ) { return []; } const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts); diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index c02f99b751ed..e462984823a8 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -109,7 +109,7 @@ export function describeSessionsSpawnTool(options?: { ? '`mode="run"` one-shot; `mode="session"` persistent/thread-bound only on supporting requester channel.' : '`mode="run"` one-shot background.', "`agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require.", - '`visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode="run"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`.', + '`visible=true`: persistent sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode="run"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`.', visibilityLine, ...(options?.swarmEnabled ? [ diff --git a/src/agents/tools/gateway-session-spawn-context.ts b/src/agents/tools/gateway-session-spawn-context.ts new file mode 100644 index 000000000000..20aa8a431c08 --- /dev/null +++ b/src/agents/tools/gateway-session-spawn-context.ts @@ -0,0 +1,16 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { AgentRuntimeSessionSpawnContext } from "../../gateway/agent-runtime-identity-token.js"; + +const sessionSpawnContext = new AsyncLocalStorage(); + +/** Scope signed session-creation authority to one local Gateway tool call. */ +export function runWithGatewaySessionSpawnContext( + context: AgentRuntimeSessionSpawnContext, + run: () => Promise, +): Promise { + return sessionSpawnContext.run(context, run); +} + +export function getGatewaySessionSpawnContext(): AgentRuntimeSessionSpawnContext | undefined { + return sessionSpawnContext.getStore(); +} diff --git a/src/agents/tools/gateway.runtime-identity.test.ts b/src/agents/tools/gateway.runtime-identity.test.ts index a6c0eafe7edb..8bd87bc70794 100644 --- a/src/agents/tools/gateway.runtime-identity.test.ts +++ b/src/agents/tools/gateway.runtime-identity.test.ts @@ -7,6 +7,7 @@ import { revokeMessageActionTurnCapability, } from "../../gateway/message-action-turn-capability.js"; import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js"; +import { runWithGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js"; import { callGatewayTool, resolveMessageActionAgentRuntimeIdentityToken } from "./gateway.js"; const mocks = vi.hoisted(() => ({ @@ -65,6 +66,37 @@ describe("gateway tool runtime identity", () => { }, ); + it("scopes signed session-spawn authority to its Gateway call", async () => { + mocks.callGateway.mockResolvedValueOnce({ key: "agent:ops:dashboard:child" }); + + await withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:main" }, + async () => + await runWithGatewaySessionSpawnContext( + { + completionOwnerSessionKey: "agent:ops:discord:direct:alice", + inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] }, + }, + () => + callGatewayTool( + "sessions.create", + {}, + { parentSessionKey: "agent:ops:main", spawnDepth: 1 }, + { requireAgentRuntimeIdentity: true }, + ), + ), + ); + + await expect( + verifyAgentRuntimeIdentityToken(capturedGatewayCall().agentRuntimeIdentityToken), + ).resolves.toMatchObject({ + sessionSpawnContext: { + completionOwnerSessionKey: "agent:ops:discord:direct:alice", + inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] }, + }, + }); + }); + it("mints message action identity only for an exact admitted source turn", async () => { const capabilityInput = { agentId: "ops", diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index f190c62cd476..f30496486cec 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -32,6 +32,7 @@ import { import { formatErrorMessage } from "../../infra/errors.js"; import { readPositiveIntegerParam, readStringParam } from "./common.js"; import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js"; +import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js"; /** Optional gateway connection overrides accepted by agent tools. */ export type GatewayCallOptions = { @@ -366,7 +367,11 @@ async function resolveAgentRuntimeIdentityTokenForGatewayTool(params: { throw new Error("agent gateway calls require the trusted local gateway context"); } try { - return await mintAgentRuntimeIdentityToken(identity); + const sessionSpawnContext = getGatewaySessionSpawnContext(); + return await mintAgentRuntimeIdentityToken({ + ...identity, + ...(sessionSpawnContext ? { sessionSpawnContext } : {}), + }); } catch (error) { if (optionalLocalIdentity && !params.required) { return undefined; diff --git a/src/agents/tools/in-process-gateway.test.ts b/src/agents/tools/in-process-gateway.test.ts index 322bcc492eac..3b761c2aad89 100644 --- a/src/agents/tools/in-process-gateway.test.ts +++ b/src/agents/tools/in-process-gateway.test.ts @@ -18,6 +18,7 @@ vi.mock("../../gateway/server-plugins.js", () => ({ vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool })); +import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js"; import { callInProcessGatewayToolWithCreation } from "./in-process-gateway.js"; describe("trusted in-process Gateway session creation", () => { @@ -55,4 +56,43 @@ describe("trusted in-process Gateway session creation", () => { { scopes: ["operator.write"] }, ); }); + + it("carries visible-spawn policy through signed identity on fallback dispatch", async () => { + mocks.hasContext = false; + const inheritedToolPolicy = { + version: 1 as const, + allow: ["read", "sessions_spawn"], + deny: ["exec"], + }; + + mocks.callGatewayTool.mockImplementationOnce(async () => { + expect(getGatewaySessionSpawnContext()).toEqual({ + completionOwnerSessionKey: "agent:main:discord:direct:alice", + inheritedToolPolicy, + }); + return { key: "agent:main:dashboard:child" }; + }); + + await callInProcessGatewayToolWithCreation( + "sessions.create", + { agentId: "main", parentSessionKey: "agent:main:main", spawnDepth: 1 }, + { + via: "spawn", + actor: { type: "agent", id: "agent:main:main" }, + completionOwnerSessionKey: "agent:main:discord:direct:alice", + inheritedToolPolicy, + }, + ); + + expect(mocks.callGatewayTool).toHaveBeenCalledWith( + "sessions.create", + {}, + { agentId: "main", parentSessionKey: "agent:main:main", spawnDepth: 1 }, + { + scopes: ["operator.write"], + requireAgentRuntimeIdentity: true, + }, + ); + expect(getGatewaySessionSpawnContext()).toBeUndefined(); + }); }); diff --git a/src/agents/tools/in-process-gateway.ts b/src/agents/tools/in-process-gateway.ts index 4f9206ae3e31..e6f0a17d8cd9 100644 --- a/src/agents/tools/in-process-gateway.ts +++ b/src/agents/tools/in-process-gateway.ts @@ -7,6 +7,7 @@ import { getInProcessGatewayRequestContext, hasInProcessGatewayContext, } from "../../gateway/server-plugins.js"; +import { runWithGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js"; import { callGatewayTool } from "./gateway.js"; export type InProcessGatewayCaller = >( @@ -49,6 +50,22 @@ export async function callInProcessGatewayToolWithCreation(method, {}, params, { scopes }); + // The fallback is a real local Gateway request. Carry spawn policy only in + // the signed agent-runtime identity token, never in model-authored params. + if (creation.via !== "spawn" || !creation.inheritedToolPolicy) { + return await callGatewayTool(method, {}, params, { scopes }); + } + return await runWithGatewaySessionSpawnContext( + { + ...(creation.completionOwnerSessionKey + ? { completionOwnerSessionKey: creation.completionOwnerSessionKey } + : {}), + inheritedToolPolicy: creation.inheritedToolPolicy, + }, + () => + callGatewayTool(method, {}, params, { + scopes, + requireAgentRuntimeIdentity: true, + }), + ); } diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 249227801f17..c42126248414 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -8,6 +8,7 @@ import { SWARM_CODE_MODE_IDEMPOTENCY_KEY, SWARM_CODE_MODE_REQUEST_FINGERPRINT, } from "../swarm-code-mode.js"; +import type { InProcessGatewayCaller } from "./in-process-gateway.js"; const hoisted = vi.hoisted(() => { const spawnSubagentDirectMock = vi.fn(); @@ -388,11 +389,12 @@ describe("sessions_spawn tool", () => { }; expect(schema.properties?.visible?.description).toBe( - "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.", + "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.", ); - expect(tool.description).toContain("`visible=true`: persistent dashboard session"); + expect(tool.description).toContain("`visible=true`: persistent sidebar dashboard session"); + expect(tool.description).toContain("when the user asks to create/open a thread"); expect(tool.description).toContain('no `mode="run"`'); - expect(tool.description).toContain("inherited tool allow/denylist"); + expect(tool.description).toContain("inherits the caller tool-policy ceiling"); expect(tool.description).toContain("`tools.sessions.visibility`"); expect(schema.properties?.runtime?.description).toContain("visible=true"); expect(schema.properties?.mode?.description).toContain("Omit with visible=true"); @@ -718,26 +720,49 @@ describe("sessions_spawn tool", () => { ); }); - it("denies visible sessions when tool restrictions cannot carry forward", async () => { - const callGateway = vi.fn(); + it("creates visible sessions while carrying inherited tool restrictions forward", async () => { + const callGateway = vi.fn(async () => ({ + key: "agent:main:dashboard:restricted-child", + runStarted: true, + runId: "run-visible-restricted", + })) as InProcessGatewayCaller; + const registerRun = vi.fn(); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", config: { agents: { list: [{ id: "main" }] } }, + inheritedToolAllowlist: ["read", "sessions_spawn"], inheritedToolDenylist: ["exec"], callGateway, + registerRun, + countActiveRuns: () => 0, }); const result = await tool.execute("visible-restricted", { task: "inspect", + label: "Track upstream fix", visible: true, }); expect(result.details).toMatchObject({ - status: "forbidden", - error: - "Visible sessions unavailable with inherited tool restrictions. This session was spawned with a tool allow/denylist; visible sessions require an unrestricted session.", + status: "accepted", + childSessionKey: "agent:main:dashboard:restricted-child", + runId: "run-visible-restricted", }); - expect(callGateway).not.toHaveBeenCalled(); + expect(callGateway).toHaveBeenCalledWith( + "sessions.create", + expect.objectContaining({ + agentId: "main", + label: "Track upstream fix", + parentSessionKey: "agent:main:main", + spawnDepth: 1, + }), + ); + expect(registerRun).toHaveBeenCalledWith( + expect.objectContaining({ + childSessionKey: "agent:main:dashboard:restricted-child", + runId: "run-visible-restricted", + }), + ); }); it("blocks unsandboxed visible targets for a sandboxed caller runtime", async () => { diff --git a/src/agents/tools/sessions-spawn-visible.ts b/src/agents/tools/sessions-spawn-visible.ts index 0bc4a0edfbb6..049a77d3cafd 100644 --- a/src/agents/tools/sessions-spawn-visible.ts +++ b/src/agents/tools/sessions-spawn-visible.ts @@ -35,7 +35,7 @@ export const VISIBLE_SESSIONS_SPAWN_SCHEMA = { visible: Type.Optional( Type.Boolean({ description: - "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.", + "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.", }), ), worktree: Type.Optional(Type.Boolean({ description: "Visible session worktree" })), @@ -161,16 +161,6 @@ export async function maybeSpawnVisibleSession(params: { } const cfg = params.options?.config ?? getRuntimeConfig(); - if ( - (params.options?.inheritedToolAllowlist?.length ?? 0) > 0 || - (params.options?.inheritedToolDenylist?.length ?? 0) > 0 - ) { - return { - status: "forbidden", - error: - "Visible sessions unavailable with inherited tool restrictions. This session was spawned with a tool allow/denylist; visible sessions require an unrestricted session.", - }; - } const ownership = resolveSubagentSpawnOwnership({ cfg, agentSessionKey: params.options?.agentSessionKey, @@ -288,6 +278,12 @@ export async function maybeSpawnVisibleSession(params: { callInProcessGatewayToolWithCreation(method, requestParams, { via: "spawn", actor: { type: "agent", id: requesterKey }, + completionOwnerSessionKey: ownership.completionRequesterSessionKey, + inheritedToolPolicy: { + version: 1, + allow: [...(params.options?.inheritedToolAllowlist ?? [])], + deny: [...(params.options?.inheritedToolDenylist ?? [])], + }, })); const response = await createGatewayCall<{ key?: string; diff --git a/src/gateway/agent-runtime-identity-token.test.ts b/src/gateway/agent-runtime-identity-token.test.ts index 06808d2b1246..cf16db533fb3 100644 --- a/src/gateway/agent-runtime-identity-token.test.ts +++ b/src/gateway/agent-runtime-identity-token.test.ts @@ -84,6 +84,37 @@ describe("agent runtime identity token", () => { }); }); + it("round-trips a signed visible-session spawn policy", async () => { + useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + const token = await runtimeToken.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "agent:main:main", + sessionSpawnContext: { + completionOwnerSessionKey: " agent:main:discord:direct:alice ", + inheritedToolPolicy: { + version: 1, + allow: [" read ", "sessions_spawn"], + deny: ["exec"], + }, + }, + }); + + await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toMatchObject({ + kind: "agentRuntime", + agentId: "main", + sessionKey: "agent:main:main", + sessionSpawnContext: { + completionOwnerSessionKey: "agent:main:discord:direct:alice", + inheritedToolPolicy: { + version: 1, + allow: ["read", "sessions_spawn"], + deny: ["exec"], + }, + }, + }); + }); + it("round-trips a short-lived cron self-management capability", async () => { useTempHome(); const runtimeToken = await importRuntimeTokenModule(); diff --git a/src/gateway/agent-runtime-identity-token.ts b/src/gateway/agent-runtime-identity-token.ts index 73cbdba2a868..68fbbde8fa9b 100644 --- a/src/gateway/agent-runtime-identity-token.ts +++ b/src/gateway/agent-runtime-identity-token.ts @@ -28,6 +28,16 @@ export type AgentRuntimeIdentity = { turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext; + sessionSpawnContext?: AgentRuntimeSessionSpawnContext; +}; + +export type AgentRuntimeSessionSpawnContext = { + completionOwnerSessionKey?: string; + inheritedToolPolicy: { + version: 1; + allow: string[]; + deny: string[]; + }; }; type AgentRuntimeIdentityTokenPayload = { @@ -37,8 +47,36 @@ type AgentRuntimeIdentityTokenPayload = { turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext; + sessionSpawnContext?: AgentRuntimeSessionSpawnContext; }; +function decodeStringList(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + return undefined; + } + return value.map((entry) => entry.trim()).filter(Boolean); +} + +function decodeSessionSpawnContext(value: unknown): AgentRuntimeSessionSpawnContext | undefined { + if (!isRecord(value) || !isRecord(value.inheritedToolPolicy)) { + return undefined; + } + const policy = value.inheritedToolPolicy; + const allow = decodeStringList(policy.allow); + const deny = decodeStringList(policy.deny); + if (policy.version !== 1 || !allow || !deny) { + return undefined; + } + const completionOwnerSessionKey = normalizeOptionalString(value.completionOwnerSessionKey); + if (value.completionOwnerSessionKey !== undefined && !completionOwnerSessionKey) { + return undefined; + } + return { + ...(completionOwnerSessionKey ? { completionOwnerSessionKey } : {}), + inheritedToolPolicy: { version: 1, allow, deny }, + }; +} + async function readSharedAgentRuntimeIdentitySecret(): Promise { return (await loadExecApprovalsAsync()).socket?.token?.trim() || null; } @@ -175,6 +213,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP turnSourceAccountId?: unknown; messageActionContext?: unknown; cronSelfManagementContext?: unknown; + sessionSpawnContext?: unknown; }; if ( raw.kind !== AGENT_RUNTIME_IDENTITY_TOKEN_KIND || @@ -219,6 +258,13 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP if (rawCronSelfManagement !== undefined && !cronSelfManagementContext) { return undefined; } + const sessionSpawnContext = + raw.sessionSpawnContext === undefined + ? undefined + : decodeSessionSpawnContext(raw.sessionSpawnContext); + if (raw.sessionSpawnContext !== undefined && !sessionSpawnContext) { + return undefined; + } return { kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId, @@ -226,6 +272,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP ...(turnSourceAccountId ? { turnSourceAccountId } : {}), ...(messageActionContext ? { messageActionContext } : {}), ...(cronSelfManagementContext ? { cronSelfManagementContext } : {}), + ...(sessionSpawnContext ? { sessionSpawnContext } : {}), }; } catch { return undefined; @@ -239,6 +286,7 @@ export async function mintAgentRuntimeIdentityToken(params: { turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; cronSelfManagementJobId?: string; + sessionSpawnContext?: AgentRuntimeSessionSpawnContext; }): Promise { if ( params.messageActionContext?.sourceReplyFinal === true && @@ -272,6 +320,7 @@ export async function mintAgentRuntimeIdentityToken(params: { ...(turnSourceAccountId ? { turnSourceAccountId } : {}), ...(messageActionContext ? { messageActionContext } : {}), ...(cronSelfManagementContext ? { cronSelfManagementContext } : {}), + ...(params.sessionSpawnContext ? { sessionSpawnContext: params.sessionSpawnContext } : {}), }); const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload); return `${payload}.${signature}`; @@ -307,5 +356,6 @@ export async function verifyAgentRuntimeIdentityToken( ...(payload.cronSelfManagementContext ? { cronSelfManagementContext: payload.cronSelfManagementContext } : {}), + ...(payload.sessionSpawnContext ? { sessionSpawnContext: payload.sessionSpawnContext } : {}), }; } diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index e8c27cb230eb..e617b4b32010 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -4237,6 +4237,26 @@ describe("agent event handler", () => { ); }); + it("includes spawnedBy in chat broadcasts for spawn-owned dashboard sessions", () => { + mockSessionLineage("agent:main:dashboard:visible-child", "agent:main:discord:direct:alice"); + const { broadcast, handler, chatRunState } = createHarness({ + resolveSessionKeyForRun: () => "agent:main:dashboard:visible-child", + }); + registerChatRun( + chatRunState, + "run-dashboard-child", + "agent:main:dashboard:visible-child", + "client-dashboard-child", + ); + + emitAgentEvent(handler, "run-dashboard-child", "assistant", { text: "visible child" }); + + expectPayloadFields(chatBroadcastCalls(broadcast)[0]?.[1], { + sessionKey: "agent:main:dashboard:visible-child", + spawnedBy: "agent:main:discord:direct:alice", + }); + }); + it("skips session row load entirely for session keys that cannot carry lineage", () => { const { broadcast, handler, chatRunState } = createHarness({ resolveSessionKeyForRun: () => "agent:main:main", @@ -4254,10 +4274,9 @@ describe("agent event handler", () => { ); } - // The chat delta path invokes resolveSpawnedBy only. Non-subagent, - // non-acp keys cannot carry spawnedBy (see supportsSpawnLineage in - // sessions-patch.ts), so resolveSpawnedBy must short-circuit without - // ever calling loadGatewaySessionRow on this hot path. + // The chat delta path invokes resolveSpawnedBy only. Main/channel keys + // cannot carry spawn lineage, so resolveSpawnedBy must short-circuit + // without calling loadGatewaySessionRow on this hot path. expect(loadGatewaySessionRow).not.toHaveBeenCalled(); const chatCalls = chatBroadcastCalls(broadcast); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index bc8cd3e29316..80aa9623888d 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -26,6 +26,7 @@ import { import { formatErrorMessage } from "../infra/errors.js"; import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; import { logError } from "../logger.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { isAcpSessionKey, isSubagentSessionKey, @@ -448,9 +449,9 @@ export function createAgentEventHandler({ } }; - // Only subagent/acp keys can carry spawnedBy (mirrors supportsSpawnLineage in - // sessions-patch.ts). Short-circuit everyone else so high-volume chat streams - // do not touch the session store. Results are cached per sessionKey because + // Native, ACP, and spawn-owned dashboard sessions can carry spawnedBy. + // Short-circuit everyone else so high-volume chat streams do not touch the + // session store. Results are cached per sessionKey because // spawnedBy is immutable once set and resolveSpawnedBy sits on the hot event // path (delta, flush, final, agent, seq-gap). const spawnedByCache = new Map(); @@ -458,9 +459,11 @@ export function createAgentEventHandler({ if (spawnedByCache.has(sessionKey)) { return spawnedByCache.get(sessionKey)!; } - // Non-lineage keys return null without polluting the cache; only - // subagent/ACP results (positive or null) are worth memoising. - if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey)) { + // Non-lineage keys return null without polluting the cache; only eligible + // child-session results (positive or null) are worth memoising. + const isDashboardSession = + parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true; + if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey) && !isDashboardSession) { return null; } let result: string | null = null; diff --git a/src/gateway/server-methods/session-creation-provenance.ts b/src/gateway/server-methods/session-creation-provenance.ts index a58066bc5748..05a4477cc160 100644 --- a/src/gateway/server-methods/session-creation-provenance.ts +++ b/src/gateway/server-methods/session-creation-provenance.ts @@ -2,10 +2,19 @@ import type { SessionCreatedActor, SessionCreatedVia, } from "../../config/sessions/session-entry-provenance.js"; +import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js"; export type TrustedSessionCreation = { via: SessionCreatedVia; actor?: SessionCreatedActor; + /** Immutable completion recipient for a spawn-owned visible session. */ + completionOwnerSessionKey?: string; + /** Effective caller tool-policy snapshot for an in-process visible spawn. */ + inheritedToolPolicy?: { + version: 1; + allow: string[]; + deny: string[]; + }; }; /** @@ -14,7 +23,11 @@ export type TrustedSessionCreation = { */ type SessionCreationClient = { authenticatedUserProfile?: { profileId?: string } | null; - internal?: { syntheticClient?: true; sessionCreation?: TrustedSessionCreation }; + internal?: { + syntheticClient?: true; + sessionCreation?: TrustedSessionCreation; + agentRuntimeIdentity?: AgentRuntimeIdentity; + }; }; export function resolveOperatorSessionCreation( @@ -24,6 +37,20 @@ export function resolveOperatorSessionCreation( if (options.allowTrustedHint && client?.internal?.sessionCreation) { return client.internal.sessionCreation; } + const agentRuntimeIdentity = client?.internal?.agentRuntimeIdentity; + if (options.allowTrustedHint && agentRuntimeIdentity?.sessionSpawnContext) { + return { + via: "spawn", + actor: { type: "agent", id: agentRuntimeIdentity.sessionKey }, + ...(agentRuntimeIdentity.sessionSpawnContext.completionOwnerSessionKey + ? { + completionOwnerSessionKey: + agentRuntimeIdentity.sessionSpawnContext.completionOwnerSessionKey, + } + : {}), + inheritedToolPolicy: agentRuntimeIdentity.sessionSpawnContext.inheritedToolPolicy, + }; + } const profileId = client?.authenticatedUserProfile?.profileId; // Actor only when proven: a profile-less wire connection may be an agent-tool // client on a remote topology, so claiming a human actor would misattribute diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index de06f1ce15d3..905aff485beb 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -408,6 +408,23 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { let runMeta: Record | undefined; let messageSeq: number | undefined; const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + const sessionCreation = resolveOperatorSessionCreation(client, { allowTrustedHint: true }); + const spawnActorSessionKey = + sessionCreation.via === "spawn" && sessionCreation.actor?.type === "agent" + ? normalizeOptionalString(sessionCreation.actor.id) + : undefined; + if ( + sessionCreation.inheritedToolPolicy && + spawnActorSessionKey && + normalizeOptionalString(p.parentSessionKey) !== spawnActorSessionKey + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "spawn parent must match the trusted agent caller"), + ); + return; + } const allowExistingModelSelection = authorizeOperatorScopesForRequiredScope( ADMIN_SCOPE, clientScopes, @@ -454,6 +471,15 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { allowExistingModelSelection, parentSessionKey: p.parentSessionKey, spawnDepth: p.spawnDepth, + spawnToolPolicy: + sessionCreation.via === "spawn" && sessionCreation.inheritedToolPolicy + ? { + ...sessionCreation.inheritedToolPolicy, + ...(sessionCreation.completionOwnerSessionKey + ? { completionOwnerSessionKey: sessionCreation.completionOwnerSessionKey } + : {}), + } + : undefined, spawnedCwd: sessionCwd, worktree: sessionWorktree ? { @@ -472,7 +498,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { emitCommandHooks: p.emitCommandHooks, resetMainWhenUnspecified: !hasInitialTurn, commandSource: "webchat", - creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }), + creation: sessionCreation, authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId), loadGatewayModelCatalog: () => context.loadGatewayModelCatalog({ agentId: modelCatalogAgentId }), diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 86c182c4aac8..4c9f9109f22f 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -1566,6 +1566,175 @@ test("sessions.create persists declared spawn lineage for spawn-owned creations" expect(created.payload?.entry?.spawnDepth).toBe(2); }); +test("sessions.create atomically persists trusted visible-spawn tool policy", async () => { + const { storePath } = await createSessionStoreDir(); + const parentSessionKey = "agent:main:main"; + await writeSessionStore({ + entries: { + [parentSessionKey]: sessionStoreEntry("sess-visible-spawn-parent"), + }, + }); + + const created = await directSessionReq<{ + key?: string; + entry?: { + label?: string; + spawnedBy?: string; + completionOwnerSessionKey?: string; + parentSessionKey?: string; + spawnDepth?: number; + inheritedToolPolicyVersion?: number; + inheritedToolAllow?: string[]; + inheritedToolDeny?: string[]; + }; + }>( + "sessions.create", + { + agentId: "main", + label: "Restricted visible child", + parentSessionKey, + spawnDepth: 1, + }, + { + client: { + connect: { scopes: ["operator.write"] }, + internal: { + syntheticClient: true, + sessionCreation: { + via: "spawn", + actor: { type: "agent", id: parentSessionKey }, + completionOwnerSessionKey: "agent:main:discord:direct:alice", + inheritedToolPolicy: { + version: 1, + allow: ["read", "sessions_spawn"], + deny: ["exec"], + }, + }, + }, + } as never, + }, + ); + + expect(created.ok, JSON.stringify(created.error)).toBe(true); + expect(created.payload?.key).toMatch(/^agent:main:dashboard:/); + expect(created.payload?.entry).toMatchObject({ + label: "Restricted visible child", + spawnedBy: parentSessionKey, + completionOwnerSessionKey: "agent:main:discord:direct:alice", + parentSessionKey, + spawnDepth: 1, + inheritedToolPolicyVersion: 1, + inheritedToolAllow: ["read", "sessions_spawn"], + inheritedToolDeny: ["exec"], + }); + const key = requireNonEmptyString(created.payload?.key, "visible child key"); + expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({ + spawnedBy: parentSessionKey, + completionOwnerSessionKey: "agent:main:discord:direct:alice", + inheritedToolPolicyVersion: 1, + inheritedToolAllow: ["read", "sessions_spawn"], + inheritedToolDeny: ["exec"], + }); +}); + +test("sessions.create accepts a signed agent-runtime visible-spawn policy", async () => { + const { storePath } = await createSessionStoreDir(); + const parentSessionKey = "agent:main:main"; + await writeSessionStore({ + entries: { + [parentSessionKey]: sessionStoreEntry("sess-runtime-spawn-parent"), + }, + }); + + const created = await directSessionReq<{ + key?: string; + entry?: { + createdVia?: string; + createdActor?: unknown; + spawnedBy?: string; + completionOwnerSessionKey?: string; + inheritedToolAllow?: string[]; + inheritedToolDeny?: string[]; + }; + }>( + "sessions.create", + { + agentId: "main", + label: "Runtime visible child", + parentSessionKey, + spawnDepth: 1, + }, + { + client: { + connect: { scopes: ["operator.write"] }, + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey: parentSessionKey, + sessionSpawnContext: { + completionOwnerSessionKey: "agent:main:discord:direct:bob", + inheritedToolPolicy: { + version: 1, + allow: ["read", "sessions_spawn"], + deny: ["exec"], + }, + }, + }, + }, + } as never, + }, + ); + + expect(created.ok, JSON.stringify(created.error)).toBe(true); + expect(created.payload?.key).toMatch(/^agent:main:dashboard:/); + expect(created.payload?.entry).toMatchObject({ + createdVia: "spawn", + createdActor: { type: "agent", id: parentSessionKey }, + spawnedBy: parentSessionKey, + completionOwnerSessionKey: "agent:main:discord:direct:bob", + inheritedToolAllow: ["read", "sessions_spawn"], + inheritedToolDeny: ["exec"], + }); + const key = requireNonEmptyString(created.payload?.key, "runtime visible child key"); + expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({ + spawnedBy: parentSessionKey, + completionOwnerSessionKey: "agent:main:discord:direct:bob", + inheritedToolPolicyVersion: 1, + }); +}); + +test("sessions.create rejects a trusted spawn whose parent differs from its agent caller", async () => { + await createSessionStoreDir(); + + const created = await directSessionReq( + "sessions.create", + { + agentId: "main", + parentSessionKey: "agent:main:other", + spawnDepth: 1, + }, + { + client: { + connect: { scopes: ["operator.write"] }, + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey: "agent:main:main", + sessionSpawnContext: { + inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] }, + }, + }, + }, + } as never, + }, + ); + + expect(created.ok).toBe(false); + expect(created.error?.message).toContain("spawn parent must match the trusted agent caller"); +}); + test("sessions.create rejects spawnDepth without parentSessionKey", async () => { await createSessionStoreDir(); diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index 3dec8d72df30..200bab3ecaaa 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -130,6 +130,10 @@ const ownedChildMetadata = { groupChannel: "dev", space: "hq", spawnedBy: "agent:main:main", + completionOwnerSessionKey: "agent:main:discord:direct:alice", + inheritedToolPolicyVersion: 1, + inheritedToolAllow: ["read", "message"], + inheritedToolDeny: ["exec"], spawnedWorkspaceDir: "/tmp/child-workspace", spawnedCwd: "/tmp/task-repo", parentSessionKey: "agent:main:main", diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 4dd05de9a3e1..212081942fcb 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -17,6 +17,10 @@ import { resolveDefaultAgentId, } from "../agents/agent-scope.js"; import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js"; +import { + normalizeInheritedToolAllowlist, + normalizeInheritedToolDenylist, +} from "../agents/inherited-tool-deny.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.types.js"; import { resolveDefaultModelForAgent, @@ -289,6 +293,13 @@ export async function createGatewaySession(params: { * operator sessions and forks stay spawn-capable roots. */ spawnDepth?: number; + /** Trusted effective policy captured by an in-process visible spawn. */ + spawnToolPolicy?: { + version: 1; + completionOwnerSessionKey?: string; + allow: string[]; + deny: string[]; + }; spawnedCwd?: string; /** Managed worktree bound to the new session; persisted alongside spawnedCwd. */ worktree?: { id: string; branch: string; repoRoot: string }; @@ -495,6 +506,12 @@ export async function createGatewaySession(params: { }; } } + if (params.spawnToolPolicy && params.spawnDepth === undefined) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "spawn tool policy requires spawnDepth"), + }; + } let canonicalParentSessionKey: string | undefined; let parentSessionEntry: SessionEntry | undefined; let parentSelectedAgentId: string | undefined; @@ -705,6 +722,17 @@ export async function createGatewaySession(params: { let createdContext: CreatedGatewaySession | undefined; let createdNewEntry = false; + const spawnToolPolicy = + params.spawnToolPolicy && canonicalParentSessionKey + ? { + completionOwnerSessionKey: normalizeOptionalString( + params.spawnToolPolicy.completionOwnerSessionKey, + ), + allow: normalizeInheritedToolAllowlist(params.spawnToolPolicy.allow), + deny: normalizeInheritedToolDenylist(params.spawnToolPolicy.deny), + parentSessionKey: canonicalParentSessionKey, + } + : undefined; const createChildSession = async (): Promise => { let currentParentSessionEntry = parentSessionEntry; if ( @@ -857,6 +885,15 @@ export async function createGatewaySession(params: { ), }; } + if (spawnToolPolicy && existingEntry !== undefined) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "spawn tool policy requires a new session", + ), + }; + } if ( params.visibility && existingEntry === undefined && @@ -1030,6 +1067,21 @@ export async function createGatewaySession(params: { // and plugin sessions) persists as a depth-0 root. Reused entries keep // their stored depth. ...(existingEntry === undefined ? { spawnDepth: params.spawnDepth ?? 0 } : {}), + ...(existingEntry === undefined && spawnToolPolicy + ? { + spawnedBy: spawnToolPolicy.parentSessionKey, + ...(spawnToolPolicy.completionOwnerSessionKey + ? { completionOwnerSessionKey: spawnToolPolicy.completionOwnerSessionKey } + : {}), + inheritedToolPolicyVersion: 1 as const, + ...(spawnToolPolicy.allow.length > 0 + ? { inheritedToolAllow: spawnToolPolicy.allow } + : {}), + ...(spawnToolPolicy.deny.length > 0 + ? { inheritedToolDeny: spawnToolPolicy.deny } + : {}), + } + : {}), ...(existingEntry === undefined && incognito ? { incognito: true as const } : {}), }; sessionEntries[target.canonicalKey] = initializedEntry; diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index cc4fb4574e9f..b2d2f50cd4fe 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1445,6 +1445,10 @@ export async function performGatewaySessionReset(params: { queueCap: currentEntry?.queueCap, queueDrop: currentEntry?.queueDrop, spawnedBy: currentEntry?.spawnedBy, + completionOwnerSessionKey: currentEntry?.completionOwnerSessionKey, + inheritedToolPolicyVersion: currentEntry?.inheritedToolPolicyVersion, + inheritedToolAllow: currentEntry?.inheritedToolAllow, + inheritedToolDeny: currentEntry?.inheritedToolDeny, spawnedWorkspaceDir: currentEntry?.spawnedWorkspaceDir, spawnedCwd: params.clearSpawnedCwd ? undefined diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 6939021c267a..ccfd27e196af 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -2,7 +2,7 @@ "base": "codex-dynamic-tools.telegram-direct.json", "replace": { "sessions_spawn": { - "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work. Run result returns; session output stays thread.", + "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work. Run result returns; session output stays thread.", "inputSchema": { "properties": { "agentId": { @@ -101,7 +101,7 @@ "type": "boolean" }, "visible": { - "description": "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.", + "description": "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.", "type": "boolean" }, "worktree": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index 0bd56396efa6..2e983768dc78 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -138,7 +138,7 @@ "type": "function" }, { - "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.", + "description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.", "inputSchema": { "properties": { "agentId": { @@ -233,7 +233,7 @@ "type": "string" }, "visible": { - "description": "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.", + "description": "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.", "type": "boolean" }, "worktree": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 940eaab1cedc..26dc31feeb19 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 61456, - "roughTokens": 15364 + "chars": 61490, + "roughTokens": 15373 }, "openClawDeveloperInstructions": { "chars": 3811, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 7049 }, "totalWithDynamicToolsJson": { - "chars": 89652, - "roughTokens": 22413 + "chars": 89686, + "roughTokens": 22422 }, "userInputText": { "chars": 1300, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 7ab0c9a97798..3cef65f9c8d5 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 61148, - "roughTokens": 15287 + "chars": 61182, + "roughTokens": 15296 }, "openClawDeveloperInstructions": { "chars": 2702, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6679 }, "totalWithDynamicToolsJson": { - "chars": 87864, - "roughTokens": 21966 + "chars": 87898, + "roughTokens": 21975 }, "userInputText": { "chars": 929, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 61a6c6539e1d..f8c50bc9041d 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 62682, - "roughTokens": 15671 + "chars": 62716, + "roughTokens": 15679 }, "openClawDeveloperInstructions": { "chars": 2702, @@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6783 }, "totalWithDynamicToolsJson": { - "chars": 89814, - "roughTokens": 22454 + "chars": 89848, + "roughTokens": 22462 }, "userInputText": { "chars": 1271,