diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index f6a823bc987f..70e0da949693 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -1494,7 +1494,9 @@ describe("createOpenClawCodingTools", () => { const names = new Set(tools.map((tool) => tool.name)); expect(names.has("message")).toBe(true); expect(names.has("sessions_send")).toBe(true); - expect(names.has("sessions_spawn")).toBe(false); + // Messaging agents can spawn (and manage) sub-sessions since the + // visible-spawn parity change; execution tools stay coding-only. + expect(names.has("sessions_spawn")).toBe(true); expect(names.has("exec")).toBe(false); expect(names.has("browser")).toBe(false); }); diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 00893d5649ab..ef726885ae74 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -78,6 +78,9 @@ describe("tool-catalog", () => { "conversations_send", "conversations_turn", "sessions_send", + "sessions_spawn", + "sessions_yield", + "subagents", "session_status", "message", "bundle-mcp", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 27d52a60df0a..afd55a0fa41a 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -220,7 +220,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ label: "sessions_spawn", description: SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY, sectionId: "sessions", - profiles: ["coding"], + profiles: ["coding", "messaging"], includeInOpenClawGroup: true, }, { @@ -228,7 +228,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ label: "sessions_yield", description: "End turn to receive sub-agent results", sectionId: "sessions", - profiles: ["coding"], + profiles: ["coding", "messaging"], includeInOpenClawGroup: true, }, { @@ -236,7 +236,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ label: "subagents", description: "Background work: subagents, media gen, cron runs. list/cancel.", sectionId: "sessions", - profiles: ["coding"], + profiles: ["coding", "messaging"], includeInOpenClawGroup: true, }, { diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 59bb5a8850d9..ee488d891913 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -306,6 +306,9 @@ describe("sessions_spawn tool", () => { const result = await tool.execute("visible", { task: "inspect issue", label: "Issue review", + model: "anthropic/claude-sonnet-4-6", + cwd: dir, + context: "fork", visible: true, worktree: true, worktreeName: "issue-review", @@ -322,9 +325,11 @@ describe("sessions_spawn tool", () => { expect(callGateway).toHaveBeenCalledWith("sessions.create", { agentId: "main", label: "Issue review", - model: "openai/gpt-5.4", + model: "anthropic/claude-sonnet-4-6", task: "inspect issue", parentSessionKey: "agent:main:main", + fork: true, + cwd: dir, worktree: true, worktreeName: "issue-review", worktreeBaseRef: "main", @@ -394,6 +399,138 @@ describe("sessions_spawn tool", () => { parentSessionKey: "agent:main:main", }), ); + expect(mockCallArg(callGateway, 0, 1, "sessions.create")).not.toHaveProperty("fork"); + }); + + it("rejects cross-agent visible transcript forks", async () => { + const callGateway = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { + agents: { + defaults: { subagents: { allowAgents: ["reviewer"] } }, + list: [{ id: "main" }, { id: "reviewer" }], + }, + }, + callGateway, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-cross-agent-fork", { + task: "review patch", + agentId: "reviewer", + context: "fork", + visible: true, + }); + + expect(result.details).toMatchObject({ + status: "error", + error: + 'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.', + }); + expect(callGateway).not.toHaveBeenCalled(); + }); + + it("rejects cwd escape for sandboxed visible sessions", async () => { + await withTempDir({ prefix: "openclaw-visible-sandbox-cwd-" }, async (dir) => { + const callGateway = vi.fn(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { + agents: { + defaults: { sandbox: { mode: "all" } }, + list: [{ id: "main", workspace: path.join(dir, "workspace") }], + }, + }, + callGateway, + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-sandbox-cwd", { + task: "inspect", + cwd: path.join(dir, "outside"), + visible: true, + }); + + expect(result.details).toMatchObject({ + status: "forbidden", + error: + "cwd override is not supported outside the target agent workspace for sandboxed visible session runs", + }); + expect(callGateway).not.toHaveBeenCalled(); + }); + }); + + it("allows cwd within a sandboxed visible session workspace", async () => { + await withTempDir({ prefix: "openclaw-visible-sandbox-cwd-" }, async (dir) => { + const workspace = path.join(dir, "workspace"); + const cwd = path.join(workspace, "packages", "app"); + const callGateway = vi.fn(async () => ({ + key: "agent:main:dashboard:child", + runStarted: true, + runId: "run-visible", + })); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + config: { + agents: { + defaults: { sandbox: { mode: "all" } }, + list: [{ id: "main", workspace }], + }, + }, + callGateway: callGateway as never, + registerRun: vi.fn(), + countActiveRuns: () => 0, + }); + + const result = await tool.execute("visible-sandbox-cwd", { + task: "inspect", + cwd, + visible: true, + }); + + expect(result.details).toMatchObject({ status: "accepted" }); + expect(callGateway).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ cwd })); + }); + }); + + it.each([ + [ + "thinking", + { thinking: "high" }, + "thinking unavailable with visible=true: thinking overrides are not wired to the sessions.create path", + ], + [ + "thread", + { thread: true }, + "thread unavailable with visible=true: visible sessions route to the dashboard, not a channel thread", + ], + [ + "mode", + { mode: "session" }, + "mode unavailable with visible=true: visible sessions are persistent dashboard sessions", + ], + [ + "lightContext", + { lightContext: true }, + "lightContext unavailable with visible=true: bootstrap staging is not wired to the sessions.create path", + ], + [ + "attachments", + { attachments: [{ name: "note.txt", content: "hello" }] }, + "attachments unavailable with visible=true: attachment staging is not wired to the sessions.create path", + ], + [ + "attachAs", + { attachAs: { mountPath: "inputs" } }, + "attachAs unavailable with visible=true: attachment staging is not wired to the sessions.create path", + ], + ] as const)("rejects visible %s overrides with a reason", async (_name, override, message) => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" }); + + await expect( + tool.execute("visible-unsupported", { task: "inspect", visible: true, ...override }), + ).rejects.toThrow(message); }); it("denies visible sessions when tool restrictions cannot carry forward", async () => { diff --git a/src/agents/tools/sessions-spawn-visible.ts b/src/agents/tools/sessions-spawn-visible.ts index b555a8da2ee3..05a97c2c57d9 100644 --- a/src/agents/tools/sessions-spawn-visible.ts +++ b/src/agents/tools/sessions-spawn-visible.ts @@ -6,16 +6,19 @@ import { import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; +import { isPathInside } from "../../infra/path-guards.js"; import { isValidAgentId, normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; +import { resolveUserPath } from "../../utils.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { listAgentIds, resolveAgentConfig } from "../agent-scope.js"; import { resolveSubagentSpawnModelSelection } from "../model-selection.js"; import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js"; +import { resolveSpawnedWorkspaceInheritance } from "../spawned-context.js"; import { getSubagentDepthFromSessionStore } from "../subagent-depth.js"; import { countActiveRunsForSession, registerSubagentRun } from "../subagent-registry.js"; import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js"; @@ -123,20 +126,42 @@ export async function maybeSpawnVisibleSession(params: { if (params.runtime !== "subagent") { throw new ToolInputError('visible=true supports runtime="subagent" only'); } + const modelOverride = normalizeToolModelOverride(readStringParam(params.raw, "model")); + const requestedCwd = readStringParam(params.raw, "cwd"); + const spawnedCwd = requestedCwd ? resolveUserPath(requestedCwd) : undefined; const unsupported = [ - ["model", normalizeToolModelOverride(readStringParam(params.raw, "model"))], - ["thinking", readStringParam(params.raw, "thinking")], - ["cwd", readStringParam(params.raw, "cwd")], - ["thread", params.raw.thread === true ? true : undefined], - ["mode", params.raw.mode], - ["context", params.raw.context], - ["lightContext", params.raw.lightContext === true ? true : undefined], - ["attachments", Array.isArray(params.raw.attachments) ? params.raw.attachments : undefined], - ["attachAs", params.raw.attachAs], + [ + "thinking", + readStringParam(params.raw, "thinking"), + "thinking overrides are not wired to the sessions.create path", + ], + [ + "thread", + params.raw.thread === true ? true : undefined, + "visible sessions route to the dashboard, not a channel thread", + ], + ["mode", params.raw.mode, "visible sessions are persistent dashboard sessions"], + [ + "lightContext", + params.raw.lightContext === true ? true : undefined, + "bootstrap staging is not wired to the sessions.create path", + ], + [ + "attachments", + Array.isArray(params.raw.attachments) ? params.raw.attachments : undefined, + "attachment staging is not wired to the sessions.create path", + ], + [ + "attachAs", + params.raw.attachAs, + "attachment staging is not wired to the sessions.create path", + ], ] as const; const unsupportedEntry = unsupported.find(([, value]) => value !== undefined); if (unsupportedEntry) { - throw new ToolInputError(`${unsupportedEntry[0]} unavailable with visible=true`); + throw new ToolInputError( + `${unsupportedEntry[0]} unavailable with visible=true: ${unsupportedEntry[2]}`, + ); } const cfg = params.options?.config ?? getRuntimeConfig(); @@ -185,6 +210,13 @@ export async function maybeSpawnVisibleSession(params: { const targetAgentId = params.requestedAgentId ? normalizeAgentId(params.requestedAgentId) : requesterAgentId; + if (params.raw.context === "fork" && targetAgentId !== requesterAgentId) { + return { + status: "error", + error: + 'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.', + }; + } const targetPolicy = resolveSubagentTargetPolicy({ requesterAgentId, targetAgentId, @@ -197,10 +229,8 @@ export async function maybeSpawnVisibleSession(params: { if (!targetPolicy.ok) { return { status: "forbidden", error: targetPolicy.error }; } - const resolvedModel = resolveSubagentSpawnModelSelection({ - cfg, - agentId: targetAgentId, - }); + const resolvedModel = + modelOverride ?? resolveSubagentSpawnModelSelection({ cfg, agentId: targetAgentId }); const runTimeoutSeconds = resolveConfiguredSubagentRunTimeoutSeconds({ cfg }); const requesterRuntime = resolveSandboxRuntimeStatus({ cfg, sessionKey: requesterKey }); const childRuntime = resolveSandboxRuntimeStatus({ @@ -216,6 +246,25 @@ export async function maybeSpawnVisibleSession(params: { : 'sessions_spawn sandbox="require" needs sandboxed target.', }; } + const spawnedWorkspaceDir = resolveSpawnedWorkspaceInheritance({ + config: cfg, + targetAgentId, + }); + const spawnedWorkspaceCwd = spawnedWorkspaceDir + ? resolveUserPath(spawnedWorkspaceDir) + : undefined; + // Sandbox mounts only the target workspace; cwd must stay within that boundary. + if ( + childRuntime.sandboxed && + spawnedCwd && + (!spawnedWorkspaceCwd || !isPathInside(spawnedWorkspaceCwd, spawnedCwd)) + ) { + return { + status: "forbidden", + error: + "cwd override is not supported outside the target agent workspace for sandboxed visible session runs", + }; + } const reservation = reserveVisibleChildSlot({ controllerSessionKey: requesterKey, @@ -241,6 +290,8 @@ export async function maybeSpawnVisibleSession(params: { model: resolvedModel, task: params.task, parentSessionKey: requesterKey, + ...(params.raw.context === "fork" ? { fork: true } : {}), + ...(spawnedCwd ? { cwd: spawnedCwd } : {}), ...(worktree ? { worktree: true } : {}), ...(worktreeName ? { worktreeName } : {}), ...(worktreeBaseRef ? { worktreeBaseRef } : {}), diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index b3cfd9b203af..53b5e18a32c9 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -230,10 +230,18 @@ describe("method scope resolution", () => { expect(isGatewayMethodClassified("sessions.patch")).toBe(true); }); - it("requires admin only when sessions.create targets an explicit cwd", () => { + it("requires admin whenever sessions.create targets an explicit cwd", () => { expect( resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { worktree: true }), ).toEqual(["operator.write"]); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { cwd: "/other/repo" }), + ).toEqual(["operator.admin"]); + expect( + authorizeOperatorScopesForMethod("sessions.create", ["operator.write"], { + cwd: "/other/repo", + }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); expect( resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { worktree: true, diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 2337c1d7acde..1cd29a4de8db 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -10,11 +10,14 @@ import { validateSessionsCreateParams, } from "../../../packages/gateway-protocol/src/index.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import { insideGitCheckout } from "../../agents/worktrees/git.js"; import { managedWorktrees } from "../../agents/worktrees/service.js"; import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { isPathInside } from "../../infra/path-guards.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveUserPath } from "../../utils.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; import { buildDashboardSessionKey, @@ -108,21 +111,13 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { } = initialTurn; const requestedCwd = normalizeOptionalString(p.cwd); const requestedExecNode = normalizeOptionalString(p.execNode); - if (requestedCwd && p.worktree !== true && !requestedExecNode) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "sessions.create cwd requires worktree=true or execNode", - ), - ); - return; - } + // Agent tools expand `~` before RPC; the Gateway contract stays absolute-only. + // Remote nodes may use Windows paths; local cwd must match the Gateway host. const cwdIsAbsolute = !requestedCwd || - path.isAbsolute(requestedCwd) || - Boolean(requestedExecNode && path.win32.isAbsolute(requestedCwd)); + (requestedExecNode + ? path.isAbsolute(requestedCwd) || path.win32.isAbsolute(requestedCwd) + : path.isAbsolute(requestedCwd)); if (!cwdIsAbsolute) { respond( false, @@ -156,9 +151,40 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { let sessionAgentId = catalogAgentId ?? p.agentId; let sessionWorktree: Awaited> | undefined; const sessionExecCwd = requestedExecNode ? requestedCwd : undefined; - let sessionCwd: string | undefined; + let sessionCwd = requestedExecNode ? undefined : requestedCwd; let sessionSourceRoot: string | undefined; let provisionedSessionWorktree = false; + if (requestedCwd && !requestedExecNode && p.worktree !== true) { + const targetAgentId = normalizeAgentId( + sessionAgentId ?? + parseAgentSessionKey(sessionKey ?? "")?.agentId ?? + resolveDefaultAgentId(cfg), + ); + const targetSessionKey = sessionKey ?? `agent:${targetAgentId}:dashboard:pending`; + const targetRuntime = resolveSandboxRuntimeStatus({ + cfg, + agentId: targetAgentId, + sessionKey: targetSessionKey, + }); + // Sandboxed dashboard sessions mount only their configured agent workspace. + if ( + targetRuntime.sandboxed && + !isPathInside( + resolveUserPath(resolveAgentWorkspaceDir(cfg, targetAgentId)), + resolveUserPath(requestedCwd), + ) + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.create cwd is outside the sandboxed agent workspace", + ), + ); + return; + } + } if (p.worktree === true) { // The normal path stays at operator.write and checks out the configured agent workspace. // An explicit cwd can target another host checkout, so method-scopes requires admin. @@ -314,8 +340,8 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { execNode: requestedExecNode, execCwd: sessionExecCwd, clearExecBinding: !requestedExecNode, - // A plain New Chat that resets an existing session must not inherit its prior worktree cwd. - clearSpawnedCwd: p.worktree !== true, + // A plain New Chat with no cwd must not inherit the prior session cwd. + clearSpawnedCwd: !sessionCwd, fork: p.fork, succeedsParent: p.succeedsParent, emitCommandHooks: p.emitCommandHooks, diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 53c98fa44b37..28d7eadf4d85 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -413,16 +413,53 @@ test("sessions.create provisions a worktree from an admin-selected cwd", async ( } }); -test("sessions.create rejects cwd without a managed worktree", async () => { +test("sessions.create persists a Gateway cwd without a managed worktree", async () => { const created = await directSessionReq("sessions.create", { cwd: "/tmp/repo" }); + expect(created.ok).toBe(true); + expect((created.payload as { entry?: { spawnedCwd?: string } })?.entry?.spawnedCwd).toBe( + "/tmp/repo", + ); +}); + +test("sessions.create keeps its cwd contract absolute-only", async () => { + const created = await directSessionReq("sessions.create", { cwd: "~/repo" }); + expect(created.ok).toBe(false); expect(created.error).toMatchObject({ code: "INVALID_REQUEST", - message: "sessions.create cwd requires worktree=true or execNode", + message: "sessions.create cwd must be absolute", }); }); +test("sessions.create rejects cwd outside a sandboxed agent workspace", async () => { + testState.agentConfig = { workspace: "/tmp/safe-workspace", sandbox: { mode: "all" } }; + try { + const created = await directSessionReq("sessions.create", { cwd: "/tmp/outside" }); + + expect(created.ok).toBe(false); + expect(created.error).toMatchObject({ + code: "INVALID_REQUEST", + message: "sessions.create cwd is outside the sandboxed agent workspace", + }); + } finally { + testState.agentConfig = undefined; + } +}); + +test("sessions.create allows cwd within a sandboxed agent workspace", async () => { + testState.agentConfig = { workspace: "/tmp/safe-workspace", sandbox: { mode: "all" } }; + try { + const cwd = "/tmp/safe-workspace/packages/app"; + const created = await directSessionReq("sessions.create", { cwd }); + + expect(created.ok).toBe(true); + expect((created.payload as { entry?: { spawnedCwd?: string } })?.entry?.spawnedCwd).toBe(cwd); + } finally { + testState.agentConfig = undefined; + } +}); + test("sessions.create skips the worktree setup script for non-admin callers", async () => { const root = await fs.mkdtemp( path.join(await fs.realpath(os.tmpdir()), "openclaw-worktree-setup-scope-"),