diff --git a/src/lib/tunnel/services-sandbox.test.ts b/src/lib/tunnel/services-sandbox.test.ts index acceb006ad8..bebf5a58dc0 100644 --- a/src/lib/tunnel/services-sandbox.test.ts +++ b/src/lib/tunnel/services-sandbox.test.ts @@ -242,6 +242,49 @@ describe("stopAll with sandbox channels", () => { logSpy.mockRestore(); }); + it("releases managed host forwards for the selected sandbox on deprecated full stop", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + spawnSyncSpy + .mockReturnValueOnce({ status: 1, stdout: "" }) + .mockReturnValueOnce({ status: 1 }) + .mockReturnValueOnce({ + status: 0, + stdout: [ + "SANDBOX BIND PORT PID STATUS", + "test-sb 127.0.0.1 8642 1234 running", + "other-sb 127.0.0.1 18789 1235 running", + "test-sb 127.0.0.1 18790 1236 active", + ].join("\n"), + }) + .mockReturnValue({ status: 0 }); + + stopAll({ pidDir, sandboxName: "test-sb", releaseGatewayPort: true }); + + expect(spawnSyncSpy).toHaveBeenCalledWith( + "/usr/local/bin/openshell", + ["forward", "list"], + expect.objectContaining({ timeout: 15_000 }), + ); + expect(spawnSyncSpy).toHaveBeenCalledWith( + "/usr/local/bin/openshell", + ["forward", "stop", "8642", "test-sb"], + expect.objectContaining({ timeout: 30_000 }), + ); + expect(spawnSyncSpy).toHaveBeenCalledWith( + "/usr/local/bin/openshell", + ["forward", "stop", "18790", "test-sb"], + expect.objectContaining({ timeout: 30_000 }), + ); + expect(spawnSyncSpy).not.toHaveBeenCalledWith( + "/usr/local/bin/openshell", + ["forward", "stop", "18789", "test-sb"], + expect.any(Object), + ); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("Released 2 managed host forward(s) for sandbox test-sb"); + logSpy.mockRestore(); + }); + it("warns when no sandbox name is available", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const savedNemoclaw = process.env.NEMOCLAW_SANDBOX; diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts index 3e4dcd20835..37b8c429d11 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -17,10 +17,15 @@ import { import { basename, join } from "node:path"; import { dockerSpawnSync } from "../adapters/docker"; import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { + OPENSHELL_OPERATION_TIMEOUT_MS, + OPENSHELL_PROBE_TIMEOUT_MS, +} from "../adapters/openshell/timeouts"; 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 { getOccupiedPorts } from "../onboard/dashboard-port"; import { buildSubprocessEnv } from "../subprocess-env"; import { registerTunnelOrigin } from "./allowed-origins"; import * as gatewayStop from "./gateway-stop"; @@ -459,7 +464,7 @@ export function showStatus(opts: ServiceOptions = {}): void { * post-stop process scan is empty. */ export function stopSandboxChannels(sandboxName: string): void { - info(`Stopping in-sandbox OpenClaw gateway (sandbox: ${sandboxName})...`); + info(`Stopping in-sandbox ${AGENT_PRODUCT_NAME} gateway (sandbox: ${sandboxName})...`); const privilegedResult = stopSandboxChannelsViaKubectl(sandboxName); if (reportStopResult(privilegedResult)) return; @@ -587,6 +592,39 @@ function reportStopResult(result: StopAttemptResult | null): boolean { return true; } +function stopSandboxForwards(sandboxName: string): void { + const openshell = resolveOpenshell(); + if (!openshell) { + warn("openshell not found — cannot release managed host forwards."); + return; + } + + const listResult = spawnSync(openshell, ["forward", "list"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + if (listResult.status !== 0) { + warn("Could not list OpenShell forwards — managed host forwards may still be running."); + return; + } + + const output = listResult.stdout ?? ""; + const ports = [...getOccupiedPorts(output).entries()] + .filter(([, owner]) => owner === sandboxName) + .map(([port]) => port); + for (const port of ports) { + spawnSync(openshell, ["forward", "stop", port, sandboxName], { + encoding: "utf-8", + stdio: "ignore", + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + } + if (ports.length > 0) { + info(`Released ${String(ports.length)} managed host forward(s) for sandbox ${sandboxName}.`); + } +} + export function stopAll(opts: ServiceOptions = {}): void { // Stop the in-sandbox OpenClaw gateway (and its messaging channels). const rawSandboxName = @@ -625,6 +663,9 @@ export function stopAll(opts: ServiceOptions = {}): void { stopService(pidDir, "cloudflared"); if (opts.releaseGatewayPort) { + if (sandboxName) { + stopSandboxForwards(sandboxName); + } gatewayStop.releaseGatewayPortForStop(sandboxName, { info, warn }); }