Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
4 changes: 1 addition & 3 deletions src/cli/exec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -28,8 +27,7 @@ export async function execCmd(args: string[]): Promise<number> {
}
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;
Expand Down
4 changes: 1 addition & 3 deletions src/cli/shell.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,8 +20,7 @@ export async function shellCmd(args: string[]): Promise<number> {
}
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;
Expand Down
1 change: 0 additions & 1 deletion src/sandbox/constants.ts

This file was deleted.

54 changes: 43 additions & 11 deletions src/sandbox/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
buildSandboxExecRootArgv,
buildSandboxGetArgv,
buildSandboxListNamesArgv,
buildSandboxStartArgv,
buildSandboxStopArgv,
buildSandboxUploadArgv,
parseSandboxGetPhase,
wrapCmdWithEnv,
} from "./container";

Expand Down Expand Up @@ -188,15 +191,8 @@ describe("buildHarnessExecArgv (harness binary selection)", () => {
});

describe("buildSandboxGetArgv", () => {
it("emits `openshell sandbox get <name> -o json`", () => {
expect(buildSandboxGetArgv(["cli"], "sess")).toEqual([
"cli",
"sandbox",
"get",
"sess",
"-o",
"json",
]);
it("emits `openshell sandbox get <name>`", () => {
expect(buildSandboxGetArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "get", "sess"]);
});

it("supports a multi-element cli prefix", () => {
Expand All @@ -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: <state>` 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 <name>`", () => {
expect(buildSandboxDeleteArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "delete", "sess"]);
});
});

describe("buildSandboxStopArgv", () => {
it("emits `openshell sandbox stop <name>`", () => {
expect(buildSandboxStopArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "stop", "sess"]);
});
});

describe("buildSandboxStartArgv", () => {
it("emits `openshell sandbox start <name>`", () => {
expect(buildSandboxStartArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "start", "sess"]);
});
});

describe("buildSandboxUploadArgv", () => {
it("emits `openshell sandbox upload <name> <local> <dest>`", () => {
expect(buildSandboxUploadArgv(["cli"], "sess", "/host/file", "/sbx/dir")).toEqual([
Expand Down
81 changes: 68 additions & 13 deletions src/sandbox/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -275,23 +286,67 @@ export async function getSandboxState(name: string): Promise<ContainerState> {
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<void> {
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<void> {
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<void> {
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(
Expand Down
2 changes: 1 addition & 1 deletion src/sandbox/fork-binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
10 changes: 5 additions & 5 deletions src/sandbox/session-ops.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,7 +21,7 @@ export async function loadSessionByName(name: string): Promise<SessionMeta | nul
}

async function enrichSession(m: SessionMeta): Promise<SessionWithState> {
const containerState = await getSandboxState(`${SANDBOX_PREFIX}${m.name}`);
const containerState = await getSandboxState(m.name);
return {
...m,
containerState,
Expand Down Expand Up @@ -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}`),
),
),
Expand All @@ -66,7 +66,7 @@ export async function reapIdleStaleSessions(): Promise<{
export async function stopSession(name: string): Promise<void> {
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}`);
}

Expand All @@ -78,7 +78,7 @@ export interface CleanOpts {
export async function cleanSession(name: string, opts: CleanOpts = {}): Promise<void> {
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);
Expand Down
18 changes: 12 additions & 6 deletions src/sandbox/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,6 +23,7 @@ import {
getSandboxState,
listSandboxes,
openshellSandboxCreateAsync,
startSandbox,
waitForSandboxReady,
} from "./container";
import { containerfileKeyForCaps, DEFAULT_CONTAINERFILES } from "./default-containerfiles";
Expand Down Expand Up @@ -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-<name>` but openshell verbs
// (get/exec/stop/start/delete) take the gateway name (unprefixed).
const containerName = name;

const tmp = mkdtempSync(join(tmpdir(), "openlock-"));
try {
Expand Down Expand Up @@ -496,7 +499,7 @@ async function reattachSession(
mounts: readonly Mount[],
providerId: ProviderId,
): Promise<ResolvedSession> {
const containerName = `${SANDBOX_PREFIX}${m.name}`;
const containerName = m.name;
const state = await getSandboxState(containerName);
if (state === "missing") {
console.error(
Expand All @@ -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;
Expand Down Expand Up @@ -670,9 +676,9 @@ export async function runSandbox(opts: SandboxOpts): Promise<void> {
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);
Expand Down