diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 4607d9f72cf..bbe020107c7 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import type { AgentDefinition } from "./defs"; // Import source directly so tests cannot pass against a stale build. -import { buildRecoveryScript } from "./runtime"; +import { buildRecoveryScript, getRegisteredAgent } from "./runtime"; function makeAgent(overrides: Partial = {}): AgentDefinition { return { @@ -60,6 +60,31 @@ const hermesAgent = makeAgent({ }, }); +describe("getRegisteredAgent", () => { + it("does not invent an agent when the target registry row is absent or OpenClaw", () => { + expect(getRegisteredAgent(null)).toBeNull(); + expect(getRegisteredAgent({})).toBeNull(); + expect(getRegisteredAgent({ agent: "openclaw" })).toBeNull(); + }); + + it("loads only the agent named by the supplied registry row", () => { + expect(getRegisteredAgent({ agent: "hermes" })?.name).toBe("hermes"); + }); + + it("fails closed when the registered agent definition is unavailable", () => { + expect(getRegisteredAgent({ agent: "missing-agent" })).toBeNull(); + }); + + it.each([ + "../openclaw", + "/tmp/agent", + "hermes/../openclaw", + "hermes\\openclaw", + ])("fails closed for path-like persisted agent name %j", (agent) => { + expect(getRegisteredAgent({ agent })).toBeNull(); + }); +}); + function extractGatewayProcessPattern(script: string | null): string { const match = script?.match(/_GATEWAY_PROC_PATTERN='([^']+)'/); expect(match).toBeTruthy(); diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index edf805978dd..a1bc95a746c 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -10,9 +10,11 @@ import { DASHBOARD_PORT } from "../core/ports"; import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; -import { type AgentDefinition, isTerminalAgent, loadAgent } from "./defs"; +import { type AgentDefinition, isTerminalAgent, listAgents, loadAgent } from "./defs"; import { getTerminalCommand } from "./gateway-restart-scripts"; +type RegisteredAgentSource = { agent?: string | null } | null | undefined; + export { type AgentRecoveryScript, buildRecoveryScript, @@ -31,12 +33,7 @@ export function getSessionAgent(sandboxName?: string): AgentDefinition | null { try { if (sandboxName) { const sb = registry.getSandbox(sandboxName); - if (sb?.agent && sb.agent !== "openclaw") { - return loadAgent(sb.agent); - } - if (sb?.agent === "openclaw" || (sb && !sb.agent)) { - return null; - } + if (sb) return getRegisteredAgent(sb); } const session = onboardSession.loadSession(); const name = session?.agent || "openclaw"; @@ -47,6 +44,22 @@ export function getSessionAgent(sandboxName?: string): AgentDefinition | null { } } +/** + * Resolve only the canonical agent persisted on the supplied sandbox registry row. + * Registry state is user-writable, so validate against the trusted manifest inventory + * before allowing its value to become a filesystem path component in loadAgent(). + */ +export function getRegisteredAgent(source: RegisteredAgentSource): AgentDefinition | null { + const name = source?.agent; + if (!name || name === "openclaw") return null; + try { + if (!listAgents().includes(name)) return null; + return loadAgent(name); + } catch { + return null; + } +} + /** * Get the health probe URL for the agent. * Returns the agent's configured probe URL, or the OpenClaw /health endpoint. diff --git a/src/lib/tunnel/agent-forward-stop.test.ts b/src/lib/tunnel/agent-forward-stop.test.ts new file mode 100644 index 00000000000..75924f66c95 --- /dev/null +++ b/src/lib/tunnel/agent-forward-stop.test.ts @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { stopAgentForwardPortsForStop } from "./agent-forward-stop"; + +function forwardList(entries: Array<{ sandbox: string; port: number; status?: string }>): string { + return [ + "SANDBOX BIND PORT PID STATUS", + ...entries.map( + (entry, index) => + `${entry.sandbox} 127.0.0.1 ${String(entry.port)} ${String(1000 + index)} ${ + entry.status ?? "running" + }`, + ), + ].join("\n"); +} + +describe("stopAgentForwardPortsForStop", () => { + it("stops declared and runtime dashboard forwards with sandbox-scoped commands", () => { + const runOpenshell = vi.fn(); + const runCaptureOpenshell = vi.fn(() => + forwardList([ + { sandbox: "nemohermes", port: 18789 }, + { sandbox: "nemohermes", port: 8642 }, + { sandbox: "nemohermes", port: 18792 }, + ]), + ); + const info = vi.fn<(message: string) => void>(); + + stopAgentForwardPortsForStop("nemohermes", { + getRegisteredAgent: () => ({ + displayName: "Hermes Agent", + forward_ports: [18789, 8642, "8642", 0, 80, 70000, "not-a-port"], + }), + getAgentDisplayName: (agent) => agent?.displayName ?? "OpenClaw", + getSandbox: () => ({ + dashboardPort: 18792, + gatewayName: "nemoclaw-18080", + gatewayPort: 18080, + }), + resolveOpenshell: () => "/usr/local/bin/openshell", + runOpenshell, + runCaptureOpenshell, + confirmPortReleased: () => true, + info, + }); + + expect(runOpenshell).toHaveBeenCalledTimes(3); + expect(runOpenshell).toHaveBeenNthCalledWith( + 1, + ["forward", "stop", "18789", "nemohermes", "--gateway", "nemoclaw-18080"], + { + ignoreError: true, + suppressOutput: true, + }, + ); + expect(runOpenshell).toHaveBeenNthCalledWith( + 2, + ["forward", "stop", "8642", "nemohermes", "--gateway", "nemoclaw-18080"], + { + ignoreError: true, + suppressOutput: true, + }, + ); + expect(runOpenshell).toHaveBeenNthCalledWith( + 3, + ["forward", "stop", "18792", "nemohermes", "--gateway", "nemoclaw-18080"], + { + ignoreError: true, + suppressOutput: true, + }, + ); + expect(runCaptureOpenshell).toHaveBeenCalledTimes(3); + expect(runCaptureOpenshell).toHaveBeenCalledWith( + ["forward", "list", "--gateway", "nemoclaw-18080"], + expect.any(Object), + ); + expect(info.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Stopped Hermes Agent host port forward 8642", + ); + }); + + it("skips OpenClaw or agents without declared forwards", () => { + const resolveOpenshell = vi.fn(() => "/usr/local/bin/openshell"); + const runOpenshell = vi.fn(); + + stopAgentForwardPortsForStop("openclaw-sandbox", { + getRegisteredAgent: () => null, + getSandbox: () => ({ agent: "openclaw", gatewayPort: 8080 }), + resolveOpenshell, + runOpenshell, + }); + + stopAgentForwardPortsForStop("empty-agent", { + getRegisteredAgent: () => ({ displayName: "Empty Agent", forward_ports: [] }), + getSandbox: () => ({ agent: "empty-agent", gatewayPort: 8080 }), + resolveOpenshell, + runOpenshell, + }); + + expect(resolveOpenshell).not.toHaveBeenCalled(); + expect(runOpenshell).not.toHaveBeenCalled(); + }); + + it("leaves forwards alone when OpenShell reports a different owner", () => { + const runOpenshell = vi.fn(); + const warn = vi.fn<(message: string) => void>(); + + stopAgentForwardPortsForStop("nemohermes", { + getRegisteredAgent: () => ({ displayName: "Hermes Agent", forward_ports: [8642] }), + getAgentDisplayName: (agent) => agent?.displayName ?? "OpenClaw", + getSandbox: () => ({ gatewayPort: 8080 }), + resolveOpenshell: () => "/usr/local/bin/openshell", + runOpenshell, + runCaptureOpenshell: () => forwardList([{ sandbox: "other-sandbox", port: 8642 }]), + warn, + }); + + expect(runOpenshell).not.toHaveBeenCalled(); + expect(warn.mock.calls.map((call) => call[0]).join("\n")).toContain( + "belongs to another sandbox", + ); + }); + + it("does not stop a forward when ownership cannot be enumerated", () => { + const runOpenshell = vi.fn(); + const warn = vi.fn<(message: string) => void>(); + + stopAgentForwardPortsForStop("nemohermes", { + getRegisteredAgent: () => ({ displayName: "Hermes Agent", forward_ports: [8642] }), + getAgentDisplayName: (agent) => agent?.displayName ?? "OpenClaw", + getSandbox: () => ({ gatewayPort: 8080 }), + resolveOpenshell: () => "/usr/local/bin/openshell", + runOpenshell, + runCaptureOpenshell: () => { + throw new Error("forward list failed"); + }, + warn, + }); + + expect(runOpenshell).not.toHaveBeenCalled(); + expect(warn.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Could not enumerate OpenShell forwards", + ); + }); + + it("warns instead of claiming success when the listener remains bound (#6392)", () => { + const runOpenshell = vi.fn(); + const info = vi.fn<(message: string) => void>(); + const warn = vi.fn<(message: string) => void>(); + + stopAgentForwardPortsForStop("nemohermes", { + getRegisteredAgent: () => ({ displayName: "Hermes Agent", forward_ports: [8642] }), + getAgentDisplayName: (agent) => agent?.displayName ?? "OpenClaw", + getSandbox: () => ({ gatewayName: "nemoclaw-18080", gatewayPort: 18080 }), + resolveOpenshell: () => "/usr/local/bin/openshell", + runOpenshell, + runCaptureOpenshell: () => forwardList([{ sandbox: "nemohermes", port: 8642 }]), + confirmPortReleased: () => false, + info, + warn, + }); + + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "stop", "8642", "nemohermes", "--gateway", "nemoclaw-18080"], + expect.any(Object), + ); + expect(info).not.toHaveBeenCalled(); + expect(warn.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Could not confirm Hermes Agent host port forward 8642 was released within 5 seconds", + ); + }); + + it("fails closed when the sandbox gateway binding is unavailable", () => { + const resolveOpenshell = vi.fn(() => "/usr/local/bin/openshell"); + const warn = vi.fn<(message: string) => void>(); + + stopAgentForwardPortsForStop("nemohermes", { + getRegisteredAgent: () => ({ displayName: "Hermes Agent", forward_ports: [8642] }), + getAgentDisplayName: (agent) => agent?.displayName ?? "OpenClaw", + getSandbox: () => null, + resolveOpenshell, + warn, + }); + + expect(resolveOpenshell).not.toHaveBeenCalled(); + expect(warn.mock.calls.map((call) => call[0]).join("\n")).toContain( + "cannot safely stop agent host port forwards", + ); + }); + + it("does not fall back to an unrelated onboard session when the registry has no agent", () => { + const getRegisteredAgent = vi.fn(() => null); + const resolveOpenshell = vi.fn(() => "/usr/local/bin/openshell"); + + stopAgentForwardPortsForStop("openclaw-sandbox", { + getSandbox: () => ({ agent: "openclaw", gatewayPort: 8080 }), + getRegisteredAgent, + resolveOpenshell, + }); + + expect(getRegisteredAgent).toHaveBeenCalledWith( + expect.objectContaining({ agent: "openclaw", gatewayPort: 8080 }), + ); + expect(resolveOpenshell).not.toHaveBeenCalled(); + }); + + it("fails closed when the sandbox registry cannot be read", () => { + const getRegisteredAgent = vi.fn(); + const runOpenshell = vi.fn(); + const warn = vi.fn<(message: string) => void>(); + + expect(() => + stopAgentForwardPortsForStop("nemohermes", { + getSandbox: () => { + throw new Error("invalid registry data"); + }, + getRegisteredAgent, + runOpenshell, + warn, + }), + ).not.toThrow(); + + expect(getRegisteredAgent).not.toHaveBeenCalled(); + expect(runOpenshell).not.toHaveBeenCalled(); + expect(warn.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Could not read the sandbox registry for 'nemohermes'", + ); + }); + + it.each([ + "../escape", + "bad name", + "--gateway", + ])("rejects malformed sandbox name %j before registry or OpenShell access", (sandboxName) => { + const getSandbox = vi.fn(); + const resolveOpenshell = vi.fn(() => "/usr/local/bin/openshell"); + const runCaptureOpenshell = vi.fn(); + const runOpenshell = vi.fn(); + const warn = vi.fn<(message: string) => void>(); + + stopAgentForwardPortsForStop(sandboxName, { + getSandbox, + resolveOpenshell, + runCaptureOpenshell, + runOpenshell, + warn, + }); + + expect(getSandbox).not.toHaveBeenCalled(); + expect(resolveOpenshell).not.toHaveBeenCalled(); + expect(runCaptureOpenshell).not.toHaveBeenCalled(); + expect(runOpenshell).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Invalid sandbox name")); + }); +}); diff --git a/src/lib/tunnel/agent-forward-stop.ts b/src/lib/tunnel/agent-forward-stop.ts new file mode 100644 index 00000000000..0b7f063ea6d --- /dev/null +++ b/src/lib/tunnel/agent-forward-stop.ts @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; + +import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts"; +import * as agentRuntime from "../agent/runtime"; +import { waitUntil } from "../core/wait"; +import { + bestEffortForwardStopForSandbox, + type ForwardListRunner, + type ForwardStopRunner, +} from "../onboard/forward-cleanup"; +import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; +import * as registry from "../state/registry"; +import { defaultProbePortFree } from "./gateway-port-confirmation"; + +type Reporter = (message: string) => void; + +type AgentWithForwards = { + displayName?: string; + forward_ports?: unknown; +}; + +type SandboxWithDashboardPort = { + agent?: string | null; + dashboardPort?: unknown; + gatewayName?: string | null; + gatewayPort?: number | null; +}; + +type StopAgentForwardPortsDeps = { + getRegisteredAgent?: (sandbox: SandboxWithDashboardPort | null) => AgentWithForwards | null; + getAgentDisplayName?: (agent: AgentWithForwards | null) => string; + getSandbox?: (sandboxName: string) => SandboxWithDashboardPort | null; + resolveOpenshell?: () => string | null; + runOpenshell?: ForwardStopRunner; + runCaptureOpenshell?: ForwardListRunner; + confirmPortReleased?: (port: number) => boolean; + info?: Reporter; + warn?: Reporter; +}; + +const FORWARD_RELEASE_TIMEOUT_MS = 5000; +const FORWARD_RELEASE_POLL_MS = 250; +const SAFE_SANDBOX_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function confirmForwardPortReleased(port: number): boolean { + const now = Date.now; + return waitUntil(() => defaultProbePortFree(port), { + deadlineMs: now() + FORWARD_RELEASE_TIMEOUT_MS, + maxAttempts: 20, + initialIntervalMs: FORWARD_RELEASE_POLL_MS, + maxIntervalMs: FORWARD_RELEASE_POLL_MS, + backoffFactor: 1, + now, + }); +} + +function getAgentForwardPorts(agent: AgentWithForwards, dashboardPort: unknown): number[] { + const ports = new Set(); + // The gateway establishes dashboardPort at runtime even when it is not manifest-declared. + const candidates = [ + ...(Array.isArray(agent.forward_ports) ? agent.forward_ports : []), + dashboardPort, + ]; + for (const rawPort of candidates) { + const port = + typeof rawPort === "number" + ? rawPort + : typeof rawPort === "string" && /^\d+$/.test(rawPort.trim()) + ? Number(rawPort.trim()) + : NaN; + if (Number.isInteger(port) && port >= 1024 && port <= 65535) { + ports.add(port); + } + } + return [...ports]; +} + +function makeRunOpenshell(openshell: string): ForwardStopRunner { + return (args, opts) => { + const result = spawnSync(openshell, args, { + encoding: "utf-8", + stdio: opts.suppressOutput ? "ignore" : "inherit", + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + if (!opts.ignoreError && result.status !== 0) { + throw new Error(`openshell ${args.join(" ")} failed`); + } + return result; + }; +} + +function makeRunCaptureOpenshell(openshell: string): ForwardListRunner { + return (args, opts) => { + const result = spawnSync(openshell, args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: opts.timeout, + }); + if (result.status !== 0) { + throw new Error(`openshell ${args.join(" ")} failed`); + } + return result.stdout || ""; + }; +} + +export function stopAgentForwardPortsForStop( + sandboxName: string | undefined, + deps: StopAgentForwardPortsDeps = {}, +): void { + if (!sandboxName) return; + + const warn = deps.warn ?? (() => {}); + if (!SAFE_SANDBOX_NAME_RE.test(sandboxName) || sandboxName.includes("..")) { + warn(`Invalid sandbox name: ${JSON.stringify(sandboxName)} - skipping host forward cleanup.`); + return; + } + + const info = deps.info ?? (() => {}); + const getSandbox = deps.getSandbox ?? registry.getSandbox; + let sandbox: SandboxWithDashboardPort | null; + try { + sandbox = getSandbox(sandboxName); + } catch (error) { + warn( + `Could not read the sandbox registry for '${sandboxName}': ` + + `${error instanceof Error ? error.message : String(error)}. ` + + "Skipping agent host port forward cleanup.", + ); + return; + } + if (!sandbox) { + warn( + `Could not resolve sandbox '${sandboxName}' - cannot safely stop agent host port forwards.`, + ); + return; + } + + const getRegisteredAgent = deps.getRegisteredAgent ?? agentRuntime.getRegisteredAgent; + const agent = getRegisteredAgent(sandbox); + if (sandbox.agent && sandbox.agent !== "openclaw" && !agent) { + warn( + `Could not resolve registered agent '${sandbox.agent}' for sandbox '${sandboxName}'; ` + + "skipping agent host port forward cleanup.", + ); + return; + } + if (!agent) return; + + const displayName = deps.getAgentDisplayName + ? deps.getAgentDisplayName(agent) + : agentRuntime.getAgentDisplayName( + agent as Parameters[0], + ); + let gatewayName: string; + try { + gatewayName = resolveSandboxGatewayName(sandbox); + } catch (error) { + warn( + `Could not resolve the OpenShell gateway for sandbox '${sandboxName}': ` + + `${(error as Error).message ?? String(error)}. ` + + `Skipping ${displayName} host port forward cleanup.`, + ); + return; + } + + const ports = getAgentForwardPorts(agent, sandbox.dashboardPort); + if (ports.length === 0) return; + + const openshell = (deps.resolveOpenshell ?? resolveOpenshell)(); + if (!openshell) { + warn(`openshell not found - cannot stop ${displayName} host port forwards.`); + return; + } + + const runOpenshell = deps.runOpenshell ?? makeRunOpenshell(openshell); + const runCaptureOpenshell = deps.runCaptureOpenshell ?? makeRunCaptureOpenshell(openshell); + const scopedRunOpenshell: ForwardStopRunner = (args, opts) => + runOpenshell([...args, "--gateway", gatewayName], opts); + const scopedRunCaptureOpenshell: ForwardListRunner = (args, opts) => + runCaptureOpenshell([...args, "--gateway", gatewayName], opts); + const confirmPortReleased = deps.confirmPortReleased ?? confirmForwardPortReleased; + + for (const port of ports) { + const result = bestEffortForwardStopForSandbox( + scopedRunOpenshell, + scopedRunCaptureOpenshell, + port, + sandboxName, + ); + if (result === "owned-other") { + warn( + `Keeping ${displayName} host port forward ${String(port)}; it belongs to another sandbox.`, + ); + continue; + } + if (result === "list-failed") { + warn( + `Could not enumerate OpenShell forwards; skipping ${displayName} host port forward ${String( + port, + )} cleanup.`, + ); + continue; + } + + if (!confirmPortReleased(port)) { + warn( + `Could not confirm ${displayName} host port forward ${String(port)} was released ` + + `within ${String(FORWARD_RELEASE_TIMEOUT_MS / 1000)} seconds; ` + + "the listener may still be running.", + ); + } else if (result === "stopped") { + info( + `Stopped ${displayName} host port forward ${String(port)} for sandbox '${sandboxName}'.`, + ); + } + } +} diff --git a/src/lib/tunnel/sandbox-gateway-stop.test.ts b/src/lib/tunnel/sandbox-gateway-stop.test.ts new file mode 100644 index 00000000000..8e1dda492d7 --- /dev/null +++ b/src/lib/tunnel/sandbox-gateway-stop.test.ts @@ -0,0 +1,326 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SpawnSyncReturns } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; + +import type { AgentDefinition } from "../agent/defs"; +import type { SandboxEntry } from "../state/registry"; +import { type SandboxGatewayStopDeps, stopSandboxChannels } from "./sandbox-gateway-stop"; + +function spawnResult(status: number | null, stdout = "", stderr = ""): SpawnSyncReturns { + return { + pid: 1, + output: [null, stdout, stderr], + stdout, + stderr, + status, + signal: null, + }; +} + +function registeredAgent( + values: Partial & Pick, +): AgentDefinition { + return values as AgentDefinition; +} + +function sandbox(values: Partial = {}): SandboxEntry { + return { name: "my-sandbox", ...values }; +} + +function harness() { + const getSandbox = vi.fn>(() => sandbox()); + const getRegisteredAgent = vi.fn>( + () => null, + ); + const resolveOpenshell = vi.fn>( + () => "/usr/local/bin/openshell", + ); + const runDocker = vi.fn>(() => spawnResult(1)); + const runProcess = vi.fn>(() => spawnResult(0)); + const info = vi.fn<(message: string) => void>(); + const warn = vi.fn<(message: string) => void>(); + const deps: SandboxGatewayStopDeps = { + getSandbox, + getRegisteredAgent, + resolveOpenshell, + runDocker, + runProcess, + info, + warn, + }; + return { + deps, + getRegisteredAgent, + getSandbox, + info, + resolveOpenshell, + runDocker, + runProcess, + warn, + }; +} + +describe("stopSandboxChannels", () => { + it("uses kubectl via the OpenShell gateway container for privileged shutdown", () => { + const h = harness(); + h.runDocker + .mockReturnValueOnce(spawnResult(0, "pod/my-sandbox-0\n")) + .mockReturnValueOnce(spawnResult(0)); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.runDocker).toHaveBeenNthCalledWith( + 1, + [ + "exec", + "openshell-cluster-nemoclaw", + "kubectl", + "get", + "pods", + "-n", + "openshell", + "-o", + "name", + ], + expect.objectContaining({ timeout: 10000 }), + ); + const args = h.runDocker.mock.calls[1][0]; + expect(args).toEqual( + expect.arrayContaining(["kubectl", "exec", "-n", "openshell", "-c", "agent"]), + ); + const script = String(args.at(-1)); + expect(script).toContain("ps -eo user=,pid=,args="); + expect(script).toContain("openclaw-gateway"); + expect(script).toContain("kill -TERM $pids"); + expect(script).toContain("kill -KILL $remaining"); + expect(h.info).toHaveBeenCalledWith("OpenClaw gateway stopped inside sandbox."); + }); + + it("falls back to gateway-scoped openshell sandbox exec through stdin", () => { + const h = harness(); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.runProcess).toHaveBeenCalledWith( + "/usr/local/bin/openshell", + ["sandbox", "exec", "--name", "my-sandbox", "--gateway", "nemoclaw", "--", "sh", "-s"], + expect.objectContaining({ + input: expect.stringContaining("find_gateway_pids"), + timeout: 20000, + }), + ); + }); + + it("selects the exact generated sandbox pod and excludes overlapping names", () => { + const h = harness(); + h.runDocker + .mockReturnValueOnce(spawnResult(0, "pod/prod-app-abc\npod/app-abc\n")) + .mockReturnValueOnce(spawnResult(0)); + + stopSandboxChannels("app", h.deps); + + const args = h.runDocker.mock.calls[1][0]; + expect(args).toContain("pod/app-abc"); + expect(args).not.toContain("pod/prod-app-abc"); + }); + + it("falls back when no exact generated sandbox pod name is available", () => { + const h = harness(); + h.runDocker.mockReturnValueOnce(spawnResult(0, "pod/prod-app-abc\npod/app-copy-abc\n")); + + stopSandboxChannels("app", h.deps); + + expect(h.runProcess).toHaveBeenCalledTimes(1); + }); + + it("treats stop script exit 1 as already stopped", () => { + const h = harness(); + h.runDocker + .mockReturnValueOnce(spawnResult(0, "pod/my-sandbox-0\n")) + .mockReturnValueOnce(spawnResult(1)); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.info).toHaveBeenCalledWith("OpenClaw gateway was not running inside sandbox."); + }); + + it("does not transiently kill a supervisor-managed Hermes gateway (#6392)", () => { + const h = harness(); + const hermes = registeredAgent({ + name: "hermes", + displayName: "Hermes Agent", + gateway_command: "hermes gateway run", + forward_ports: [18789, 8642], + }); + h.getSandbox.mockReturnValue(sandbox({ agent: "hermes" })); + h.getRegisteredAgent.mockReturnValue(hermes); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.runDocker).not.toHaveBeenCalled(); + expect(h.runProcess).not.toHaveBeenCalled(); + expect(h.info.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Hermes Agent gateway is managed by the sandbox", + ); + }); + + it("does not treat a terminal agent command as a gateway process", () => { + const h = harness(); + const terminal = registeredAgent({ + name: "langchain-deepagents-code", + displayName: "LangChain Deep Agents Code", + runtime: { + kind: "terminal", + interactive_command: "dcode", + headless_command: "dcode -n", + }, + }); + h.getSandbox.mockReturnValue(sandbox({ agent: "langchain-deepagents-code" })); + h.getRegisteredAgent.mockReturnValue(terminal); + + stopSandboxChannels("dcode-sandbox", h.deps); + + expect(h.runDocker).not.toHaveBeenCalled(); + expect(h.runProcess).not.toHaveBeenCalled(); + expect(h.info).toHaveBeenCalledWith( + "LangChain Deep Agents Code has no gateway runtime; skipping in-sandbox gateway stop.", + ); + }); + + it("ignores an unrelated global session when the target registry row is missing", () => { + const h = harness(); + h.getSandbox.mockReturnValue(null); + h.runDocker + .mockReturnValueOnce(spawnResult(0, "pod/openclaw-target-abc\n")) + .mockReturnValueOnce(spawnResult(0)); + + stopSandboxChannels("openclaw-target", h.deps); + + expect(h.getRegisteredAgent).toHaveBeenCalledWith(null); + expect(h.runDocker).toHaveBeenCalledTimes(2); + expect(h.info.mock.calls.map((call) => call[0]).join("\n")).not.toContain("Hermes Agent"); + }); + + it("fails closed when the sandbox registry cannot be read", () => { + const h = harness(); + h.getSandbox.mockImplementation(() => { + throw new Error("invalid registry data"); + }); + + expect(() => stopSandboxChannels("my-sandbox", h.deps)).not.toThrow(); + + expect(h.getRegisteredAgent).not.toHaveBeenCalled(); + expect(h.runDocker).not.toHaveBeenCalled(); + expect(h.runProcess).not.toHaveBeenCalled(); + expect(h.warn).toHaveBeenCalledWith( + expect.stringContaining("Could not read the sandbox registry for 'my-sandbox'"), + ); + }); + + it("fails closed when the persisted gateway binding is invalid", () => { + const h = harness(); + h.getSandbox.mockReturnValue(sandbox({ agent: "openclaw", gatewayPort: 0 })); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.runDocker).not.toHaveBeenCalled(); + expect(h.resolveOpenshell).not.toHaveBeenCalled(); + expect(h.runProcess).not.toHaveBeenCalled(); + expect(h.warn.mock.calls.map((call) => call[0]).join("\n")).toContain( + "Invalid persisted sandbox gateway binding", + ); + }); + + it("fails closed when a registered non-OpenClaw agent cannot be loaded", () => { + const h = harness(); + h.getSandbox.mockReturnValue(sandbox({ agent: "missing-agent" })); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.runDocker).not.toHaveBeenCalled(); + expect(h.runProcess).not.toHaveBeenCalled(); + expect(h.warn).toHaveBeenCalledWith( + expect.stringContaining("Could not resolve registered agent 'missing-agent'"), + ); + }); + + it("warns when privileged shutdown reports the gateway may still be running", () => { + const h = harness(); + h.runDocker + .mockReturnValueOnce(spawnResult(0, "pod/my-sandbox-0\n")) + .mockReturnValueOnce(spawnResult(2, "", "205")); + + stopSandboxChannels("my-sandbox", h.deps); + + const output = h.warn.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("Could not stop OpenClaw gateway inside sandbox"); + expect(output).toContain("gateway may still be running"); + expect(output).toContain("205"); + }); + + it("warns when spawn returns null status", () => { + const h = harness(); + h.runDocker + .mockReturnValueOnce(spawnResult(0, "pod/my-sandbox-0\n")) + .mockReturnValueOnce(spawnResult(null)); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.warn).toHaveBeenCalledWith(expect.stringContaining("exit unknown")); + }); + + it("warns when privileged shutdown is unavailable and openshell is not found", () => { + const h = harness(); + h.resolveOpenshell.mockReturnValue(null); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.runProcess).not.toHaveBeenCalled(); + expect(h.warn).toHaveBeenCalledWith( + "openshell not found — cannot stop OpenClaw gateway inside sandbox.", + ); + }); + + it("routes shutdown through the sandbox's persisted non-default gateway", () => { + const h = harness(); + h.getSandbox.mockReturnValue(sandbox({ gatewayName: "nemoclaw-18080", gatewayPort: 18080 })); + + stopSandboxChannels("my-sandbox", h.deps); + + expect(h.runDocker).toHaveBeenCalledWith( + expect.arrayContaining(["exec", "openshell-cluster-nemoclaw-18080", "kubectl"]), + expect.any(Object), + ); + expect(h.runProcess).toHaveBeenCalledWith( + "/usr/local/bin/openshell", + expect.arrayContaining(["--gateway", "nemoclaw-18080"]), + expect.any(Object), + ); + }); + + it("targets launcher, re-exec, and identity-guarded bare gateway forms", () => { + const h = harness(); + h.runDocker + .mockReturnValueOnce(spawnResult(0, "pod/my-sandbox-0\n")) + .mockReturnValueOnce(spawnResult(0)); + + stopSandboxChannels("my-sandbox", h.deps); + + const script = String(h.runDocker.mock.calls[1][0].at(-1)); + expect(script).toContain("openclaw-gateway"); + expect(script).toContain("openclaw[[:space:]]+gateway"); + expect(script).toContain("openclaw[[:space:]]*$"); + expect(script).toContain("identity_files_trusted"); + }); + + it("rejects malformed sandbox names before any spawn", () => { + const h = harness(); + + expect(() => stopSandboxChannels("../escape", h.deps)).toThrow("Invalid sandbox name"); + expect(h.getSandbox).not.toHaveBeenCalled(); + expect(h.runDocker).not.toHaveBeenCalled(); + expect(h.runProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tunnel/sandbox-gateway-stop.ts b/src/lib/tunnel/sandbox-gateway-stop.ts new file mode 100644 index 00000000000..7176783c7b1 --- /dev/null +++ b/src/lib/tunnel/sandbox-gateway-stop.ts @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, + spawnSync, +} from "node:child_process"; + +import { dockerSpawnSync } from "../adapters/docker"; +import { getGatewayClusterContainerName } from "../adapters/openshell/gateway-drift"; +import { resolveOpenshell } from "../adapters/openshell/resolve"; +import * as agentRuntime from "../agent/runtime"; +import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; +import * as registry from "../state/registry"; +import { GATEWAY_STOP_SCRIPT } from "./gateway-stop-script"; + +type Reporter = (message: string) => void; +type StopAttemptResult = ReturnType; +type ProcessRunner = ( + command: string, + args: readonly string[], + options: SpawnSyncOptionsWithStringEncoding, +) => SpawnSyncReturns; + +export type SandboxGatewayStopDeps = { + getSandbox?: typeof registry.getSandbox; + getRegisteredAgent?: typeof agentRuntime.getRegisteredAgent; + getAgentDisplayName?: typeof agentRuntime.getAgentDisplayName; + hasGatewayRuntime?: typeof agentRuntime.hasGatewayRuntime; + resolveOpenshell?: typeof resolveOpenshell; + runDocker?: typeof dockerSpawnSync; + runProcess?: ProcessRunner; + info?: Reporter; + warn?: Reporter; +}; + +const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function defaultInfo(message: string): void { + console.log(`[services] ${message}`); +} + +function defaultWarn(message: string): void { + console.log(`[services] ${message}`); +} + +function validateSandboxName(name: string): string { + if (!SAFE_NAME_RE.test(name) || name.includes("..")) { + throw new Error(`Invalid sandbox name: ${JSON.stringify(name)}`); + } + return name; +} + +/** Stop only a proven OpenClaw gateway; supervised agents remain owned by their sandbox. */ +export function stopSandboxChannels(sandboxName: string, deps: SandboxGatewayStopDeps = {}): void { + const info = deps.info ?? defaultInfo; + const warn = deps.warn ?? defaultWarn; + const validatedSandboxName = validateSandboxName(sandboxName); + let sandbox: ReturnType; + try { + sandbox = (deps.getSandbox ?? registry.getSandbox)(validatedSandboxName); + } catch (error) { + warn( + `Could not read the sandbox registry for '${validatedSandboxName}': ` + + `${(error as Error).message ?? String(error)}. Skipping in-sandbox gateway stop.`, + ); + return; + } + const agent = (deps.getRegisteredAgent ?? agentRuntime.getRegisteredAgent)(sandbox); + + if (sandbox?.agent && sandbox.agent !== "openclaw" && !agent) { + warn( + `Could not resolve registered agent '${sandbox.agent}' for sandbox ` + + `'${validatedSandboxName}'; skipping in-sandbox gateway stop.`, + ); + return; + } + if (!(deps.hasGatewayRuntime ?? agentRuntime.hasGatewayRuntime)(agent)) { + const agentDisplayName = (deps.getAgentDisplayName ?? agentRuntime.getAgentDisplayName)(agent); + info(`${agentDisplayName} has no gateway runtime; skipping in-sandbox gateway stop.`); + return; + } + + const agentDisplayName = (deps.getAgentDisplayName ?? agentRuntime.getAgentDisplayName)(agent); + if (agent) { + info( + `${agentDisplayName} gateway is managed by the sandbox; ` + + "leaving it running while host forwards stop.", + ); + return; + } + + let gatewayName: string; + try { + gatewayName = resolveSandboxGatewayName(sandbox); + } catch (error) { + warn( + `Could not resolve the OpenShell gateway for sandbox '${validatedSandboxName}': ` + + `${(error as Error).message ?? String(error)}. Skipping in-sandbox gateway stop.`, + ); + return; + } + + const gatewayLabel = `${agentDisplayName} gateway`; + info(`Stopping in-sandbox ${gatewayLabel} (sandbox: ${validatedSandboxName})...`); + + const privilegedResult = stopSandboxChannelsViaKubectl( + validatedSandboxName, + gatewayName, + GATEWAY_STOP_SCRIPT, + deps.runDocker ?? dockerSpawnSync, + ); + if (reportStopResult(privilegedResult, gatewayLabel, info, warn)) return; + + const openshell = (deps.resolveOpenshell ?? resolveOpenshell)(); + if (!openshell) { + warn(`openshell not found — cannot stop ${gatewayLabel} inside sandbox.`); + return; + } + + const fallbackResult = (deps.runProcess ?? spawnSync)( + openshell, + ["sandbox", "exec", "--name", validatedSandboxName, "--gateway", gatewayName, "--", "sh", "-s"], + { + encoding: "utf-8", + input: GATEWAY_STOP_SCRIPT, + stdio: ["pipe", "pipe", "pipe"], + timeout: 20000, + }, + ); + reportStopResult(fallbackResult, gatewayLabel, info, warn); +} + +function isSandboxPodName(line: string, sandboxName: string): boolean { + if (!line.startsWith("pod/")) return false; + const podName = line.slice("pod/".length); + if (podName === sandboxName) return true; + const prefix = `${sandboxName}-`; + if (!podName.startsWith(prefix)) return false; + const generatedSuffix = podName.slice(prefix.length); + return /^[a-z0-9]+$/.test(generatedSuffix); +} + +function stopSandboxChannelsViaKubectl( + sandboxName: string, + gatewayName: string, + gatewayStopScript: string, + runDocker: typeof dockerSpawnSync, +): StopAttemptResult | null { + const gatewayContainer = getGatewayClusterContainerName(gatewayName); + const podsResult = runDocker( + ["exec", gatewayContainer, "kubectl", "get", "pods", "-n", "openshell", "-o", "name"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 }, + ); + if (podsResult.status !== 0 || !podsResult.stdout) return null; + + const podOutput = + typeof podsResult.stdout === "string" ? podsResult.stdout : podsResult.stdout.toString(); + const pod = podOutput + .split(/\r?\n/) + .map((line: string) => line.trim()) + .find((line: string) => isSandboxPodName(line, sandboxName)); + if (!pod) return null; + + return runDocker( + [ + "exec", + gatewayContainer, + "kubectl", + "exec", + "-n", + "openshell", + "-c", + "agent", + pod, + "--", + "sh", + "-lc", + gatewayStopScript, + ], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }, + ); +} + +function reportStopResult( + result: StopAttemptResult | null, + gatewayLabel: string, + info: Reporter, + warn: Reporter, +): boolean { + if (!result) return false; + + if (result.status === 0) { + info(`${gatewayLabel} stopped inside sandbox.`); + return true; + } + if (result.status === 1) { + info(`${gatewayLabel} was not running inside sandbox.`); + return true; + } + + const details = [result.stderr, result.stdout] + .map((text) => (typeof text === "string" ? text : text?.toString())) + .filter((text): text is string => Boolean(text?.trim())) + .map((text) => text.trim()) + .join(" "); + warn( + `Could not stop ${gatewayLabel} inside sandbox (exit ${String(result.status ?? "unknown")}).` + + " The sandbox may be unreachable or the gateway may still be running." + + (details ? ` Details: ${details}` : ""), + ); + return true; +} diff --git a/src/lib/tunnel/services-gateway-ownership.test.ts b/src/lib/tunnel/services-gateway-ownership.test.ts index 99a37393b11..bfc348453ae 100644 --- a/src/lib/tunnel/services-gateway-ownership.test.ts +++ b/src/lib/tunnel/services-gateway-ownership.test.ts @@ -7,9 +7,11 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SandboxEntry } from "../state/registry"; +import * as agentForwardStop from "./agent-forward-stop"; import type { ReleaseGatewayPortResult } from "./gateway-port-release"; import type { GatewayStopDeps } from "./gateway-stop"; import * as gatewayStop from "./gateway-stop"; +import * as sandboxGatewayStop from "./sandbox-gateway-stop"; import { stopAll } from "./services"; vi.mock("../adapters/docker", () => ({ @@ -183,12 +185,28 @@ describe("stopAll gateway-stop wiring", () => { vi.restoreAllMocks(); }); - it("passes the resolved sandbox and service reporters to the focused stop module", () => { + it("orders supervised-agent full stop as sandbox guard, forwards, then gateway release", () => { const pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-gateway-stop-wiring-")); vi.stubEnv("PATH", ""); + const order: string[] = []; + const stopSandboxGateway = vi + .spyOn(sandboxGatewayStop, "stopSandboxChannels") + .mockImplementation((_sandboxName, deps) => { + order.push("sandbox-guard"); + deps?.info?.( + "Hermes Agent gateway is managed by the sandbox; leaving it running while host forwards stop.", + ); + }); const releaseForStop = vi .spyOn(gatewayStop, "releaseGatewayPortForStop") - .mockImplementation(() => {}); + .mockImplementation(() => { + order.push("gateway-release"); + }); + const stopAgentForwards = vi + .spyOn(agentForwardStop, "stopAgentForwardPortsForStop") + .mockImplementation(() => { + order.push("host-forwards"); + }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { @@ -197,14 +215,22 @@ describe("stopAll gateway-stop wiring", () => { rmSync(pidDir, { recursive: true, force: true }); } - expect(releaseForStop).toHaveBeenCalledTimes(1); + expect(stopSandboxGateway).toHaveBeenCalledWith("alpha", { + info: expect.any(Function), + warn: expect.any(Function), + }); + expect(stopAgentForwards).toHaveBeenCalledWith("alpha", { + info: expect.any(Function), + warn: expect.any(Function), + }); expect(releaseForStop).toHaveBeenCalledWith("alpha", { info: expect.any(Function), warn: expect.any(Function), }); - expect(logSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n")).toContain( - "All services stopped", - ); + expect(order).toEqual(["sandbox-guard", "host-forwards", "gateway-release"]); + const output = logSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + expect(output).toContain("Hermes Agent gateway is managed by the sandbox"); + expect(output).toContain("All services stopped"); }); it("preserves the shared gateway for canonical tunnel-only stop", () => { @@ -213,6 +239,10 @@ describe("stopAll gateway-stop wiring", () => { const releaseForStop = vi .spyOn(gatewayStop, "releaseGatewayPortForStop") .mockImplementation(() => {}); + const stopAgentForwards = vi + .spyOn(agentForwardStop, "stopAgentForwardPortsForStop") + .mockImplementation(() => {}); + vi.spyOn(sandboxGatewayStop, "stopSandboxChannels").mockImplementation(() => {}); try { stopAll({ pidDir, sandboxName: "alpha" }); @@ -221,5 +251,6 @@ describe("stopAll gateway-stop wiring", () => { } expect(releaseForStop).not.toHaveBeenCalled(); + expect(stopAgentForwards).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/tunnel/services-sandbox.test.ts b/src/lib/tunnel/services-sandbox.test.ts index 785f3730843..3679125f0df 100644 --- a/src/lib/tunnel/services-sandbox.test.ts +++ b/src/lib/tunnel/services-sandbox.test.ts @@ -6,506 +6,167 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// --------------------------------------------------------------------------- -// We test stopSandboxChannels / stopAll by temporarily replacing the -// compiled resolve-openshell module's export and spying on spawnSync. -// This avoids vi.mock() hoisting issues with CommonJS require chains. -// --------------------------------------------------------------------------- +import * as agentForwardStop from "./agent-forward-stop"; +import * as gatewayStop from "./gateway-stop"; +import * as sandboxGatewayStop from "./sandbox-gateway-stop"; +import { stopAll } from "./services"; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const resolveOpenshellModule = require("../adapters/openshell/resolve"); +const SANDBOX_ENV_NAMES = ["NEMOCLAW_SANDBOX", "NEMOCLAW_SANDBOX_NAME", "SANDBOX_NAME"] as const; -const { stopAll, stopSandboxChannels } = require("./services") as typeof import("./services"); - -// --------------------------------------------------------------------------- -// stopSandboxChannels -// --------------------------------------------------------------------------- - -describe("stopSandboxChannels", () => { - let spawnSyncSpy: ReturnType; - let originalResolve: typeof resolveOpenshellModule.resolveOpenshell; - - beforeEach(() => { - originalResolve = resolveOpenshellModule.resolveOpenshell; - resolveOpenshellModule.resolveOpenshell = vi.fn(() => "/usr/local/bin/openshell"); - // Spy on child_process.spawnSync used by the compiled dist module. - // The dist code does `require("node:child_process").spawnSync`, so - // we spy on the same module that the compiled code loaded. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const cp = require("node:child_process"); - spawnSyncSpy = vi.spyOn(cp, "spawnSync").mockReturnValue({ status: 0 }); - }); - - afterEach(() => { - resolveOpenshellModule.resolveOpenshell = originalResolve; - spawnSyncSpy.mockRestore(); - }); - - it("uses kubectl via the OpenShell gateway container for privileged shutdown", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ status: 0, stdout: "pod/my-sandbox-0\n" }) - .mockReturnValueOnce({ status: 0 }); - - stopSandboxChannels("my-sandbox"); - - expect(spawnSyncSpy).toHaveBeenNthCalledWith( - 1, - "docker", - [ - "exec", - "openshell-cluster-nemoclaw", - "kubectl", - "get", - "pods", - "-n", - "openshell", - "-o", - "name", - ], - expect.objectContaining({ timeout: 10000 }), - ); - expect(spawnSyncSpy).toHaveBeenNthCalledWith( - 2, - "docker", - [ - "exec", - "openshell-cluster-nemoclaw", - "kubectl", - "exec", - "-n", - "openshell", - "-c", - "agent", - "pod/my-sandbox-0", - "--", - "sh", - "-lc", - expect.any(String), - ], - expect.objectContaining({ timeout: 20000 }), - ); - const args = spawnSyncSpy.mock.calls[1][1] as string[]; - const script = args[args.length - 1]; - expect(script).toContain("ps -eo user=,pid=,args="); - expect(script).toContain("openclaw-gateway"); - expect(script).toContain("kill -TERM $pids"); - expect(script).toContain("kill -KILL $remaining"); - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("OpenClaw gateway stopped inside sandbox"); - logSpy.mockRestore(); - }); - - it("falls back to openshell sandbox exec when the gateway container is unavailable", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy.mockReturnValueOnce({ status: 1, stdout: "" }).mockReturnValueOnce({ status: 0 }); - - stopSandboxChannels("my-sandbox"); - - expect(spawnSyncSpy).toHaveBeenNthCalledWith( - 2, - "/usr/local/bin/openshell", - ["sandbox", "exec", "--name", "my-sandbox", "--", "sh", "-lc", expect.any(String)], - expect.objectContaining({ timeout: 20000 }), - ); - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("OpenClaw gateway stopped inside sandbox"); - logSpy.mockRestore(); - }); - - it("uses the generated sandbox pod name for privileged shutdown", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ status: 0, stdout: "pod/app-abc\n" }) - .mockReturnValueOnce({ status: 0 }); - - stopSandboxChannels("app"); - - const args = spawnSyncSpy.mock.calls[1][1] as string[]; - expect(args).toContain("pod/app-abc"); - logSpy.mockRestore(); - }); - - it("does not select overlapping sandbox pod names for privileged shutdown", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ - status: 0, - stdout: "pod/prod-app-abc\npod/app-abc\n", - }) - .mockReturnValueOnce({ status: 0 }); - - stopSandboxChannels("app"); - - const args = spawnSyncSpy.mock.calls[1][1] as string[]; - expect(args).toContain("pod/app-abc"); - expect(args).not.toContain("pod/prod-app-abc"); - logSpy.mockRestore(); - }); - - it("falls back when no exact generated sandbox pod name is available", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ - status: 0, - stdout: "pod/prod-app-abc\npod/app-copy-abc\n", - }) - .mockReturnValueOnce({ status: 0 }); - - stopSandboxChannels("app"); - - expect(spawnSyncSpy).toHaveBeenNthCalledWith( - 2, - "/usr/local/bin/openshell", - ["sandbox", "exec", "--name", "app", "--", "sh", "-lc", expect.any(String)], - expect.objectContaining({ timeout: 20000 }), - ); - logSpy.mockRestore(); - }); - - it("treats stop script exit 1 (no process matched) as already stopped", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ status: 0, stdout: "pod/my-sandbox-0\n" }) - .mockReturnValueOnce({ status: 1 }); - - stopSandboxChannels("my-sandbox"); - - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("OpenClaw gateway was not running inside sandbox"); - logSpy.mockRestore(); - }); - - it("warns when privileged shutdown reports the gateway may still be running", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ status: 0, stdout: "pod/my-sandbox-0\n" }) - .mockReturnValueOnce({ status: 2, stderr: "205" }); - - stopSandboxChannels("my-sandbox"); - - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("Could not stop in-sandbox gateway"); - expect(output).toContain("gateway may still be running"); - expect(output).toContain("205"); - logSpy.mockRestore(); - }); - - it("warns when spawn returns null status (timeout)", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ status: 0, stdout: "pod/my-sandbox-0\n" }) - .mockReturnValueOnce({ status: null }); - - stopSandboxChannels("my-sandbox"); - - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("Could not stop in-sandbox gateway"); - logSpy.mockRestore(); - }); - - it("warns when privileged shutdown is unavailable and openshell is not found", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - resolveOpenshellModule.resolveOpenshell = vi.fn(() => null); - spawnSyncSpy.mockReturnValueOnce({ status: 1, stdout: "" }); - - stopSandboxChannels("my-sandbox"); - - expect(spawnSyncSpy).toHaveBeenCalledTimes(1); - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("openshell not found"); - logSpy.mockRestore(); - }); - - it("uses --name flag for fallback sandbox selection (not positional)", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy.mockReturnValueOnce({ status: 1, stdout: "" }).mockReturnValueOnce({ status: 0 }); - - stopSandboxChannels("my-sandbox"); - - const args = spawnSyncSpy.mock.calls[1][1] as string[]; - expect(args[1]).toBe("exec"); - expect(args[2]).toBe("--name"); - expect(args[3]).toBe("my-sandbox"); - logSpy.mockRestore(); - }); - - it("targets both launcher and re-exec'd gateway process forms", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ status: 0, stdout: "pod/my-sandbox-0\n" }) - .mockReturnValueOnce({ status: 0 }); - - stopSandboxChannels("my-sandbox"); - - const args = spawnSyncSpy.mock.calls[1][1] as string[]; - const script = args[args.length - 1]; - // Must match all three gateway argv forms: the launcher - // ("openclaw gateway run"), the re-exec'd binary ("openclaw-gateway"), - // and the post-startup form where OpenClaw rewrites argv to a bare - // "openclaw" via process.title (#4951). - expect(script).toContain("openclaw-gateway"); - expect(script).toContain("openclaw[[:space:]]+gateway"); - expect(script).toContain("openclaw[[:space:]]*$"); - logSpy.mockRestore(); - }); - - it("rejects malformed sandbox names before spawning docker or openshell", () => { - expect(() => stopSandboxChannels("../escape")).toThrow("Invalid sandbox name"); - expect(spawnSyncSpy).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// stopAll — sandbox channel integration -// --------------------------------------------------------------------------- +function restoreSandboxEnv(saved: Record<(typeof SANDBOX_ENV_NAMES)[number], string | undefined>) { + for (const name of SANDBOX_ENV_NAMES) { + const value = saved[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} describe("stopAll with sandbox channels", () => { let pidDir: string; - let spawnSyncSpy: ReturnType; - let originalResolve: typeof resolveOpenshellModule.resolveOpenshell; + let stopSandboxChannels: ReturnType; + let savedEnv: Record<(typeof SANDBOX_ENV_NAMES)[number], string | undefined>; beforeEach(() => { pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-sandbox-test-")); - originalResolve = resolveOpenshellModule.resolveOpenshell; - resolveOpenshellModule.resolveOpenshell = vi.fn(() => "/usr/local/bin/openshell"); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const cp = require("node:child_process"); - spawnSyncSpy = vi.spyOn(cp, "spawnSync").mockReturnValue({ status: 0 }); + savedEnv = Object.fromEntries( + SANDBOX_ENV_NAMES.map((name) => [name, process.env[name]]), + ) as typeof savedEnv; + for (const name of SANDBOX_ENV_NAMES) delete process.env[name]; + stopSandboxChannels = vi + .spyOn(sandboxGatewayStop, "stopSandboxChannels") + .mockImplementation(() => {}); }); afterEach(() => { rmSync(pidDir, { recursive: true, force: true }); - resolveOpenshellModule.resolveOpenshell = originalResolve; - spawnSyncSpy.mockRestore(); + restoreSandboxEnv(savedEnv); + vi.restoreAllMocks(); }); it("stops in-sandbox channels when sandboxName is provided", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - spawnSyncSpy - .mockReturnValueOnce({ status: 0, stdout: "pod/test-sb-0\n" }) - .mockReturnValueOnce({ status: 0 }); stopAll({ pidDir, sandboxName: "test-sb" }); - expect(spawnSyncSpy).toHaveBeenCalledWith( - "docker", - expect.arrayContaining(["kubectl", "exec", "-n", "openshell", "-c", "agent"]), - expect.any(Object), - ); - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("OpenClaw gateway stopped"); - expect(output).toContain("All services stopped"); - logSpy.mockRestore(); + expect(stopSandboxChannels).toHaveBeenCalledWith("test-sb", { + info: expect.any(Function), + warn: expect.any(Function), + }); + expect(logSpy.mock.calls.map((call) => call[0]).join("\n")).toContain("All services stopped"); }); it("warns when no sandbox name is available", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const savedNemoclaw = process.env.NEMOCLAW_SANDBOX; - const savedNemoclawName = process.env.NEMOCLAW_SANDBOX_NAME; - const savedSandbox = process.env.SANDBOX_NAME; - delete process.env.NEMOCLAW_SANDBOX; - delete process.env.NEMOCLAW_SANDBOX_NAME; - delete process.env.SANDBOX_NAME; - try { - stopAll({ pidDir }); - } finally { - if (savedNemoclaw !== undefined) process.env.NEMOCLAW_SANDBOX = savedNemoclaw; - if (savedNemoclawName !== undefined) process.env.NEMOCLAW_SANDBOX_NAME = savedNemoclawName; - if (savedSandbox !== undefined) process.env.SANDBOX_NAME = savedSandbox; - } + stopAll({ pidDir }); - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(stopSandboxChannels).not.toHaveBeenCalled(); + const output = logSpy.mock.calls.map((call) => call[0]).join("\n"); expect(output).toContain("No sandbox name available"); expect(output).toContain("All services stopped"); - logSpy.mockRestore(); }); - it("still stops cloudflared even when sandbox exec fails", () => { + it("still stops cloudflared when in-sandbox shutdown cannot stop a process", () => { writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); - spawnSyncSpy.mockReturnValue({ status: 255 }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); stopAll({ pidDir, sandboxName: "test-sb" }); - logSpy.mockRestore(); - // cloudflared PID file should be cleaned up regardless + expect(stopSandboxChannels).toHaveBeenCalledTimes(1); expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); }); it("reads sandbox name from NEMOCLAW_SANDBOX env when not in opts", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const saved = process.env.NEMOCLAW_SANDBOX; process.env.NEMOCLAW_SANDBOX = "env-sandbox"; - try { - stopAll({ pidDir }); - } finally { - if (saved !== undefined) { - process.env.NEMOCLAW_SANDBOX = saved; - } else { - delete process.env.NEMOCLAW_SANDBOX; - } - } + stopAll({ pidDir }); - expect(spawnSyncSpy).toHaveBeenCalledWith( - "/usr/local/bin/openshell", - expect.arrayContaining(["env-sandbox"]), - expect.any(Object), - ); - logSpy.mockRestore(); + expect(stopSandboxChannels).toHaveBeenCalledWith("env-sandbox", expect.any(Object)); }); it("reads sandbox name from NEMOCLAW_SANDBOX_NAME when NEMOCLAW_SANDBOX is unset", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const savedNemoclaw = process.env.NEMOCLAW_SANDBOX; - const savedNemoclawName = process.env.NEMOCLAW_SANDBOX_NAME; - delete process.env.NEMOCLAW_SANDBOX; process.env.NEMOCLAW_SANDBOX_NAME = "named-sandbox"; - try { - stopAll({ pidDir }); - } finally { - if (savedNemoclaw !== undefined) { - process.env.NEMOCLAW_SANDBOX = savedNemoclaw; - } else { - delete process.env.NEMOCLAW_SANDBOX; - } - if (savedNemoclawName !== undefined) { - process.env.NEMOCLAW_SANDBOX_NAME = savedNemoclawName; - } else { - delete process.env.NEMOCLAW_SANDBOX_NAME; - } - } + stopAll({ pidDir }); - expect(spawnSyncSpy).toHaveBeenCalledWith( - "/usr/local/bin/openshell", - expect.arrayContaining(["named-sandbox"]), - expect.any(Object), - ); - logSpy.mockRestore(); + expect(stopSandboxChannels).toHaveBeenCalledWith("named-sandbox", expect.any(Object)); }); - it("prefers NEMOCLAW_SANDBOX_NAME over NEMOCLAW_SANDBOX (consistent with resolveDefaultSandboxName)", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const savedNemoclaw = process.env.NEMOCLAW_SANDBOX; - const savedNemoclawName = process.env.NEMOCLAW_SANDBOX_NAME; - const savedSandbox = process.env.SANDBOX_NAME; + it("prefers NEMOCLAW_SANDBOX_NAME over NEMOCLAW_SANDBOX", () => { process.env.NEMOCLAW_SANDBOX_NAME = "name-sandbox"; process.env.NEMOCLAW_SANDBOX = "other-sandbox"; - delete process.env.SANDBOX_NAME; - try { - stopAll({ pidDir }); - } finally { - if (savedNemoclaw !== undefined) process.env.NEMOCLAW_SANDBOX = savedNemoclaw; - else delete process.env.NEMOCLAW_SANDBOX; - if (savedNemoclawName !== undefined) process.env.NEMOCLAW_SANDBOX_NAME = savedNemoclawName; - else delete process.env.NEMOCLAW_SANDBOX_NAME; - if (savedSandbox !== undefined) process.env.SANDBOX_NAME = savedSandbox; - else delete process.env.SANDBOX_NAME; - } + stopAll({ pidDir }); - expect(spawnSyncSpy).toHaveBeenCalledWith( - "/usr/local/bin/openshell", - expect.arrayContaining(["name-sandbox"]), - expect.any(Object), - ); - logSpy.mockRestore(); + expect(stopSandboxChannels).toHaveBeenCalledWith("name-sandbox", expect.any(Object)); }); - it("uses the effective env-selected sandbox for sandbox cleanup with explicit host pidDir", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const savedNemoclaw = process.env.NEMOCLAW_SANDBOX; - const savedNemoclawName = process.env.NEMOCLAW_SANDBOX_NAME; - const savedSandbox = process.env.SANDBOX_NAME; + it("uses the effective env-selected sandbox with an explicit host pidDir", () => { const pidRoot = mkdtempSync(join(tmpdir(), "nemoclaw-services-pid-root-")); const effectivePidDir = join(pidRoot, "nemoclaw-services-name-sandbox"); const lowerPriorityPidDir = join(pidRoot, "nemoclaw-services-other-sandbox"); - rmSync(effectivePidDir, { recursive: true, force: true }); - rmSync(lowerPriorityPidDir, { recursive: true, force: true }); mkdirSync(effectivePidDir, { recursive: true, mode: 0o700 }); mkdirSync(lowerPriorityPidDir, { recursive: true, mode: 0o700 }); writeFileSync(join(effectivePidDir, "cloudflared.pid"), "999999999"); writeFileSync(join(lowerPriorityPidDir, "cloudflared.pid"), "999999999"); process.env.NEMOCLAW_SANDBOX_NAME = "name-sandbox"; process.env.NEMOCLAW_SANDBOX = "other-sandbox"; - delete process.env.SANDBOX_NAME; try { stopAll({ pidDir: effectivePidDir }); - expect(spawnSyncSpy).toHaveBeenCalledWith( - "/usr/local/bin/openshell", - expect.arrayContaining(["name-sandbox"]), - expect.any(Object), - ); + expect(stopSandboxChannels).toHaveBeenCalledWith("name-sandbox", expect.any(Object)); expect(existsSync(join(effectivePidDir, "cloudflared.pid"))).toBe(false); expect(existsSync(join(lowerPriorityPidDir, "cloudflared.pid"))).toBe(true); } finally { - if (savedNemoclaw !== undefined) process.env.NEMOCLAW_SANDBOX = savedNemoclaw; - else delete process.env.NEMOCLAW_SANDBOX; - if (savedNemoclawName !== undefined) process.env.NEMOCLAW_SANDBOX_NAME = savedNemoclawName; - else delete process.env.NEMOCLAW_SANDBOX_NAME; - if (savedSandbox !== undefined) process.env.SANDBOX_NAME = savedSandbox; - else delete process.env.SANDBOX_NAME; rmSync(pidRoot, { recursive: true, force: true }); - logSpy.mockRestore(); } }); - it("rejects malformed env var sandbox names before calling stopSandboxChannels", () => { + it.each([ + "bad name", + "../../etc/passwd", + ])("rejects malformed env sandbox name %j before in-sandbox shutdown", (invalidName) => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const savedNemoclaw = process.env.NEMOCLAW_SANDBOX; - const savedNemoclawName = process.env.NEMOCLAW_SANDBOX_NAME; - const savedSandbox = process.env.SANDBOX_NAME; - delete process.env.NEMOCLAW_SANDBOX; - process.env.NEMOCLAW_SANDBOX_NAME = "bad name"; - delete process.env.SANDBOX_NAME; + process.env.NEMOCLAW_SANDBOX_NAME = invalidName; - try { - stopAll({ pidDir }); - } finally { - if (savedNemoclaw !== undefined) process.env.NEMOCLAW_SANDBOX = savedNemoclaw; - else delete process.env.NEMOCLAW_SANDBOX; - if (savedNemoclawName !== undefined) process.env.NEMOCLAW_SANDBOX_NAME = savedNemoclawName; - else delete process.env.NEMOCLAW_SANDBOX_NAME; - if (savedSandbox !== undefined) process.env.SANDBOX_NAME = savedSandbox; - else delete process.env.SANDBOX_NAME; - } + stopAll({ pidDir }); + + expect(stopSandboxChannels).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.map((call) => call[0]).join("\n")).toContain("Invalid sandbox name"); + }); + + it("keeps host cleanup running for a malformed explicit sandbox name", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const stopAgentForwards = vi + .spyOn(agentForwardStop, "stopAgentForwardPortsForStop") + .mockImplementation(() => {}); + const releaseGateway = vi + .spyOn(gatewayStop, "releaseGatewayPortForStop") + .mockImplementation(() => {}); + writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); + + expect(() => + stopAll({ pidDir, sandboxName: "bad name", releaseGatewayPort: true }), + ).not.toThrow(); - // Should NOT have called openshell sandbox exec with the bad name - expect(spawnSyncSpy).not.toHaveBeenCalled(); - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(stopSandboxChannels).not.toHaveBeenCalled(); + expect(stopAgentForwards).not.toHaveBeenCalled(); + expect(releaseGateway).not.toHaveBeenCalled(); + expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); + const output = logSpy.mock.calls.map((call) => call[0]).join("\n"); expect(output).toContain("Invalid sandbox name"); expect(output).toContain("All services stopped"); - logSpy.mockRestore(); }); - it("rejects path-traversal sandbox names from env vars", () => { + it("does not stop default cloudflared for a malformed sandbox name without an explicit pidDir", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const savedNemoclaw = process.env.NEMOCLAW_SANDBOX; - const savedNemoclawName = process.env.NEMOCLAW_SANDBOX_NAME; - const savedSandbox = process.env.SANDBOX_NAME; - delete process.env.NEMOCLAW_SANDBOX; - process.env.NEMOCLAW_SANDBOX_NAME = "../../etc/passwd"; - delete process.env.SANDBOX_NAME; - try { - stopAll({ pidDir }); - } finally { - if (savedNemoclaw !== undefined) process.env.NEMOCLAW_SANDBOX = savedNemoclaw; - else delete process.env.NEMOCLAW_SANDBOX; - if (savedNemoclawName !== undefined) process.env.NEMOCLAW_SANDBOX_NAME = savedNemoclawName; - else delete process.env.NEMOCLAW_SANDBOX_NAME; - if (savedSandbox !== undefined) process.env.SANDBOX_NAME = savedSandbox; - else delete process.env.SANDBOX_NAME; - } + expect(() => stopAll({ sandboxName: "bad name" })).not.toThrow(); - expect(spawnSyncSpy).not.toHaveBeenCalled(); - const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("Invalid sandbox name"); - logSpy.mockRestore(); + expect(stopSandboxChannels).not.toHaveBeenCalled(); + const output = logSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("Invalid sandbox name without an explicit PID directory"); + expect(output).not.toContain("cloudflared was not running"); + expect(output).toContain("All services stopped"); }); }); diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts index 1de076165c6..27f944fa146 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync, execSync, spawn, spawnSync } from "node:child_process"; +import { execFileSync, execSync, spawn } from "node:child_process"; import { chmodSync, closeSync, @@ -15,18 +15,18 @@ import { writeFileSync, } from "node:fs"; import { basename, join } from "node:path"; -import { dockerSpawnSync } from "../adapters/docker"; -import { resolveOpenshell } from "../adapters/openshell/resolve"; import { renderBox } from "../cli/banner"; import { AGENT_PRODUCT_NAME, CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; import { isRecord } from "../core/json-types"; import { DASHBOARD_PORT } from "../core/ports"; import { buildSubprocessEnv } from "../subprocess-env"; +import * as agentForwardStop from "./agent-forward-stop"; import { registerTunnelOrigin } from "./allowed-origins"; import * as gatewayStop from "./gateway-stop"; -import { GATEWAY_STOP_SCRIPT } from "./gateway-stop-script"; +import * as sandboxGatewayStop from "./sandbox-gateway-stop"; export { GATEWAY_STOP_SCRIPT } from "./gateway-stop-script"; +export { stopSandboxChannels } from "./sandbox-gateway-stop"; // --------------------------------------------------------------------------- // Types @@ -445,125 +445,8 @@ export function showStatus(opts: ServiceOptions = {}): void { } } -/** - * Stop the OpenClaw gateway (and its messaging channels) inside the sandbox. - * - * Uses the OpenShell gateway container's kubectl as the privileged path so it - * can signal the gateway process even when the sandbox SSH/exec user is - * `sandbox` and the gateway process runs as the separate `gateway` user. The - * fallback `openshell sandbox exec` path uses the same verified script for - * older/non-root deployments where the exec user can signal the gateway. - * - * The in-sandbox script intentionally does not rely on a bare `pkill -f` - * result: `pkill -f openclaw[- ]gateway` can match the transient shell/pkill - * command line and report success while the real gateway process survives. - * Instead, it gathers concrete PIDs from `ps`, excludes its own process tree, - * sends TERM/KILL as needed, and only reports success after a post-stop process - * scan is empty. - * - * The matcher must also recognize the bare `openclaw` process name that - * OpenClaw reports after rewriting `process.title`, but that broad argv form is - * accepted only when it matches the recorded gateway PID plus local gateway - * marker. This keeps `tunnel stop` from killing unrelated bare OpenClaw - * processes while still finding the rewritten gateway (#4951). - */ -export function stopSandboxChannels(sandboxName: string): void { - const validatedSandboxName = validateSandboxName(sandboxName); - info(`Stopping in-sandbox OpenClaw gateway (sandbox: ${validatedSandboxName})...`); - - const privilegedResult = stopSandboxChannelsViaKubectl(validatedSandboxName); - if (reportStopResult(privilegedResult)) return; - - const openshell = resolveOpenshell(); - if (!openshell) { - warn("openshell not found — cannot stop in-sandbox messaging channels."); - return; - } - - const fallbackResult = spawnSync( - openshell, - ["sandbox", "exec", "--name", validatedSandboxName, "--", "sh", "-lc", GATEWAY_STOP_SCRIPT], - { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }, - ); - reportStopResult(fallbackResult); -} - -const GATEWAY_CLUSTER_CONTAINER = "openshell-cluster-nemoclaw"; - -type StopAttemptResult = ReturnType; - -function isSandboxPodName(line: string, sandboxName: string): boolean { - if (!line.startsWith("pod/")) return false; - const podName = line.slice("pod/".length); - if (podName === sandboxName) return true; - const prefix = `${sandboxName}-`; - if (!podName.startsWith(prefix)) return false; - const generatedSuffix = podName.slice(prefix.length); - return /^[a-z0-9]+$/.test(generatedSuffix); -} - -function stopSandboxChannelsViaKubectl(sandboxName: string): StopAttemptResult | null { - const podsResult = dockerSpawnSync( - ["exec", GATEWAY_CLUSTER_CONTAINER, "kubectl", "get", "pods", "-n", "openshell", "-o", "name"], - { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000 }, - ); - if (podsResult.status !== 0 || !podsResult.stdout) return null; - - const podOutput = - typeof podsResult.stdout === "string" ? podsResult.stdout : podsResult.stdout.toString(); - const pod = podOutput - .split(/\r?\n/) - .map((line: string) => line.trim()) - .find((line: string) => isSandboxPodName(line, sandboxName)); - if (!pod) return null; - - return dockerSpawnSync( - [ - "exec", - GATEWAY_CLUSTER_CONTAINER, - "kubectl", - "exec", - "-n", - "openshell", - "-c", - "agent", - pod, - "--", - "sh", - "-lc", - GATEWAY_STOP_SCRIPT, - ], - { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }, - ); -} - -function reportStopResult(result: StopAttemptResult | null): boolean { - if (!result) return false; - - if (result.status === 0) { - info("OpenClaw gateway stopped inside sandbox."); - return true; - } - if (result.status === 1) { - info("OpenClaw gateway was not running inside sandbox."); - return true; - } - - const details = [result.stderr, result.stdout] - .map((text) => (typeof text === "string" ? text : text?.toString())) - .filter((text): text is string => Boolean(text?.trim())) - .map((text) => text.trim()) - .join(" "); - warn( - `Could not stop in-sandbox gateway (exit ${String(result.status ?? "unknown")}).` + - " The sandbox may be unreachable or the gateway may still be running." + - (details ? ` Details: ${details}` : ""), - ); - return true; -} - export function stopAll(opts: ServiceOptions = {}): void { - // Stop the in-sandbox OpenClaw gateway (and its messaging channels). + // Resolve the target sandbox once and reuse it for in-sandbox and host-side cleanup. const rawSandboxName = opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX_NAME ?? @@ -577,11 +460,15 @@ export function stopAll(opts: ServiceOptions = {}): void { // Resolve host-side service state from the same effective sandbox selected // for in-sandbox shutdown, so pid cleanup cannot drift to a lower-priority // env var or the default sandbox. - const pidDir = resolvePidDir(sandboxName ? { ...opts, sandboxName } : opts); - ensurePidDir(pidDir); + const pidDir = + opts.pidDir ?? + (rawSandboxName && !sandboxName + ? undefined + : resolvePidDir({ ...opts, sandboxName: sandboxName ?? "default" })); + if (pidDir) ensurePidDir(pidDir); if (sandboxName) { - stopSandboxChannels(sandboxName); + sandboxGatewayStop.stopSandboxChannels(sandboxName, { info, warn }); } else if (rawSandboxName) { warn(`Invalid sandbox name: ${JSON.stringify(rawSandboxName)} — skipping in-sandbox stop.`); } else { @@ -596,10 +483,17 @@ export function stopAll(opts: ServiceOptions = {}): void { /* best-effort */ } - // Stop host-side services. - stopService(pidDir, "cloudflared"); + // Stop host-side services only when their state directory is explicit or + // derived from a trusted sandbox name. An invalid requested sandbox must not + // fall through to the default sandbox's PID directory. + if (pidDir) { + stopService(pidDir, "cloudflared"); + } else { + warn("Invalid sandbox name without an explicit PID directory; skipping host service stop."); + } - if (opts.releaseGatewayPort) { + if (opts.releaseGatewayPort && sandboxName) { + agentForwardStop.stopAgentForwardPortsForStop(sandboxName, { info, warn }); gatewayStop.releaseGatewayPortForStop(sandboxName, { info, warn }); }