mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
09c46fb682
commit
3383e35aa3
@@ -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);
|
||||
});
|
||||
|
||||
@@ -78,6 +78,9 @@ describe("tool-catalog", () => {
|
||||
"conversations_send",
|
||||
"conversations_turn",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"sessions_yield",
|
||||
"subagents",
|
||||
"session_status",
|
||||
"message",
|
||||
"bundle-mcp",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-"),
|
||||
|
||||
Reference in New Issue
Block a user