diff --git a/package.json b/package.json index 4d440da..41277cb 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "start": "bun run src/cli.ts", - "test": "bun test", + "test": "bun test ./src/ ./tests/", "typecheck": "tsc --noEmit", "lint": "biome check .", "lint:fix": "biome check --write .", diff --git a/src/cli/exec.ts b/src/cli/exec.ts index 8c39383..b61543c 100644 --- a/src/cli/exec.ts +++ b/src/cli/exec.ts @@ -1,6 +1,5 @@ import type { ParseArgsOptionsConfig } from "node:util"; import { parseArgs } from "node:util"; -import { SANDBOX_PREFIX } from "../sandbox/constants"; import { getSandboxState, execCmd as runExec } from "../sandbox/container"; import { printCmdHelp } from "./_help"; import { resolveSessionName } from "./_resolve"; @@ -28,8 +27,7 @@ export async function execCmd(args: string[]): Promise { } const name = await resolveSessionName(positionals[0], "exec into"); if (!name) return 1; - const containerName = `${SANDBOX_PREFIX}${name}`; - const state = await getSandboxState(containerName); + const state = await getSandboxState(name); if (state === "missing") { console.error(`session ${name} has no container`); return 1; diff --git a/src/cli/shell.ts b/src/cli/shell.ts index 9b46a6a..7890768 100644 --- a/src/cli/shell.ts +++ b/src/cli/shell.ts @@ -1,6 +1,5 @@ import type { ParseArgsOptionsConfig } from "node:util"; import { parseArgs } from "node:util"; -import { SANDBOX_PREFIX } from "../sandbox/constants"; import { execBash, getSandboxState } from "../sandbox/container"; import { printCmdHelp } from "./_help"; import { resolveSessionName } from "./_resolve"; @@ -21,8 +20,7 @@ export async function shellCmd(args: string[]): Promise { } const name = await resolveSessionName(positionals[0], "shell into"); if (!name) return 1; - const containerName = `${SANDBOX_PREFIX}${name}`; - const state = await getSandboxState(containerName); + const state = await getSandboxState(name); if (state === "missing") { console.error(`session ${name} has no container`); return 1; diff --git a/src/sandbox/constants.ts b/src/sandbox/constants.ts deleted file mode 100644 index 854011c..0000000 --- a/src/sandbox/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SANDBOX_PREFIX = "openshell-sandbox-"; diff --git a/src/sandbox/container.test.ts b/src/sandbox/container.test.ts index d1b7bb0..3f2c78a 100644 --- a/src/sandbox/container.test.ts +++ b/src/sandbox/container.test.ts @@ -9,7 +9,10 @@ import { buildSandboxExecRootArgv, buildSandboxGetArgv, buildSandboxListNamesArgv, + buildSandboxStartArgv, + buildSandboxStopArgv, buildSandboxUploadArgv, + parseSandboxGetPhase, wrapCmdWithEnv, } from "./container"; @@ -188,15 +191,8 @@ describe("buildHarnessExecArgv (harness binary selection)", () => { }); describe("buildSandboxGetArgv", () => { - it("emits `openshell sandbox get -o json`", () => { - expect(buildSandboxGetArgv(["cli"], "sess")).toEqual([ - "cli", - "sandbox", - "get", - "sess", - "-o", - "json", - ]); + it("emits `openshell sandbox get `", () => { + expect(buildSandboxGetArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "get", "sess"]); }); it("supports a multi-element cli prefix", () => { @@ -208,18 +204,54 @@ describe("buildSandboxGetArgv", () => { "sandbox", "get", "sess", - "-o", - "json", ]); }); }); +describe("parseSandboxGetPhase", () => { + // openshell sandbox get prints colorized human output, e.g. + // \x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m + // \x1b[2mPhase:\x1b[0m Ready + // The parser must strip ANSI escapes and locate the `Phase: ` line. + it("returns 'running' for Ready/Running phase", () => { + expect(parseSandboxGetPhase(" Phase: Ready\n Revision: 1")).toBe("running"); + expect(parseSandboxGetPhase(" Phase: Running")).toBe("running"); + }); + it("returns 'exited' for Failed/Exited/Stopped/Error phase", () => { + expect(parseSandboxGetPhase(" Phase: Failed")).toBe("exited"); + expect(parseSandboxGetPhase(" Phase: Exited")).toBe("exited"); + expect(parseSandboxGetPhase(" Phase: Stopped")).toBe("exited"); + // Fork v0.5.0 has no Stopped variant — explicit stop transitions Ready → Error. + expect(parseSandboxGetPhase(" Phase: Error")).toBe("exited"); + }); + it("returns 'other' when phase is unknown or missing", () => { + expect(parseSandboxGetPhase(" Phase: Provisioning")).toBe("other"); + expect(parseSandboxGetPhase("no phase here")).toBe("other"); + }); + it("strips ANSI color codes around the Phase label and value", () => { + const ansi = "\x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m\n\n \x1b[2mPhase:\x1b[0m Ready\n"; + expect(parseSandboxGetPhase(ansi)).toBe("running"); + }); +}); + describe("buildSandboxDeleteArgv", () => { it("emits `openshell sandbox delete `", () => { expect(buildSandboxDeleteArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "delete", "sess"]); }); }); +describe("buildSandboxStopArgv", () => { + it("emits `openshell sandbox stop `", () => { + expect(buildSandboxStopArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "stop", "sess"]); + }); +}); + +describe("buildSandboxStartArgv", () => { + it("emits `openshell sandbox start `", () => { + expect(buildSandboxStartArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "start", "sess"]); + }); +}); + describe("buildSandboxUploadArgv", () => { it("emits `openshell sandbox upload `", () => { expect(buildSandboxUploadArgv(["cli"], "sess", "/host/file", "/sbx/dir")).toEqual([ diff --git a/src/sandbox/container.ts b/src/sandbox/container.ts index f82d6e8..0a05eed 100644 --- a/src/sandbox/container.ts +++ b/src/sandbox/container.ts @@ -230,14 +230,25 @@ export async function waitForSandboxReady(name: string, timeoutMs = 60_000): Pro // up on `sandbox delete`. // ============================================================================ +// `openshell sandbox get` accepts only [name] + --policy-only — there is no +// -o/--output flag. We parse the human-formatted output for the `Phase:` line +// (the only field getSandboxState consumes). export function buildSandboxGetArgv(cliPrefix: readonly string[], name: string): string[] { - return [...cliPrefix, "sandbox", "get", name, "-o", "json"]; + return [...cliPrefix, "sandbox", "get", name]; } export function buildSandboxDeleteArgv(cliPrefix: readonly string[], name: string): string[] { return [...cliPrefix, "sandbox", "delete", name]; } +export function buildSandboxStopArgv(cliPrefix: readonly string[], name: string): string[] { + return [...cliPrefix, "sandbox", "stop", name]; +} + +export function buildSandboxStartArgv(cliPrefix: readonly string[], name: string): string[] { + return [...cliPrefix, "sandbox", "start", name]; +} + export function buildSandboxUploadArgv( cliPrefix: readonly string[], name: string, @@ -275,23 +286,67 @@ export async function getSandboxState(name: string): Promise { const out = await new Response(proc.stdout).text(); const code = await proc.exited; if (code !== 0) return "missing"; - try { - const json = JSON.parse(out); - // `openshell sandbox get` returns { status: { phase: "Ready"|"Failed"|... }, ... } - const phase = json?.status?.phase ?? json?.phase; - if (phase === "Ready" || phase === "Running") return "running"; - if (phase === "Failed" || phase === "Exited") return "exited"; - return "other"; - } catch { - return "other"; - } + return parseSandboxGetPhase(out); +} + +// Extracts the Phase field from `openshell sandbox get` human output, which +// is colorized + indented like ` Phase: Ready`. Strips ANSI before matching. +export function parseSandboxGetPhase(stdout: string): ContainerState { + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ESC (0x1b) is the point — stripping ANSI CSI sequences. + const stripped = stdout.replace(/\u001b\[[0-9;]*m/g, ""); + const m = stripped.match(/^\s*Phase:\s*(\S+)/m); + const phase = m?.[1]; + if (phase === "Ready" || phase === "Running") return "running"; + // Fork v0.5.0 has no Stopped variant: explicit `openshell sandbox stop` + // transitions phase Ready → Error (watch loop treats container exit as + // terminal failure). Mapping Error → "exited" lets reattach trigger + // startSandbox; genuine provisioning failures surface when startSandbox + // or waitForSandboxReady fail. + if (phase === "Failed" || phase === "Exited" || phase === "Stopped" || phase === "Error") + return "exited"; + return "other"; } export async function deleteSandbox(name: string): Promise { const cli = await getCliInvocation(); const argv = buildSandboxDeleteArgv(cli.argv, name); - const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "ignore" }); - await proc.exited; + const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "pipe" }); + const stderr = await new Response(proc.stderr).text(); + const code = await proc.exited; + // NotFound is fine — clean is idempotent; surface other failures so we + // don't leave orphaned podman containers while pretending success. + if (code !== 0 && !/sandbox not found|NotFound/.test(stderr)) { + throw new Error(`openshell sandbox delete failed (exit ${code}): ${stderr.trim()}`); + } +} + +// Halt the container without removing it. Workspace volume + cred secret +// survive; reconnect via startSandbox. Used by `openlock stop` and +// reapIdleStaleSessions to avoid destroying user state. +export async function stopSandbox(name: string): Promise { + const cli = await getCliInvocation(); + const argv = buildSandboxStopArgv(cli.argv, name); + const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "pipe" }); + const stderr = await new Response(proc.stderr).text(); + const code = await proc.exited; + if (code !== 0) { + throw new Error(`openshell sandbox stop failed (exit ${code}): ${stderr.trim()}`); + } +} + +// Start a previously-stopped container. Idempotent on already-running +// containers. Throws when the backend resource has been pruned (the +// underlying CLI emits the "backend resource missing" warning and exits +// non-zero only on hard errors). +export async function startSandbox(name: string): Promise { + const cli = await getCliInvocation(); + const argv = buildSandboxStartArgv(cli.argv, name); + const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "pipe" }); + const stderr = await new Response(proc.stderr).text(); + const code = await proc.exited; + if (code !== 0) { + throw new Error(`openshell sandbox start failed (exit ${code}): ${stderr.trim()}`); + } } export async function uploadToSandbox( diff --git a/src/sandbox/fork-binaries.ts b/src/sandbox/fork-binaries.ts index 97ef9f0..e25878a 100644 --- a/src/sandbox/fork-binaries.ts +++ b/src/sandbox/fork-binaries.ts @@ -7,7 +7,7 @@ import { forkDir } from "../paths"; // release ships, alongside any matching changes in openlock that depend // on fork-side behavior. const OPENSHELL_FORK_REPO = "vessux/OpenShell"; -export const OPENSHELL_FORK_TAG = "v0.4.0"; +export const OPENSHELL_FORK_TAG = "v0.5.0"; type ForkBinary = "openshell-gateway" | "openshell-sandbox" | "openshell"; diff --git a/src/sandbox/session-ops.ts b/src/sandbox/session-ops.ts index 5fcbb37..181026a 100644 --- a/src/sandbox/session-ops.ts +++ b/src/sandbox/session-ops.ts @@ -1,11 +1,11 @@ import { rmSync } from "node:fs"; import { resolve } from "node:path"; -import { SANDBOX_PREFIX } from "./constants"; import { buildOpenshellExecArgv, deleteSandbox, downloadFromSandbox, getSandboxState, + stopSandbox, } from "./container"; import { getCliInvocation } from "./fork-binaries"; import { pruneSandboxRefs } from "./git-sync"; @@ -21,7 +21,7 @@ export async function loadSessionByName(name: string): Promise { - const containerState = await getSandboxState(`${SANDBOX_PREFIX}${m.name}`); + const containerState = await getSandboxState(m.name); return { ...m, containerState, @@ -55,7 +55,7 @@ export async function reapIdleStaleSessions(): Promise<{ const start = Date.now(); await Promise.all( targets.map((r) => - deleteSandbox(`${SANDBOX_PREFIX}${r.meta.name}`).catch((e: unknown) => + stopSandbox(r.meta.name).catch((e: unknown) => console.error(`stop ${r.meta.name}: ${(e as Error).message}`), ), ), @@ -66,7 +66,7 @@ export async function reapIdleStaleSessions(): Promise<{ export async function stopSession(name: string): Promise { const m = await loadSessionByName(name); if (!m) throw new Error(`no such session: ${name}`); - await deleteSandbox(`${SANDBOX_PREFIX}${m.name}`); + await stopSandbox(m.name); console.log(`stopped ${name}`); } @@ -78,7 +78,7 @@ export interface CleanOpts { export async function cleanSession(name: string, opts: CleanOpts = {}): Promise { const m = await loadSessionByName(name); if (!m) throw new Error(`no such session: ${name}`); - const containerName = `${SANDBOX_PREFIX}${m.name}`; + const containerName = m.name; if (opts.copyDir) { const dest = resolve(opts.copyDir); diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index dafd21f..a650037 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -14,7 +14,6 @@ import type { ProviderId } from "../providers/types"; import { type Runtime, resolveRuntime } from "../runtime"; import { readToken } from "../tokens"; import { validateBranchFlagAgainstWorkdir } from "./branch-validation"; -import { SANDBOX_PREFIX } from "./constants"; import { buildOpenshellExecArgv, buildSandboxEnv, @@ -24,6 +23,7 @@ import { getSandboxState, listSandboxes, openshellSandboxCreateAsync, + startSandbox, waitForSandboxReady, } from "./container"; import { containerfileKeyForCaps, DEFAULT_CONTAINERFILES } from "./default-containerfiles"; @@ -148,7 +148,10 @@ async function createSession( const id = newSessionId(); const name = friendlyNameFromId(basename(projectPath), id); - const containerName = `${SANDBOX_PREFIX}${name}`; + // openshell registers the sandbox under its CLI --name; the podman container + // happens to be named `openshell-sandbox-` but openshell verbs + // (get/exec/stop/start/delete) take the gateway name (unprefixed). + const containerName = name; const tmp = mkdtempSync(join(tmpdir(), "openlock-")); try { @@ -496,7 +499,7 @@ async function reattachSession( mounts: readonly Mount[], providerId: ProviderId, ): Promise { - const containerName = `${SANDBOX_PREFIX}${m.name}`; + const containerName = m.name; const state = await getSandboxState(containerName); if (state === "missing") { console.error( @@ -515,6 +518,9 @@ async function reattachSession( } await startGateway(); await ensureProvider(providerId); + if (state === "exited") { + await startSandbox(containerName); + } await waitForSandboxReady(m.name); for (const mount of mounts) { if (mount.type !== "copy-refresh") continue; @@ -670,9 +676,9 @@ export async function runSandbox(opts: SandboxOpts): Promise { harness, }; const exitCode = await attachHarnessAndSync(containerName, sessionName, launch, resolved.mounts); - // listSandboxes returns all states; gateway should only stay up for OTHER - // currently-running sandboxes, so filter via getSandboxState. - const others = (await listSandboxes(SANDBOX_PREFIX)).filter((n) => n !== containerName); + // listSandboxes returns all gateway-tracked sandboxes; gateway should only + // stay up for OTHER currently-running sandboxes, so filter via getSandboxState. + const others = (await listSandboxes()).filter((n) => n !== containerName); const otherStates = await Promise.all(others.map((n) => getSandboxState(n))); const stillRunning = others.filter((_, i) => otherStates[i] === "running"); handleGatewayShutdown(stillRunning.length);