diff --git a/src/lib/inventory-commands.ts b/src/lib/inventory-commands.ts index 3a0d43a27a0..a04a034231f 100644 --- a/src/lib/inventory-commands.ts +++ b/src/lib/inventory-commands.ts @@ -29,6 +29,8 @@ export interface ListSandboxesCommandDeps { recoverRegistryEntries: () => Promise; getLiveInference: () => GatewayInference | null; loadLastSession: () => { sandboxName?: string | null } | null; + /** Detect active SSH sessions for a sandbox. Returns session count or null if unavailable. */ + getActiveSessionCount?: (sandboxName: string) => number | null; log?: (message?: string) => void; } @@ -92,7 +94,9 @@ export async function listSandboxesCommand(deps: ListSandboxesCommandDeps): Prom const provider = sb.provider || "unknown"; const gpu = sb.gpuEnabled ? "GPU" : "CPU"; const presets = sb.policies && sb.policies.length > 0 ? sb.policies.join(", ") : "none"; - log(` ${sb.name}${def}`); + const sessionCount = deps.getActiveSessionCount ? deps.getActiveSessionCount(sb.name) : null; + const connected = sessionCount !== null && sessionCount > 0 ? " ●" : ""; + log(` ${sb.name}${def}${connected}`); log(` model: ${model} provider: ${provider} ${gpu} policies: ${presets}`); } log(""); diff --git a/src/lib/sandbox-session-state.test.ts b/src/lib/sandbox-session-state.test.ts new file mode 100644 index 00000000000..56860a91d33 --- /dev/null +++ b/src/lib/sandbox-session-state.test.ts @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { + parseForwardList, + parseSshProcesses, + hasActiveForwards, + getForwardsForSandbox, + classifySessionState, + getActiveSandboxSessions, + type ForwardEntry, + type SessionClassification, + type SessionDetectionDeps, +} from "./sandbox-session-state"; + +describe("parseForwardList", () => { + it("returns empty array for empty/null input", () => { + expect(parseForwardList("")).toEqual([]); + expect(parseForwardList(null as unknown as string)).toEqual([]); + expect(parseForwardList(undefined as unknown as string)).toEqual([]); + }); + + it("skips header row", () => { + const output = "SANDBOX BIND PORT PID STATUS\n"; + expect(parseForwardList(output)).toEqual([]); + }); + + it("parses single forward entry", () => { + const output = `SANDBOX BIND PORT PID STATUS +my-sandbox 127.0.0.1 18789 12345 running`; + const entries = parseForwardList(output); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ + sandboxName: "my-sandbox", + bind: "127.0.0.1", + port: "18789", + pid: 12345, + status: "running", + }); + }); + + it("parses multiple forward entries", () => { + const output = `SANDBOX BIND PORT PID STATUS +sandbox-1 127.0.0.1 18789 100 running +sandbox-2 127.0.0.1 18790 200 running +sandbox-1 127.0.0.1 11434 101 stopped`; + const entries = parseForwardList(output); + expect(entries).toHaveLength(3); + expect(entries[0].sandboxName).toBe("sandbox-1"); + expect(entries[1].sandboxName).toBe("sandbox-2"); + expect(entries[2].status).toBe("stopped"); + }); + + it("handles missing PID gracefully", () => { + const output = "my-sandbox 127.0.0.1 18789 - running"; + const entries = parseForwardList(output); + expect(entries).toHaveLength(1); + expect(entries[0].pid).toBeNull(); + }); + + it("handles lines with insufficient columns", () => { + const output = "incomplete line\nmy-sandbox 127.0.0.1 18789 999 running"; + const entries = parseForwardList(output); + expect(entries).toHaveLength(1); + expect(entries[0].sandboxName).toBe("my-sandbox"); + }); +}); + +describe("parseSshProcesses", () => { + it("returns empty array for empty input", () => { + expect(parseSshProcesses("", "my-sandbox")).toEqual([]); + expect(parseSshProcesses(null as unknown as string, "my-sandbox")).toEqual([]); + }); + + it("returns empty array for empty sandbox name", () => { + expect(parseSshProcesses("12345 ssh openshell-test", "")).toEqual([]); + }); + + it("detects SSH process targeting sandbox", () => { + const output = `12345 ssh -F /tmp/config openshell-my-sandbox +67890 ssh -F /tmp/config openshell-other-sandbox`; + const sessions = parseSshProcesses(output, "my-sandbox"); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toEqual({ + sandboxName: "my-sandbox", + pid: 12345, + sshHost: "openshell-my-sandbox", + }); + }); + + it("detects multiple SSH sessions to the same sandbox", () => { + const output = `111 ssh -F /tmp/a.conf openshell-dev +222 ssh -F /tmp/b.conf openshell-dev +333 ssh -F /tmp/c.conf openshell-prod`; + const sessions = parseSshProcesses(output, "dev"); + expect(sessions).toHaveLength(2); + expect(sessions.map((s) => s.pid)).toEqual([111, 222]); + }); + + it("ignores unrelated SSH processes", () => { + const output = `100 ssh user@remote-host +200 ssh -F config openshell-my-sandbox +300 /usr/bin/ssh-agent`; + const sessions = parseSshProcesses(output, "my-sandbox"); + expect(sessions).toHaveLength(1); + expect(sessions[0].pid).toBe(200); + }); + + it("does not match partial sandbox name prefixes", () => { + // openshell-my-sandbox-extended should NOT match openshell-my-sandbox + const output = `100 ssh -F /tmp/cfg openshell-my-sandbox-extended`; + const sessions = parseSshProcesses(output, "my-sandbox"); + // Word-boundary matching ensures `openshell-my-sandbox` does not match + // inside `openshell-my-sandbox-extended`. + expect(sessions).toHaveLength(0); + }); + + it("matches sandbox name at end of line", () => { + const output = `100 ssh -F /tmp/cfg openshell-my-sandbox`; + const sessions = parseSshProcesses(output, "my-sandbox"); + expect(sessions).toHaveLength(1); + expect(sessions[0].pid).toBe(100); + }); + + it("matches sandbox name followed by whitespace", () => { + const output = `100 ssh -F /tmp/cfg -o StrictHostKeyChecking=no openshell-dev -t bash`; + const sessions = parseSshProcesses(output, "dev"); + expect(sessions).toHaveLength(1); + }); +}); + +describe("hasActiveForwards", () => { + const entries: ForwardEntry[] = [ + { sandboxName: "dev", bind: "127.0.0.1", port: "18789", pid: 100, status: "running" }, + { sandboxName: "prod", bind: "127.0.0.1", port: "18790", pid: 200, status: "stopped" }, + ]; + + it("returns true when sandbox has running forwards", () => { + expect(hasActiveForwards(entries, "dev")).toBe(true); + }); + + it("returns false when sandbox has only stopped forwards", () => { + expect(hasActiveForwards(entries, "prod")).toBe(false); + }); + + it("returns false for unknown sandbox", () => { + expect(hasActiveForwards(entries, "unknown")).toBe(false); + }); +}); + +describe("getForwardsForSandbox", () => { + const entries: ForwardEntry[] = [ + { sandboxName: "dev", bind: "127.0.0.1", port: "18789", pid: 100, status: "running" }, + { sandboxName: "dev", bind: "127.0.0.1", port: "11434", pid: 101, status: "running" }, + { sandboxName: "prod", bind: "127.0.0.1", port: "18790", pid: 200, status: "running" }, + ]; + + it("filters entries for specific sandbox", () => { + const result = getForwardsForSandbox(entries, "dev"); + expect(result).toHaveLength(2); + expect(result.every((e) => e.sandboxName === "dev")).toBe(true); + }); + + it("returns empty for unknown sandbox", () => { + expect(getForwardsForSandbox(entries, "unknown")).toEqual([]); + }); +}); + +describe("classifySessionState", () => { + it("detects active sessions from SSH processes", () => { + const forwards: ForwardEntry[] = []; + const sessions = [{ sandboxName: "dev", pid: 100, sshHost: "openshell-dev" }]; + const result = classifySessionState(forwards, sessions, "dev"); + expect(result.hasActiveSessions).toBe(true); + expect(result.sessionCount).toBe(1); + expect(result.forwardCount).toBe(0); + expect(result.sources).toContain("ssh"); + }); + + it("forward-only does not count as active SSH session", () => { + const forwards: ForwardEntry[] = [ + { sandboxName: "dev", bind: "127.0.0.1", port: "18789", pid: 100, status: "running" }, + ]; + const sessions: { sandboxName: string; pid: number; sshHost: string }[] = []; + const result = classifySessionState(forwards, sessions, "dev"); + expect(result.hasActiveSessions).toBe(false); + expect(result.forwardCount).toBe(1); + expect(result.sources).toContain("forward"); + expect(result.sources).not.toContain("ssh"); + }); + + it("reports both sources when present", () => { + const forwards: ForwardEntry[] = [ + { sandboxName: "dev", bind: "127.0.0.1", port: "18789", pid: 100, status: "running" }, + ]; + const sessions = [{ sandboxName: "dev", pid: 200, sshHost: "openshell-dev" }]; + const result = classifySessionState(forwards, sessions, "dev"); + expect(result.hasActiveSessions).toBe(true); + expect(result.sessionCount).toBe(1); + expect(result.forwardCount).toBe(1); + expect(result.sources).toContain("forward"); + expect(result.sources).toContain("ssh"); + }); + + it("ignores sessions for other sandboxes", () => { + const forwards: ForwardEntry[] = []; + const sessions = [{ sandboxName: "prod", pid: 100, sshHost: "openshell-prod" }]; + const result = classifySessionState(forwards, sessions, "dev"); + expect(result.hasActiveSessions).toBe(false); + expect(result.sessionCount).toBe(0); + expect(result.forwardCount).toBe(0); + }); + + it("counts multiple sessions", () => { + const forwards: ForwardEntry[] = []; + const sessions = [ + { sandboxName: "dev", pid: 100, sshHost: "openshell-dev" }, + { sandboxName: "dev", pid: 200, sshHost: "openshell-dev" }, + ]; + const result = classifySessionState(forwards, sessions, "dev"); + expect(result.hasActiveSessions).toBe(true); + expect(result.sessionCount).toBe(2); + expect(result.forwardCount).toBe(0); + }); +}); + +describe("getActiveSandboxSessions", () => { + it("returns detected=false when no deps available", () => { + const deps: SessionDetectionDeps = { + getForwardList: () => null, + getSshProcesses: () => null, + }; + const result = getActiveSandboxSessions("dev", deps); + expect(result.detected).toBe(false); + expect(result.sessions).toEqual([]); + }); + + it("returns detected=false for empty sandbox name", () => { + const deps: SessionDetectionDeps = { + getForwardList: () => "some output", + getSshProcesses: () => "some output", + }; + const result = getActiveSandboxSessions("", deps); + expect(result.detected).toBe(false); + }); + + it("detects sessions from pgrep output", () => { + const deps: SessionDetectionDeps = { + getForwardList: () => "", + getSshProcesses: () => "12345 ssh -F /tmp/cfg openshell-my-sandbox\n", + }; + const result = getActiveSandboxSessions("my-sandbox", deps); + expect(result.detected).toBe(true); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].pid).toBe(12345); + }); + + it("returns detected=false when pgrep unavailable (forward list alone insufficient)", () => { + const deps: SessionDetectionDeps = { + getForwardList: () => + "SANDBOX BIND PORT PID STATUS\nmy-sandbox 127.0.0.1 18789 999 running\n", + getSshProcesses: () => null, + }; + const result = getActiveSandboxSessions("my-sandbox", deps); + // SSH process detection is the authoritative source; forward list alone + // cannot determine interactive sessions (dashboard forward always runs). + expect(result.detected).toBe(false); + expect(result.sessions).toEqual([]); + }); + + it("integrates both sources", () => { + const deps: SessionDetectionDeps = { + getForwardList: () => + "SANDBOX BIND PORT PID STATUS\ndev 127.0.0.1 18789 100 running\n", + getSshProcesses: () => "200 ssh -F /tmp/cfg openshell-dev\n", + }; + const result = getActiveSandboxSessions("dev", deps); + expect(result.detected).toBe(true); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].pid).toBe(200); + }); +}); diff --git a/src/lib/sandbox-session-state.ts b/src/lib/sandbox-session-state.ts new file mode 100644 index 00000000000..63db9700fb1 --- /dev/null +++ b/src/lib/sandbox-session-state.ts @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Active sandbox session detection. + * + * Provides typed, testable utilities for detecting active SSH connections + * to OpenShell sandboxes. Used by destructive operations (destroy, rebuild, + * stop) to warn users before terminating sessions, and by informational + * commands (status, list, connect) to show connection state. + * + * Design follows gateway-state.ts pattern: pure classifiers that parse + * CLI output are separated from the I/O layer that invokes those commands. + */ + +import { spawnSync } from "node:child_process"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single detected SSH session to a sandbox. */ +export interface SandboxSession { + /** The sandbox name this session connects to. */ + sandboxName: string; + /** PID of the SSH process on the host. */ + pid: number; + /** SSH target host (typically openshell-). */ + sshHost: string; +} + +/** Result of detecting active sessions for a sandbox. */ +export interface ActiveSessionsResult { + /** Whether detection was able to run (false if tools unavailable). */ + detected: boolean; + /** Active sessions found for the requested sandbox. */ + sessions: SandboxSession[]; +} + +/** A forward entry parsed from `openshell forward list` output. */ +export interface ForwardEntry { + /** Sandbox name owning the forward. */ + sandboxName: string; + /** Bind address (e.g., "127.0.0.1"). */ + bind: string; + /** Port number being forwarded. */ + port: string; + /** PID of the forwarding process (null if not parseable). */ + pid: number | null; + /** Status string (e.g., "running", "stopped"). */ + status: string; +} + +// --------------------------------------------------------------------------- +// Pure classifiers — parse CLI output, no I/O +// --------------------------------------------------------------------------- + +/** + * Parse `openshell forward list` output into structured forward entries. + * + * Output format (columns separated by whitespace): + * SANDBOX BIND PORT PID STATUS + * + * The first line may be a header row — we skip lines where "SANDBOX" appears + * literally in the first column. + */ +export function parseForwardList(output: string): ForwardEntry[] { + if (!output || typeof output !== "string") return []; + + const entries: ForwardEntry[] = []; + const lines = output.split("\n").map((l) => l.trim()).filter(Boolean); + + for (const line of lines) { + // Skip header row + if (/^\s*SANDBOX\s/i.test(line)) continue; + + const parts = line.split(/\s+/); + if (parts.length < 4) continue; + + const [sandboxName, bind, port, pidStr, ...rest] = parts; + const pid = /^\d+$/.test(pidStr) ? Number.parseInt(pidStr, 10) : null; + const status = rest.join(" ").toLowerCase() || "unknown"; + + entries.push({ sandboxName, bind, port, pid, status }); + } + + return entries; +} + +/** + * Parse process list output to find SSH processes targeting a specific sandbox. + * + * SSH connections to sandboxes use the host pattern `openshell-`. + * We match the full SSH host as a complete word to avoid false positives when + * one sandbox name is a prefix of another (e.g., `dev` vs `dev-staging`). + * + * Input format: one line per process — ` ` + * (compatible with both `pgrep -a` on Linux and `ps -axo pid,command`) + */ +export function parseSshProcesses(pgrepOutput: string, sandboxName: string): SandboxSession[] { + if (!pgrepOutput || typeof pgrepOutput !== "string") return []; + if (!sandboxName) return []; + + const sshHost = `openshell-${sandboxName}`; + // Match sshHost as a complete word — preceded by whitespace/start and followed + // by whitespace/end. This prevents `openshell-dev` from matching inside + // `openshell-dev-staging`. + const hostPattern = new RegExp(`(?:^|\\s)${escapeRegExp(sshHost)}(?:\\s|$)`); + const sessions: SandboxSession[] = []; + const lines = pgrepOutput.split("\n").filter(Boolean); + + for (const line of lines) { + const pidMatch = line.match(/^\s*(\d+)\s+(.+)/); + if (!pidMatch) continue; + + const pid = Number.parseInt(pidMatch[1], 10); + const cmdline = pidMatch[2]; + + if (hostPattern.test(cmdline)) { + sessions.push({ sandboxName, pid, sshHost }); + } + } + + return sessions; +} + +/** Escape special regex characters in a string for safe use in RegExp. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Check if a sandbox has active forwards from parsed forward entries. + * Active forwards (status includes "running") indicate an active connection. + */ +export function hasActiveForwards(entries: ForwardEntry[], sandboxName: string): boolean { + return entries.some( + (e) => e.sandboxName === sandboxName && e.status.includes("running"), + ); +} + +/** + * Get forward entries for a specific sandbox. + */ +export function getForwardsForSandbox(entries: ForwardEntry[], sandboxName: string): ForwardEntry[] { + return entries.filter((e) => e.sandboxName === sandboxName); +} + +/** Classification result from combining forward and SSH session evidence. */ +export interface SessionClassification { + /** Whether interactive SSH sessions are active (authoritative indicator). */ + hasActiveSessions: boolean; + /** Number of active SSH sessions. */ + sessionCount: number; + /** Number of running port forwards for this sandbox. */ + forwardCount: number; + /** Which detection sources contributed evidence (e.g., ["forward", "ssh"]). */ + sources: string[]; +} + +/** + * Determine whether there are active SSH sessions for a sandbox from both + * forward list and process detection. + * + * Combines evidence from forward entries and SSH processes. Either source + * alone is sufficient to detect an active session — forwards may exist + * without an interactive SSH session (e.g., port-forward only), and SSH + * sessions may exist without a tracked forward (e.g., manual SSH). + */ +export function classifySessionState( + forwardEntries: ForwardEntry[], + sshSessions: SandboxSession[], + sandboxName: string, +): SessionClassification { + const sources: string[] = []; + + const activeForwards = getForwardsForSandbox(forwardEntries, sandboxName).filter( + (e) => e.status.includes("running"), + ); + if (activeForwards.length > 0) { + sources.push("forward"); + } + + const matchingSessions = sshSessions.filter((s) => s.sandboxName === sandboxName); + if (matchingSessions.length > 0) { + sources.push("ssh"); + } + + // SSH sessions are the authoritative indicator of interactive connections. + // Forwards alone don't necessarily mean interactive use (dashboard forward). + const sessionCount = matchingSessions.length; + const hasActiveSessions = sessionCount > 0; + + return { hasActiveSessions, sessionCount, forwardCount: activeForwards.length, sources }; +} + +// --------------------------------------------------------------------------- +// I/O layer — invokes system commands to gather raw output +// --------------------------------------------------------------------------- + +export interface SessionDetectionDeps { + /** Run `openshell forward list` and return stdout. Null if unavailable. */ + getForwardList: () => string | null; + /** Run `pgrep -a ssh` and return stdout. Null if unavailable. */ + getSshProcesses: () => string | null; +} + +/** + * Detect active SSH sessions for a named sandbox. + * + * This is the high-level entry point used by consumers (destroy, rebuild, etc.). + * It invokes system commands through the deps interface for testability. + * + * Detection relies on `pgrep -a ssh` to find SSH processes targeting the + * sandbox's SSH host. The `getForwardList` dep is not used here (forward + * activity alone doesn't indicate interactive sessions — the dashboard + * forward is always running). Consumers that need forward state can call + * `parseForwardList` + `classifySessionState` directly. + */ +export function getActiveSandboxSessions( + sandboxName: string, + deps: SessionDetectionDeps, +): ActiveSessionsResult { + if (!sandboxName) { + return { detected: false, sessions: [] }; + } + + const pgrepOutput = deps.getSshProcesses(); + + if (pgrepOutput === null) { + return { detected: false, sessions: [] }; + } + + const sshSessions = parseSshProcesses(pgrepOutput, sandboxName); + + return { + detected: true, + sessions: sshSessions, + }; +} + +/** + * Query SSH processes using `ps` (portable across macOS and Linux). + * + * `pgrep -a` on macOS only prints PIDs (no command line), making it useless + * for matching SSH target hosts. `ps -axo pid,command` works on both platforms + * and returns full command lines in pgrep-compatible format (`PID COMMAND`). + */ +function querySshProcesses(): string | null { + try { + const result = spawnSync("ps", ["-axo", "pid,command"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5000, + }); + if (result.status !== 0) return null; + // Filter to only SSH lines to reduce noise and match pgrep -a output format + const lines = (result.stdout || "") + .split("\n") + .filter((line) => /\bssh\b/.test(line)) + .join("\n"); + return lines; + } catch { + return null; + } +} + +/** + * Create the default system deps for session detection. + * Uses `openshell forward list` and `ps` (cross-platform) on the host. + */ +export function createSystemDeps(openshellBinary: string): SessionDetectionDeps { + return { + getForwardList: (): string | null => { + try { + const result = spawnSync(openshellBinary, ["forward", "list"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5000, + }); + if (result.status !== 0) return null; + return result.stdout || ""; + } catch { + return null; + } + }, + getSshProcesses: querySshProcesses, + }; +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 9b3cd89a715..76470eeabe8 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -68,6 +68,10 @@ const sandboxState = require("./lib/sandbox-state"); const { ensureOllamaAuthProxy } = require("./lib/onboard"); const skillInstall = require("./lib/skill-install"); const { parseSandboxPhase } = require("./lib/gateway-state"); +const { + getActiveSandboxSessions, + createSystemDeps: createSessionDeps, +} = require("./lib/sandbox-session-state"); // ── Global commands ────────────────────────────────────────────── @@ -1152,11 +1156,36 @@ function showStatus() { } async function listSandboxes() { + const opsBinList = resolveOpenshell(); + const sessionDeps = opsBinList ? createSessionDeps(opsBinList) : null; + + // Cache the SSH process probe once for all sandboxes — avoids spawning ps + // per sandbox row. The getSshProcesses() call is the expensive part (5s timeout). + let cachedSshOutput: string | null | undefined; + const getCachedSshOutput = () => { + if (cachedSshOutput === undefined && sessionDeps) { + cachedSshOutput = sessionDeps.getSshProcesses(); + } + return cachedSshOutput ?? null; + }; + await listSandboxesCommand({ recoverRegistryEntries: () => recoverRegistryEntries(), getLiveInference: () => parseGatewayInference(captureOpenshell(["inference", "get"], { ignoreError: true }).output), loadLastSession: () => onboardSession.loadSession(), + getActiveSessionCount: sessionDeps + ? (name) => { + try { + const sshOutput = getCachedSshOutput(); + if (sshOutput === null) return null; + const { parseSshProcesses } = require("./lib/sandbox-session-state"); + return parseSshProcesses(sshOutput, name).length; + } catch { + return null; + } + } + : undefined, log: console.log, }); } @@ -1178,6 +1207,22 @@ async function sandboxConnect(sandboxName, { dangerouslySkipPermissions = false /* non-fatal — don't block connect on version check failure */ } + // Active session hint — inform if already connected in another terminal + try { + const opsBinConnect = resolveOpenshell(); + if (opsBinConnect) { + const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBinConnect)); + if (sessionResult.detected && sessionResult.sessions.length > 0) { + const count = sessionResult.sessions.length; + console.log( + ` ${D}Note: ${count} existing SSH session${count > 1 ? "s" : ""} to '${sandboxName}' detected (another terminal).${R}`, + ); + } + } + } catch { + /* non-fatal — don't block connect on session detection failure */ + } + // Check both the CLI flag and the registry for dangerously-skip-permissions. // The registry flag persists from onboard, so subsequent connects without // the CLI flag still enter permanent shields-down state. @@ -1270,6 +1315,21 @@ async function sandboxStatus(sandboxName) { } console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`); console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); + + // Active session indicator + try { + const opsBinStatus = resolveOpenshell(); + if (opsBinStatus) { + const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBinStatus)); + if (sessionResult.detected) { + const count = sessionResult.sessions.length; + console.log(` Connected: ${count > 0 ? `${G}yes${R} (${count} session${count > 1 ? "s" : ""})` : "no"}`); + } + } + } catch { + /* non-fatal */ + } + if (sb.dangerouslySkipPermissions) { console.log(` Permissions: dangerously-skip-permissions (shields permanently down)`); } else if (shields.isShieldsDown(sandboxName)) { @@ -1727,8 +1787,28 @@ function cleanupSandboxServices(sandboxName, { stopHostServices = false } = {}) async function sandboxDestroy(sandboxName, args = []) { const skipConfirm = args.includes("--yes") || args.includes("--force"); + + // Active session detection — enrich the confirmation prompt if sessions are active + let activeSessionCount = 0; + const opsBin = resolveOpenshell(); + if (opsBin) { + try { + const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBin)); + if (sessionResult.detected) { + activeSessionCount = sessionResult.sessions.length; + } + } catch { + /* non-fatal */ + } + } + if (!skipConfirm) { console.log(` ${YW}Destroy sandbox '${sandboxName}'?${R}`); + if (activeSessionCount > 0) { + const plural = activeSessionCount > 1 ? "sessions" : "session"; + console.log(` ${YW}⚠ Active SSH ${plural} detected (${activeSessionCount} connection${activeSessionCount > 1 ? "s" : ""})${R}`); + console.log(` Destroying will terminate ${activeSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`); + } console.log(" This will permanently delete the sandbox and all workspace files inside it."); console.log(" This cannot be undone."); const answer = await askPrompt(" Type 'yes' to confirm, or press Enter to cancel [y/N]: "); @@ -1807,6 +1887,21 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { throw new Error(msg); } : (_msg, code = 1) => process.exit(code); + + // Active session detection — enrich the confirmation prompt if sessions are active + let rebuildActiveSessionCount = 0; + const opsBinRebuild = resolveOpenshell(); + if (opsBinRebuild) { + try { + const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBinRebuild)); + if (sessionResult.detected) { + rebuildActiveSessionCount = sessionResult.sessions.length; + } + } catch { + /* non-fatal */ + } + } + const sb = registry.getSandbox(sandboxName); if (!sb) { console.error(` Sandbox '${sandboxName}' not found in registry.`); @@ -1838,6 +1933,12 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { console.log(""); if (!skipConfirm) { + if (rebuildActiveSessionCount > 0) { + const plural = rebuildActiveSessionCount > 1 ? "sessions" : "session"; + console.log(` ${YW}⚠ Active SSH ${plural} detected (${rebuildActiveSessionCount} connection${rebuildActiveSessionCount > 1 ? "s" : ""})${R}`); + console.log(` Rebuilding will terminate ${rebuildActiveSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`); + console.log(""); + } console.log(" This will:"); console.log(" 1. Back up workspace state"); console.log(" 2. Destroy and recreate the sandbox with the current image");