Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6d08d7a
fix(onboard): skip Docker bridge probe for VM driver
ericksoa May 13, 2026
c98d291
fix(onboard): keep bridge probe patch entrypoint-neutral
ericksoa May 13, 2026
26b66cd
fix(onboard): wait for VM startup output before detaching
ericksoa May 13, 2026
a458b2f
fix(onboard): keep VM startup gate out of entrypoint
ericksoa May 13, 2026
d3fbfeb
fix(hermes): keep macos vm startup mutable
ericksoa May 13, 2026
3869623
fix(onboard): reuse stored messaging channels
ericksoa May 13, 2026
cef0079
fix(connect): avoid legacy dns repair for vm sandboxes
ericksoa May 13, 2026
8343311
fix: monkeypatch macos vm dns for inference
ericksoa May 13, 2026
696be2a
fix: allow discord guild users for hermes
ericksoa May 13, 2026
db6b0d4
refactor: keep onboard entrypoint net neutral
ericksoa May 13, 2026
aed17c3
Merge remote-tracking branch 'origin/main' into fix/macos-vm-skip-doc…
ericksoa May 13, 2026
11cc7f3
fix: allow discord regional websocket gateways
ericksoa May 13, 2026
0f54432
fix: address messaging reuse review feedback
ericksoa May 13, 2026
46ab1f6
Merge remote-tracking branch 'origin/main' into fix/macos-vm-skip-doc…
ericksoa May 13, 2026
2b290c0
fix: flush sandbox create tail before ready recovery
ericksoa May 13, 2026
3a9f58f
fix: keep VM DNS monkeypatch best-effort
ericksoa May 13, 2026
242a624
fix(onboard): address messaging reuse feedback
ericksoa May 13, 2026
32056fa
fix(macos): harden VM DNS monkeypatch
ericksoa May 13, 2026
d7a3b25
Merge branch 'main' into fix/macos-vm-skip-docker-bridge-probe
cv May 13, 2026
793666c
fix(macos): address VM DNS review feedback
ericksoa May 13, 2026
70a4887
fix(macos): special-case only VM DNS repair
ericksoa May 13, 2026
cd513ea
Merge branch 'main' into fix/macos-vm-skip-docker-bridge-probe
cv May 13, 2026
5a84abb
Merge remote-tracking branch 'origin/main' into fix/macos-vm-skip-doc…
cv May 13, 2026
2646709
Merge remote-tracking branch 'origin/main' into fix/macos-vm-skip-doc…
ericksoa May 14, 2026
4c36539
fix(connect): probe VM inference after route reapply
ericksoa May 14, 2026
55296c4
merge: main into fix/macos-vm-skip-docker-bridge-probe
cv May 14, 2026
69698b7
fix(onboard): satisfy entrypoint budget
cv May 14, 2026
7a22871
Merge branch 'main' into fix/macos-vm-skip-docker-bridge-probe
ericksoa May 14, 2026
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
15 changes: 12 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4461,7 +4461,7 @@ async function startDockerDriverGateway({
if (drift) {
restartDockerDriverGatewayProcessForDrift(pidFileGatewayPid, drift.reason);
} else if (registerDockerDriverGatewayEndpoint() && (await isDockerDriverGatewayHttpReady())) {
await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure);
await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure, { drivers: gatewayEnv.OPENSHELL_DRIVERS });
console.log(" ✓ Reusing existing Docker-driver gateway");
return;
} else {
Expand Down Expand Up @@ -4492,7 +4492,7 @@ async function startDockerDriverGateway({
isGatewayHealthy(adoptedStatus, adoptedGwInfo, adoptedActiveGatewayInfo) &&
(await isDockerDriverGatewayHttpReady())
) {
await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure);
await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure, { drivers: gatewayEnv.OPENSHELL_DRIVERS });
console.log(` ✓ Reusing existing Docker-driver gateway process (PID ${portListenerPid})`);
return;
}
Expand Down Expand Up @@ -4561,7 +4561,7 @@ async function startDockerDriverGateway({
isGatewayHealthy(status, namedInfo, currentInfo) &&
(await isGatewayTcpReady())
) {
await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure);
await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure, { drivers: gatewayEnv.OPENSHELL_DRIVERS });
console.log(" ✓ Docker-driver gateway is healthy");
return;
}
Expand Down Expand Up @@ -5918,11 +5918,20 @@ async function createSandbox(
...envArgs,
"nemoclaw-start",
])} 2>&1`;
const selectedOpenShellDrivers = (process.env.OPENSHELL_DRIVERS ??
(process.platform === "darwin" ? "vm" : "docker"))
.split(",")
.map((driver) => driver.trim())
.filter(Boolean);
const waitForStartupOutputBeforeReadyDetach = selectedOpenShellDrivers.includes("vm");
const createResult = await streamSandboxCreate(createCommand, sandboxEnv, {
readyCheck: () => {
const list = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true });
return isSandboxReady(list, sandboxName);
},
readyCheckOutputPatterns: waitForStartupOutputBeforeReadyDetach
? [/Setting up NemoClaw/]
: undefined,
});

if (initialSandboxPolicy.cleanup && initialSandboxPolicy.cleanup()) {
Expand Down
25 changes: 25 additions & 0 deletions src/lib/onboard/gateway-sandbox-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { describe, expect, it } from "vitest";
import {
isSandboxBridgeGatewayReachable,
formatSandboxBridgeUnreachableMessage,
shouldVerifySandboxBridgeGatewayReachability,
verifySandboxBridgeGatewayReachableOrExit,
} from "../../../dist/lib/onboard/gateway-sandbox-reachability";

describe("isSandboxBridgeGatewayReachable", () => {
Expand Down Expand Up @@ -67,6 +69,29 @@ describe("isSandboxBridgeGatewayReachable", () => {
});
});

describe("verifySandboxBridgeGatewayReachableOrExit", () => {
it("skips the Docker bridge probe when OpenShell is using the macOS VM driver", async () => {
let inspectCalls = 0;
await verifySandboxBridgeGatewayReachableOrExit(false, {
drivers: "vm",
inspectSubnetImpl: () => {
inspectCalls += 1;
return undefined;
},
runImpl: () => {
throw new Error("probe should not run");
},
});
expect(inspectCalls).toBe(0);
});

it("keeps the bridge probe enabled for Docker-driver gateways", () => {
expect(shouldVerifySandboxBridgeGatewayReachability({ drivers: "docker" })).toBe(true);
expect(shouldVerifySandboxBridgeGatewayReachability({ drivers: "vm,docker" })).toBe(true);
expect(shouldVerifySandboxBridgeGatewayReachability({ drivers: "vm" })).toBe(false);
});
});

describe("formatSandboxBridgeUnreachableMessage", () => {
it("returns empty for an ok result", () => {
expect(
Expand Down
20 changes: 19 additions & 1 deletion src/lib/onboard/gateway-sandbox-reachability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ export interface SandboxBridgeReachabilityOptions {
inspectSubnetImpl?: (networkName: string) => string | undefined;
}

export interface SandboxBridgeReachabilityVerifyOptions
extends SandboxBridgeReachabilityOptions {
drivers?: string;
}

export function shouldVerifySandboxBridgeGatewayReachability(
opts: { drivers?: string } = {},
): boolean {
const drivers = (opts.drivers ?? process.env.OPENSHELL_DRIVERS ?? "docker")
.split(/[,\s]+/)
.map((driver) => driver.trim().toLowerCase())
.filter(Boolean);
return drivers.includes("docker");
}

function defaultInspectSubnet(networkName: string): string | undefined {
try {
const out = dockerInspectFormat(
Expand Down Expand Up @@ -186,8 +201,11 @@ export function formatSandboxBridgeUnreachableMessage(

export async function verifySandboxBridgeGatewayReachableOrExit(
exitOnFailure: boolean,
opts: SandboxBridgeReachabilityVerifyOptions = {},
): Promise<void> {
const reach = await isSandboxBridgeGatewayReachable();
if (!shouldVerifySandboxBridgeGatewayReachability({ drivers: opts.drivers })) return;

const reach = await isSandboxBridgeGatewayReachable(opts);
if (reach.ok) return;

const message = formatSandboxBridgeUnreachableMessage(reach);
Expand Down
61 changes: 61 additions & 0 deletions src/lib/sandbox/create-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,67 @@ describe("sandbox-create-stream", () => {
expect(child.unref).toHaveBeenCalled();
});

it("does not detach on Ready until required startup output appears", async () => {
vi.useFakeTimers();

const child = new FakeChild();
const logLine = vi.fn();
let resolved = false;
const promise = streamSandboxCreate("echo create", process.env, {
spawnImpl: () => child,
readyCheck: () => true,
readyCheckOutputPatterns: [/Setting up NemoClaw/],
pollIntervalMs: 5,
heartbeatIntervalMs: 1_000,
silentPhaseMs: 10_000,
logLine,
}).then((result) => {
resolved = true;
return result;
});

child.stdout.emit("data", Buffer.from("Created sandbox: demo\n"));
await vi.advanceTimersByTimeAsync(12);

expect(resolved).toBe(false);
expect(child.kill).not.toHaveBeenCalled();
expect(logLine).toHaveBeenCalledWith(
" Sandbox reported Ready; waiting for startup command output before detaching.",
);

child.stderr.emit("data", Buffer.from("Setting up NemoClaw (Hermes)...\n"));
await vi.advanceTimersByTimeAsync(6);

await expect(promise).resolves.toMatchObject({
status: 0,
forcedReady: true,
output: expect.stringContaining("Setting up NemoClaw (Hermes)..."),
});
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
});

it("does not recover a non-zero close before required startup output appears", async () => {
const child = new FakeChild();
const promise = streamSandboxCreate("echo create", process.env, {
spawnImpl: () => child,
readyCheck: () => true,
readyCheckOutputPatterns: [/Setting up NemoClaw/],
pollIntervalMs: 60_000,
heartbeatIntervalMs: 1_000,
silentPhaseMs: 10_000,
logLine: vi.fn(),
});

child.stdout.emit("data", Buffer.from("Created sandbox: demo\n"));
child.emit("close", 255);

await expect(promise).resolves.toMatchObject({
status: 255,
sawProgress: true,
});
expect((await promise).forcedReady).toBeUndefined();
});

it("flushes the final partial line before resolving", async () => {
const child = new FakeChild();
const promise = streamSandboxCreate("echo create", process.env, {
Expand Down
22 changes: 21 additions & 1 deletion src/lib/sandbox/create-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface StreamSandboxCreateOptions {
heartbeatIntervalMs?: number;
silentPhaseMs?: number;
logLine?: (line: string) => void;
// Optional guard for the early-ready escape hatch. When set, readyCheck()
// alone cannot detach the create stream until at least one streamed output
// line matches a configured pattern.
readyCheckOutputPatterns?: readonly RegExp[];
// Initial progress phase:
// build — docker-building the sandbox image
// upload — pushing the built image into the gateway registry
Expand Down Expand Up @@ -110,6 +114,9 @@ export function streamSandboxCreate(
let pending = "";
let lastPrintedLine = "";
let sawProgress = false;
let readyCheckOutputMatched =
!options.readyCheckOutputPatterns || options.readyCheckOutputPatterns.length === 0;
let printedReadyCheckOutputWait = false;
let settled = false;
let polling = false;
const pollIntervalMs = options.pollIntervalMs || 2000;
Expand Down Expand Up @@ -172,6 +179,9 @@ export function streamSandboxCreate(
if (!line) return;
lines.push(line);
lastOutputAt = Date.now();
if (!readyCheckOutputMatched && matchesAny(line, options.readyCheckOutputPatterns ?? [])) {
readyCheckOutputMatched = true;
}
if (matchesAny(line, BUILD_PROGRESS_PATTERNS)) {
setPhase("build");
} else if (matchesAny(line, PULL_PROGRESS_PATTERNS)) {
Expand Down Expand Up @@ -238,6 +248,16 @@ export function streamSandboxCreate(
}
if (!ready) return;
setPhase("ready");
if (!readyCheckOutputMatched) {
if (!printedReadyCheckOutputWait) {
const detail =
"Sandbox reported Ready; waiting for startup command output before detaching.";
lines.push(detail);
printProgressLine(` ${detail}`);
printedReadyCheckOutputWait = true;
}
return;
}
const detail = "Sandbox reported Ready before create stream exited; continuing.";
lines.push(detail);
printProgressLine(` ${detail}`);
Expand Down Expand Up @@ -300,7 +320,7 @@ export function streamSandboxCreate(
// last poll tick and the stream exit (e.g. SSH 255 after "Created sandbox:").
if (code && code !== 0 && options.readyCheck) {
try {
if (options.readyCheck()) {
if (options.readyCheck() && readyCheckOutputMatched) {
finish(0, { forcedReady: true });
return;
}
Expand Down
Loading