From 3693449d7e06836ec85a03e0bc50e33f9402fdd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 08:40:21 +0000 Subject: [PATCH] feat: sandbox session tool visibility --- CHANGELOG.md | 1 + docs/session-tool.md | 19 +++++ docs/subagents.md | 2 +- src/agents/clawdbot-tools.ts | 13 +++- src/agents/pi-tools.ts | 1 + src/agents/sandbox.ts | 12 +++- src/agents/tools/sessions-history-tool.ts | 58 +++++++++++++++- src/agents/tools/sessions-list-tool.ts | 30 +++++++- src/agents/tools/sessions-send-tool.ts | 54 +++++++++++++++ src/agents/tools/sessions-spawn-tool.ts | 21 ++++++ src/config/sessions.ts | 2 + src/config/types.ts | 56 +++++++++++++++ src/config/zod-schema.ts | 84 +++++++++++++++++++++++ src/gateway/protocol/schema.ts | 2 + src/gateway/server-bridge.ts | 46 +++++++++++++ src/gateway/server-methods/sessions.ts | 50 ++++++++++++++ src/gateway/server.sessions.test.ts | 30 ++++++++ src/gateway/session-utils.ts | 6 ++ 18 files changed, 479 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5797df63..ff9b73b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. +- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`). - Control UI: show a reading indicator bubble while the assistant is responding. - Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). diff --git a/docs/session-tool.md b/docs/session-tool.md index 253e0f7e4..272acac78 100644 --- a/docs/session-tool.md +++ b/docs/session-tool.md @@ -35,6 +35,7 @@ Parameters: Behavior: - `messageLimit > 0` fetches `chat.history` per session and includes the last N messages. - Tool results are filtered out in list output; use `sessions_history` for tool messages. +- When running in a **sandboxed** agent session, session tools default to **spawned-only visibility** (see below). Row shape (JSON): - `key`: session key (string) @@ -131,5 +132,23 @@ Parameters: Behavior: - Starts a new `subagent:` session with `deliver: false`. - Sub-agents default to the full tool surface **minus session tools** (configurable via `agent.subagents.tools`). +- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning). - After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat surface. - Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent. + +## Sandbox Session Visibility + +Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`. + +Config: + +```json5 +{ + agent: { + sandbox: { + // default: "spawned" + sessionToolsVisibility: "spawned" // or "all" + } + } +} +``` diff --git a/docs/subagents.md b/docs/subagents.md index 238fbf8be..0d66c85f4 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -13,6 +13,7 @@ Primary goals: - Parallelize “research / long task / slow tool” work without blocking the main run. - Keep sub-agents isolated by default (session separation + optional sandboxing). - Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default. +- Avoid nested fan-out: sub-agents cannot spawn sub-agents. ## Tool @@ -69,4 +70,3 @@ Sub-agents use a dedicated in-process queue lane: - Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost. - Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve. - diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index c5c35c1d0..b4400eba8 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -17,6 +17,7 @@ export function createClawdbotTools(options?: { browserControlUrl?: string; agentSessionKey?: string; agentSurface?: string; + sandboxed?: boolean; config?: ClawdbotConfig; }): AnyAgentTool[] { const imageTool = createImageTool({ config: options?.config }); @@ -28,15 +29,23 @@ export function createClawdbotTools(options?: { createDiscordTool(), createSlackTool(), createGatewayTool(), - createSessionsListTool(), - createSessionsHistoryTool(), + createSessionsListTool({ + agentSessionKey: options?.agentSessionKey, + sandboxed: options?.sandboxed, + }), + createSessionsHistoryTool({ + agentSessionKey: options?.agentSessionKey, + sandboxed: options?.sandboxed, + }), createSessionsSendTool({ agentSessionKey: options?.agentSessionKey, agentSurface: options?.agentSurface, + sandboxed: options?.sandboxed, }), createSessionsSpawnTool({ agentSessionKey: options?.agentSessionKey, agentSurface: options?.agentSurface, + sandboxed: options?.sandboxed, }), ...(imageTool ? [imageTool] : []), ]; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 4c5fc5f4f..f438002ce 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -556,6 +556,7 @@ export function createClawdbotCodingTools(options?: { browserControlUrl: sandbox?.browser?.controlUrl, agentSessionKey: options?.sessionKey, agentSurface: options?.surface, + sandboxed: !!sandbox, config: options?.config, }), ]; diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 6166f3349..b788e25a5 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -114,7 +114,17 @@ const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-"; const DEFAULT_SANDBOX_WORKDIR = "/workspace"; const DEFAULT_SANDBOX_IDLE_HOURS = 24; const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7; -const DEFAULT_TOOL_ALLOW = ["bash", "process", "read", "write", "edit"]; +const DEFAULT_TOOL_ALLOW = [ + "bash", + "process", + "read", + "write", + "edit", + "sessions_list", + "sessions_history", + "sessions_send", + "sessions_spawn", +]; const DEFAULT_TOOL_DENY = [ "browser", "canvas", diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index d3ddd5534..9ed9e1470 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -17,7 +17,37 @@ const SessionsHistoryToolSchema = Type.Object({ includeTools: Type.Optional(Type.Boolean()), }); -export function createSessionsHistoryTool(): AnyAgentTool { +function resolveSandboxSessionToolsVisibility( + cfg: ReturnType, +) { + return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +async function isSpawnedSessionAllowed(params: { + requesterSessionKey: string; + targetSessionKey: string; +}): Promise { + try { + const list = (await callGateway({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + limit: 500, + spawnedBy: params.requesterSessionKey, + }, + })) as { sessions?: Array> }; + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + return sessions.some((entry) => entry?.key === params.targetSessionKey); + } catch { + return false; + } +} + +export function createSessionsHistoryTool(opts?: { + agentSessionKey?: string; + sandboxed?: boolean; +}): AnyAgentTool { return { label: "Session History", name: "sessions_history", @@ -30,11 +60,37 @@ export function createSessionsHistoryTool(): AnyAgentTool { }); const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); + const visibility = resolveSandboxSessionToolsVisibility(cfg); + const requesterInternalKey = + typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: opts.agentSessionKey, + alias, + mainKey, + }) + : undefined; const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey, }); + const restrictToSpawned = + opts?.sandboxed === true && + visibility === "spawned" && + requesterInternalKey && + !requesterInternalKey.toLowerCase().startsWith("subagent:"); + if (restrictToSpawned) { + const ok = await isSpawnedSessionAllowed({ + requesterSessionKey: requesterInternalKey, + targetSessionKey: resolvedKey, + }); + if (!ok) { + return jsonResult({ + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${sessionKey}`, + }); + } + } const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? Math.max(1, Math.floor(params.limit)) diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 0209813f2..dc2dd14aa 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -44,7 +44,16 @@ const SessionsListToolSchema = Type.Object({ messageLimit: Type.Optional(Type.Integer({ minimum: 0 })), }); -export function createSessionsListTool(): AnyAgentTool { +function resolveSandboxSessionToolsVisibility( + cfg: ReturnType, +) { + return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; +} + +export function createSessionsListTool(opts?: { + agentSessionKey?: string; + sandboxed?: boolean; +}): AnyAgentTool { return { label: "Sessions", name: "sessions_list", @@ -54,6 +63,20 @@ export function createSessionsListTool(): AnyAgentTool { const params = args as Record; const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); + const visibility = resolveSandboxSessionToolsVisibility(cfg); + const requesterInternalKey = + typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: opts.agentSessionKey, + alias, + mainKey, + }) + : undefined; + const restrictToSpawned = + opts?.sandboxed === true && + visibility === "spawned" && + requesterInternalKey && + !requesterInternalKey.toLowerCase().startsWith("subagent:"); const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => value.trim().toLowerCase(), @@ -86,8 +109,9 @@ export function createSessionsListTool(): AnyAgentTool { params: { limit, activeMinutes, - includeGlobal: true, - includeUnknown: true, + includeGlobal: !restrictToSpawned, + includeUnknown: !restrictToSpawned, + spawnedBy: restrictToSpawned ? requesterInternalKey : undefined, }, })) as { path?: string; diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index bcc732486..72183c896 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -33,6 +33,7 @@ const SessionsSendToolSchema = Type.Object({ export function createSessionsSendTool(opts?: { agentSessionKey?: string; agentSurface?: string; + sandboxed?: boolean; }): AnyAgentTool { return { label: "Session Send", @@ -47,11 +48,64 @@ export function createSessionsSendTool(opts?: { const message = readStringParam(params, "message", { required: true }); const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); + const visibility = + cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; + const requesterInternalKey = + typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() + ? resolveInternalSessionKey({ + key: opts.agentSessionKey, + alias, + mainKey, + }) + : undefined; const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey, }); + const restrictToSpawned = + opts?.sandboxed === true && + visibility === "spawned" && + requesterInternalKey && + !requesterInternalKey.toLowerCase().startsWith("subagent:"); + if (restrictToSpawned) { + try { + const list = (await callGateway({ + method: "sessions.list", + params: { + includeGlobal: false, + includeUnknown: false, + limit: 500, + spawnedBy: requesterInternalKey, + }, + })) as { sessions?: Array> }; + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + const ok = sessions.some((entry) => entry?.key === resolvedKey); + if (!ok) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${sessionKey}`, + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + }); + } + } catch { + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${sessionKey}`, + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + }); + } + } const timeoutSeconds = typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 1464974c4..cd7a97f83 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -160,6 +160,7 @@ async function runSubagentAnnounceFlow(params: { export function createSessionsSpawnTool(opts?: { agentSessionKey?: string; agentSurface?: string; + sandboxed?: boolean; }): AnyAgentTool { return { label: "Sessions", @@ -185,6 +186,15 @@ export function createSessionsSpawnTool(opts?: { const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = opts?.agentSessionKey; + if ( + typeof requesterSessionKey === "string" && + requesterSessionKey.trim().toLowerCase().startsWith("subagent:") + ) { + return jsonResult({ + status: "forbidden", + error: "sessions_spawn is not allowed from sub-agent sessions", + }); + } const requesterInternalKey = requesterSessionKey ? resolveInternalSessionKey({ key: requesterSessionKey, @@ -199,6 +209,17 @@ export function createSessionsSpawnTool(opts?: { }); const childSessionKey = `subagent:${crypto.randomUUID()}`; + if (opts?.sandboxed === true) { + try { + await callGateway({ + method: "sessions.patch", + params: { key: childSessionKey, spawnedBy: requesterInternalKey }, + timeoutMs: 10_000, + }); + } catch { + // best-effort; scoping relies on this metadata but spawning still works without it + } + } const childSystemPrompt = buildSubagentSystemPrompt({ requesterSessionKey, requesterSurface: opts?.agentSurface, diff --git a/src/config/sessions.ts b/src/config/sessions.ts index ff440eab8..a92219c40 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -26,6 +26,8 @@ export type SessionChatType = "direct" | "group" | "room"; export type SessionEntry = { sessionId: string; updatedAt: number; + /** Parent session key that spawned this session (used for sandbox session-tool scoping). */ + spawnedBy?: string; systemSent?: boolean; abortedLastRun?: boolean; chatType?: SessionChatType; diff --git a/src/config/types.ts b/src/config/types.ts index 9b03b78ef..7d7bb92b5 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -77,6 +77,8 @@ export type AgentElevatedAllowFromConfig = { }; export type WhatsAppConfig = { + /** Optional per-account WhatsApp configuration (multi-account). */ + accounts?: Record; /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; /** Optional allowlist for WhatsApp group senders (E.164). */ @@ -98,6 +100,23 @@ export type WhatsAppConfig = { >; }; +export type WhatsAppAccountConfig = { + /** If false, do not start this WhatsApp account provider. Default: true. */ + enabled?: boolean; + /** Override auth directory (Baileys multi-file auth state). */ + authDir?: string; + allowFrom?: string[]; + groupAllowFrom?: string[]; + groupPolicy?: GroupPolicy; + textChunkLimit?: number; + groups?: Record< + string, + { + requireMention?: boolean; + } + >; +}; + export type BrowserProfileConfig = { /** CDP port for this profile. Allocated once at creation, persisted permanently. */ cdpPort?: number; @@ -488,6 +507,37 @@ export type RoutingConfig = { timeoutSeconds?: number; }; groupChat?: GroupChatConfig; + /** Default agent id when no binding matches. Default: "main". */ + defaultAgentId?: string; + agentToAgent?: { + /** Enable agent-to-agent messaging tools. Default: false. */ + enabled?: boolean; + /** Allowlist of agent ids or patterns (implementation-defined). */ + allow?: string[]; + }; + agents?: Record< + string, + { + workspace?: string; + agentDir?: string; + model?: string; + sandbox?: { + mode?: "off" | "non-main" | "all"; + perSession?: boolean; + workspaceRoot?: string; + }; + } + >; + bindings?: Array<{ + agentId: string; + match: { + surface: string; + surfaceAccountId?: string; + peer?: { kind: "dm" | "group" | "channel"; id: string }; + guildId?: string; + teamId?: string; + }; + }>; queue?: { mode?: QueueMode; bySurface?: QueueModeBySurface; @@ -836,6 +886,12 @@ export type ClawdbotConfig = { sandbox?: { /** Enable sandboxing for sessions. */ mode?: "off" | "non-main" | "all"; + /** + * Session tools visibility for sandboxed sessions. + * - "spawned": only allow session tools to target sessions spawned from this session (default) + * - "all": allow session tools to target any session + */ + sessionToolsVisibility?: "spawned" | "all"; /** Use one container per session (recommended for hard isolation). */ perSession?: boolean; /** Root directory for sandbox workspaces. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 15abff42e..6039afb70 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -201,6 +201,61 @@ const RoutingSchema = z .object({ groupChat: GroupChatSchema, transcribeAudio: TranscribeAudioSchema, + defaultAgentId: z.string().optional(), + agentToAgent: z + .object({ + enabled: z.boolean().optional(), + allow: z.array(z.string()).optional(), + }) + .optional(), + agents: z + .record( + z.string(), + z + .object({ + workspace: z.string().optional(), + agentDir: z.string().optional(), + model: z.string().optional(), + sandbox: z + .object({ + mode: z + .union([ + z.literal("off"), + z.literal("non-main"), + z.literal("all"), + ]) + .optional(), + perSession: z.boolean().optional(), + workspaceRoot: z.string().optional(), + }) + .optional(), + }) + .optional(), + ) + .optional(), + bindings: z + .array( + z.object({ + agentId: z.string(), + match: z.object({ + surface: z.string(), + surfaceAccountId: z.string().optional(), + peer: z + .object({ + kind: z.union([ + z.literal("dm"), + z.literal("group"), + z.literal("channel"), + ]), + id: z.string(), + }) + .optional(), + guildId: z.string().optional(), + teamId: z.string().optional(), + }), + }), + ) + .optional(), queue: z .object({ mode: QueueModeSchema.optional(), @@ -504,6 +559,9 @@ export const ClawdbotSchema = z.object({ mode: z .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) .optional(), + sessionToolsVisibility: z + .union([z.literal("spawned"), z.literal("all")]) + .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: z @@ -608,6 +666,32 @@ export const ClawdbotSchema = z.object({ .optional(), whatsapp: z .object({ + accounts: z + .record( + z.string(), + z + .object({ + enabled: z.boolean().optional(), + /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ + authDir: z.string().optional(), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), + textChunkLimit: z.number().int().positive().optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), + }) + .optional(), + ) + .optional(), allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index c93645366..eec93fe79 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -311,6 +311,7 @@ export const SessionsListParamsSchema = Type.Object( activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), includeGlobal: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()), + spawnedBy: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -322,6 +323,7 @@ export const SessionsPatchParamsSchema = Type.Object( verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), sendPolicy: Type.Optional( Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), ), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 610c74946..a0be80f91 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -349,6 +349,52 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } : { sessionId: randomUUID(), updatedAt: now }; + if ("spawnedBy" in p) { + const raw = p.spawnedBy; + if (raw === null) { + if (existing?.spawnedBy) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "spawnedBy cannot be cleared once set", + }, + }; + } + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid spawnedBy: empty", + }, + }; + } + if (!key.startsWith("subagent:")) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: + "spawnedBy is only supported for subagent:* sessions", + }, + }; + } + if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "spawnedBy cannot be changed once set", + }, + }; + } + next.spawnedBy = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index a0ec54352..4c45d22ea 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -110,6 +110,56 @@ export const sessionsHandlers: GatewayRequestHandlers = { } : { sessionId: randomUUID(), updatedAt: now }; + if ("spawnedBy" in p) { + const raw = p.spawnedBy; + if (raw === null) { + if (existing?.spawnedBy) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "spawnedBy cannot be cleared once set", + ), + ); + return; + } + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid spawnedBy: empty"), + ); + return; + } + if (!key.startsWith("subagent:")) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "spawnedBy is only supported for subagent:* sessions", + ), + ); + return; + } + if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "spawnedBy cannot be changed once set", + ), + ); + return; + } + next.spawnedBy = trimmed; + } + } + if ("thinkingLevel" in p) { const raw = p.thinkingLevel; if (raw === null) { diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 1c8ef9176..590e1c774 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -53,6 +53,11 @@ describe("gateway server sessions", () => { updatedAt: now - 120_000, totalTokens: 50, }, + "subagent:one": { + sessionId: "sess-subagent", + updatedAt: now - 120_000, + spawnedBy: "main", + }, global: { sessionId: "sess-global", updatedAt: now - 10_000, @@ -148,6 +153,31 @@ describe("gateway server sessions", () => { expect(main2?.verboseLevel).toBeUndefined(); expect(main2?.sendPolicy).toBe("deny"); + const spawnedOnly = await rpcReq<{ + sessions: Array<{ key: string }>; + }>(ws, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + spawnedBy: "main", + }); + expect(spawnedOnly.ok).toBe(true); + expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([ + "subagent:one", + ]); + + const spawnedPatched = await rpcReq<{ + ok: true; + entry: { spawnedBy?: string }; + }>(ws, "sessions.patch", { key: "subagent:two", spawnedBy: "main" }); + expect(spawnedPatched.ok).toBe(true); + expect(spawnedPatched.payload?.entry.spawnedBy).toBe("main"); + + const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", { + key: "main", + spawnedBy: "main", + }); + expect(spawnedPatchedInvalidKey.ok).toBe(false); + piSdkMock.enabled = true; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const modelPatched = await rpcReq<{ diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 3deba73d9..46bb66fce 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -227,6 +227,7 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; + const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const activeMinutes = typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes) @@ -239,6 +240,11 @@ export function listSessionsFromStore(params: { if (!includeUnknown && key === "unknown") return false; return true; }) + .filter(([key, entry]) => { + if (!spawnedBy) return true; + if (key === "unknown" || key === "global") return false; + return entry?.spawnedBy === spawnedBy; + }) .map(([key, entry]) => { const updatedAt = entry?.updatedAt ?? null; const input = entry?.inputTokens ?? 0;