diff --git a/CHANGELOG.md b/CHANGELOG.md index e99e90473..b0aa49d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson. - Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson. - Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway. +- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state. ## 2026.1.10 diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts new file mode 100644 index 000000000..79d8003eb --- /dev/null +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; + +describe("resolveSandboxContext", () => { + it("does not sandbox the agent main session in non-main mode", async () => { + vi.resetModules(); + + const spawn = vi.fn(() => { + throw new Error("spawn should not be called"); + }); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, spawn }; + }); + + const { resolveSandboxContext } = await import("./sandbox.js"); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { + sandbox: { mode: "non-main", scope: "session" }, + }, + list: [{ id: "main" }], + }, + }; + + const result = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/clawdbot-test", + }); + + expect(result).toBeNull(); + expect(spawn).not.toHaveBeenCalled(); + + vi.doUnmock("node:child_process"); + }, 15_000); + + it("does not create a sandbox workspace for the agent main session in non-main mode", async () => { + vi.resetModules(); + + const spawn = vi.fn(() => { + throw new Error("spawn should not be called"); + }); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, spawn }; + }); + + const { ensureSandboxWorkspaceForSession } = await import("./sandbox.js"); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { + sandbox: { mode: "non-main", scope: "session" }, + }, + list: [{ id: "main" }], + }, + }; + + const result = await ensureSandboxWorkspaceForSession({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: "/tmp/clawdbot-test", + }); + + expect(result).toBeNull(); + expect(spawn).not.toHaveBeenCalled(); + + vi.doUnmock("node:child_process"); + }, 15_000); +}); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 94b90c9e6..94a2c916a 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -546,11 +546,22 @@ export function resolveSandboxConfigForAgent( function shouldSandboxSession( cfg: SandboxConfig, sessionKey: string, - mainKey: string, + mainSessionKey: string, ) { if (cfg.mode === "off") return false; if (cfg.mode === "all") return true; - return sessionKey.trim() !== mainKey.trim(); + return sessionKey.trim() !== mainSessionKey.trim(); +} + +function resolveMainSessionKeyForSandbox(params: { + cfg?: ClawdbotConfig; + agentId: string; +}): string { + if (params.cfg?.session?.scope === "global") return "global"; + return buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: normalizeMainKey(params.cfg?.session?.mainKey), + }); } export function resolveSandboxRuntimeStatus(params: { @@ -571,10 +582,7 @@ export function resolveSandboxRuntimeStatus(params: { }); const cfg = params.cfg; const sandboxCfg = resolveSandboxConfigForAgent(cfg, agentId); - const mainSessionKey = buildAgentMainSessionKey({ - agentId, - mainKey: normalizeMainKey(cfg?.session?.mainKey), - }); + const mainSessionKey = resolveMainSessionKeyForSandbox({ cfg, agentId }); const sandboxed = sessionKey ? shouldSandboxSession(sandboxCfg, sessionKey, mainSessionKey) : false; @@ -1293,8 +1301,11 @@ export async function resolveSandboxContext(params: { if (!rawSessionKey) return null; const agentId = resolveAgentIdFromSessionKey(rawSessionKey); const cfg = resolveSandboxConfigForAgent(params.config, agentId); - const mainKey = normalizeMainKey(params.config?.session?.mainKey); - if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; + const mainSessionKey = resolveMainSessionKeyForSandbox({ + cfg: params.config, + agentId, + }); + if (!shouldSandboxSession(cfg, rawSessionKey, mainSessionKey)) return null; await maybePruneSandboxes(cfg); @@ -1373,8 +1384,11 @@ export async function ensureSandboxWorkspaceForSession(params: { if (!rawSessionKey) return null; const agentId = resolveAgentIdFromSessionKey(rawSessionKey); const cfg = resolveSandboxConfigForAgent(params.config, agentId); - const mainKey = normalizeMainKey(params.config?.session?.mainKey); - if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; + const mainSessionKey = resolveMainSessionKeyForSandbox({ + cfg: params.config, + agentId, + }); + if (!shouldSandboxSession(cfg, rawSessionKey, mainSessionKey)) return null; const agentWorkspaceDir = resolveUserPath( params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR, diff --git a/src/auto-reply/reply.queue.test.ts b/src/auto-reply/reply.queue.test.ts index 5510b12e1..b5a9bba37 100644 --- a/src/auto-reply/reply.queue.test.ts +++ b/src/auto-reply/reply.queue.test.ts @@ -101,7 +101,7 @@ describe("queue followups", () => { const secondText = Array.isArray(second) ? second[0]?.text : second?.text; expect(secondText).toBe("main"); - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(500); await Promise.resolve(); expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 2e7113579..5d962a8c5 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -1361,86 +1361,90 @@ describe("trigger handling", () => { }); }); - it("stages inbound media into the sandbox workspace", async () => { - await withTempHome(async (home) => { - const inboundDir = join(home, ".clawdbot", "media", "inbound"); - await fs.mkdir(inboundDir, { recursive: true }); - const mediaPath = join(inboundDir, "photo.jpg"); - await fs.writeFile(mediaPath, "test"); + it( + "stages inbound media into the sandbox workspace", + { timeout: 15_000 }, + async () => { + await withTempHome(async (home) => { + const inboundDir = join(home, ".clawdbot", "media", "inbound"); + await fs.mkdir(inboundDir, { recursive: true }); + const mediaPath = join(inboundDir, "photo.jpg"); + await fs.writeFile(mediaPath, "test"); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), - sandbox: { - mode: "non-main" as const, - workspaceRoot: join(home, "sandboxes"), + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + sandbox: { + mode: "non-main" as const, + workspaceRoot: join(home, "sandboxes"), + }, }, }, - }, - whatsapp: { - allowFrom: ["*"], - }, - session: { - store: join(home, "sessions.json"), - }, - }; + whatsapp: { + allowFrom: ["*"], + }, + session: { + store: join(home, "sessions.json"), + }, + }; - const ctx = { - Body: "hi", - From: "group:whatsapp:demo", - To: "+2000", - ChatType: "group" as const, - Provider: "whatsapp" as const, - MediaPath: mediaPath, - MediaType: "image/jpeg", - MediaUrl: mediaPath, - }; + const ctx = { + Body: "hi", + From: "group:whatsapp:demo", + To: "+2000", + ChatType: "group" as const, + Provider: "whatsapp" as const, + MediaPath: mediaPath, + MediaType: "image/jpeg", + MediaUrl: mediaPath, + }; - const res = await getReplyFromConfig(ctx, {}, cfg); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("ok"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const res = await getReplyFromConfig(ctx, {}, cfg); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const prompt = - vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; - const stagedPath = `media/inbound/${basename(mediaPath)}`; - expect(prompt).toContain(stagedPath); - expect(prompt).not.toContain(mediaPath); + const prompt = + vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + const stagedPath = `media/inbound/${basename(mediaPath)}`; + expect(prompt).toContain(stagedPath); + expect(prompt).not.toContain(mediaPath); - const sessionKey = resolveSessionKey( - cfg.session?.scope ?? "per-sender", - ctx, - cfg.session?.mainKey, - ); - const agentId = resolveAgentIdFromSessionKey(sessionKey); - const sandbox = await ensureSandboxWorkspaceForSession({ - config: cfg, - sessionKey, - workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), + const sessionKey = resolveSessionKey( + cfg.session?.scope ?? "per-sender", + ctx, + cfg.session?.mainKey, + ); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const sandbox = await ensureSandboxWorkspaceForSession({ + config: cfg, + sessionKey, + workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), + }); + expect(sandbox).not.toBeNull(); + if (!sandbox) { + throw new Error("Expected sandbox to be set"); + } + const stagedFullPath = join( + sandbox.workspaceDir, + "media", + "inbound", + basename(mediaPath), + ); + await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy(); }); - expect(sandbox).not.toBeNull(); - if (!sandbox) { - throw new Error("Expected sandbox to be set"); - } - const stagedFullPath = join( - sandbox.workspaceDir, - "media", - "inbound", - basename(mediaPath), - ); - await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy(); - }); - }); + }, + ); }); describe("group intro prompts", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index ae824ac7b..fd0143272 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -8,6 +8,7 @@ import { } from "../agents/defaults.js"; import { resolveModelAuthMode } from "../agents/model-auth.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js"; import { derivePromptTokens, normalizeUsage, @@ -248,14 +249,22 @@ export function buildStatusMessage(args: StatusArgs): string { const runtime = (() => { const sandboxMode = args.agent?.sandbox?.mode ?? "off"; if (sandboxMode === "off") return { label: "direct" }; - const sessionScope = args.sessionScope ?? "per-sender"; - const mainKey = resolveMainSessionKey({ - session: { scope: sessionScope }, - }); const sessionKey = args.sessionKey?.trim(); - const sandboxed = sessionKey - ? sandboxMode === "all" || sessionKey !== mainKey.trim() - : false; + const sandboxed = (() => { + if (!sessionKey) return false; + if (sandboxMode === "all") return true; + if (args.config) { + return resolveSandboxRuntimeStatus({ + cfg: args.config, + sessionKey, + }).sandboxed; + } + const sessionScope = args.sessionScope ?? "per-sender"; + const mainKey = resolveMainSessionKey({ + session: { scope: sessionScope }, + }); + return sessionKey !== mainKey.trim(); + })(); const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown"; return { label: `${runtime}/${sandboxMode}`, diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 8460c3ad0..b01c9d7f1 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -35,7 +35,7 @@ vi.mock("../runtime.js", () => ({ })); describe("cron cli", () => { - it("trims model and thinking on cron add", async () => { + it("trims model and thinking on cron add", { timeout: 15_000 }, async () => { callGatewayFromCli.mockClear(); const { registerCronCli } = await import("./cron-cli.js"); diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 5d766d65d..300371819 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -131,6 +131,13 @@ describe("gateway server auth/connect", () => { { timeout: 15000 }, async () => { const { server, ws } = await startServerWithClient(); + const closeInfoPromise = new Promise<{ code: number; reason: string }>( + (resolve) => { + ws.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + }, + ); ws.send( JSON.stringify({ @@ -164,18 +171,7 @@ describe("gateway server auth/connect", () => { "invalid connect params", ); - const closeInfo = await new Promise<{ code: number; reason: string }>( - (resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("close timeout")), - 3000, - ); - ws.once("close", (code, reason) => { - clearTimeout(timer); - resolve({ code, reason: reason.toString() }); - }); - }, - ); + const closeInfo = await closeInfoPromise; expect(closeInfo.code).toBe(1008); expect(closeInfo.reason).toContain("invalid connect params"); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index aa3dba9c9..df5c42356 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1480,6 +1480,13 @@ export async function startGatewayServer( ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}` : "invalid handshake: first request must be connect" : "invalid request frame"; + handshakeState = "failed"; + setCloseCause("invalid-handshake", { + frameType, + frameMethod, + frameId, + handshakeError, + }); if (isRequestFrame) { const req = parsed as RequestFrame; send({ @@ -1493,13 +1500,6 @@ export async function startGatewayServer( `invalid handshake conn=${connId} remote=${remoteAddr ?? "?"}`, ); } - handshakeState = "failed"; - setCloseCause("invalid-handshake", { - frameType, - frameMethod, - frameId, - handshakeError, - }); const closeReason = truncateCloseReason( handshakeError || "invalid handshake", );