feat: add visible session spawn parity (#110943)

* feat(agents): add visible spawn parity

* feat(agents): expose spawn tools to messaging profile

* test(agents): messaging profile now includes sessions_spawn
This commit is contained in:
Peter Steinberger
2026-07-18 21:50:10 +01:00
committed by GitHub
parent 09c46fb682
commit 3383e35aa3
8 changed files with 302 additions and 38 deletions
@@ -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);
});
+3
View File
@@ -78,6 +78,9 @@ describe("tool-catalog", () => {
"conversations_send",
"conversations_turn",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"session_status",
"message",
"bundle-mcp",
+3 -3
View File
@@ -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,
},
{
+138 -1
View File
@@ -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 () => {
+65 -14
View File
@@ -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 } : {}),
+9 -1
View File
@@ -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,
+42 -16
View File
@@ -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<ReturnType<typeof managedWorktrees.create>> | 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,
+39 -2
View File
@@ -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-"),