Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/lib/inventory-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ListSandboxesCommandDeps {
recoverRegistryEntries: () => Promise<RecoveryResult>;
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;
}

Expand Down Expand Up @@ -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("");
Expand Down
265 changes: 265 additions & 0 deletions src/lib/sandbox-session-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// 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 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");
// The current implementation does substring match — this is intentional:
// `openshell-my-sandbox` is a substring of `openshell-my-sandbox-extended`.
// In practice, sandbox names are unique and don't overlap as prefixes.
// If this becomes an issue, we can add word-boundary matching later.
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.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.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.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);
});

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);
});
});

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("works with forward list only (no pgrep)", () => {
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);
expect(result.detected).toBe(true);
// Forward-only doesn't produce sessions (need SSH process)
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);
});
});
Loading
Loading