Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
7 changes: 6 additions & 1 deletion agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,12 @@ if [ "$(id -u)" -ne 0 ]; then
export HOME=/sandbox
export HERMES_HOME="${HERMES_DIR}"

if ! verify_config_integrity "${HERMES_DIR}" "${HERMES_HASH_FILE}"; then
# macOS VM startup currently runs this entrypoint as the sandbox user and
# remaps rootfs ownership to the host uid. In that mode the strict /etc hash
# cannot remain a root-owned trust anchor, so use the same locked-aware
# mutable-default verifier as OpenClaw. The root path below keeps strict
# verification against /etc/nemoclaw/hermes.config-hash.
if ! verify_config_integrity_if_locked "${HERMES_DIR}"; then
echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2
exit 1
fi
Expand Down
40 changes: 37 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,30 @@ function getRecordedMessagingChannelsForResume(
return getKnownMessagingChannels(session.messagingChannels);
}

function getMessagingProviderNamesForChannel(sandboxName: string, channel: string): string[] {
if (channel === "discord") return [`${sandboxName}-discord-bridge`];
if (channel === "telegram") return [`${sandboxName}-telegram-bridge`];
if (channel === "slack") return [`${sandboxName}-slack-bridge`];
return [];
Comment thread
ericksoa marked this conversation as resolved.
Outdated
}

function getReusableStoredMessagingChannelsForNonInteractive(
sandboxName: string | null,
): string[] {
if (!sandboxName || !isNonInteractive()) return [];

const entry = registry.getSandbox(sandboxName);
const configuredChannels = getKnownMessagingChannels(entry?.messagingChannels);
if (configuredChannels.length === 0) return [];

const disabledChannels = new Set(registry.getDisabledChannels(sandboxName));
return configuredChannels.filter((channel) => {
if (disabledChannels.has(channel)) return false;
const providers = getMessagingProviderNamesForChannel(sandboxName, channel);
return providers.length > 0 && providers.every((provider) => providerExistsInGateway(provider));
});
}

/**
* Detect whether any messaging provider credential has been rotated since
* the sandbox was created, by comparing SHA-256 hashes of the current
Expand Down Expand Up @@ -4461,7 +4485,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 +4516,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 +4585,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 @@ -10741,6 +10765,16 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
}
} else {
selectedMessagingChannels = await setupMessagingChannels();
if (selectedMessagingChannels.length === 0) {
const reusableStoredMessagingChannels =
getReusableStoredMessagingChannelsForNonInteractive(sandboxName);
if (reusableStoredMessagingChannels.length > 0) {
selectedMessagingChannels = reusableStoredMessagingChannels;
note(
` [non-interactive] Reusing existing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`,
);
}
}
}
const messagingChannelConfig = readMessagingChannelConfigFromEnv();
onboardSession.updateSession((current: Session) => {
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
70 changes: 66 additions & 4 deletions src/lib/sandbox/create-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class FakeChild extends EventEmitter implements StreamableChildProcess {
unref = vi.fn();
}

const dockerEnv = { ...process.env, OPENSHELL_DRIVERS: "docker" };
const vmEnv = { ...process.env, OPENSHELL_DRIVERS: "vm" };

describe("sandbox-create-stream", () => {
afterEach(() => {
vi.useRealTimers();
Expand All @@ -30,7 +33,7 @@ describe("sandbox-create-stream", () => {
it("prints the initial build banner immediately", async () => {
const child = new FakeChild();
const logLine = vi.fn();
const promise = streamSandboxCreate("echo create", process.env, {
const promise = streamSandboxCreate("echo create", dockerEnv, {
logLine,
spawnImpl: () => child,
});
Expand All @@ -43,7 +46,7 @@ describe("sandbox-create-stream", () => {
it("streams visible progress lines and returns the collected output", async () => {
const child = new FakeChild();
const logLine = vi.fn();
const promise = streamSandboxCreate("echo create", process.env, {
const promise = streamSandboxCreate("echo create", dockerEnv, {
logLine,
spawnImpl: () => child,
heartbeatIntervalMs: 1_000,
Expand Down Expand Up @@ -99,7 +102,7 @@ describe("sandbox-create-stream", () => {

const child = new FakeChild();
let checks = 0;
const promise = streamSandboxCreate("echo create", process.env, {
const promise = streamSandboxCreate("echo create", dockerEnv, {
spawnImpl: () => child,
readyCheck: () => {
checks += 1;
Expand All @@ -124,6 +127,65 @@ 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", vmEnv, {
spawnImpl: () => child,
readyCheck: () => true,
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", vmEnv, {
spawnImpl: () => child,
readyCheck: () => true,
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 All @@ -144,7 +206,7 @@ describe("sandbox-create-stream", () => {
it("recovers when sandbox is ready at the moment the stream exits non-zero", async () => {
const child = new FakeChild();
const logLine = vi.fn();
const promise = streamSandboxCreate("echo create", process.env, {
const promise = streamSandboxCreate("echo create", dockerEnv, {
spawnImpl: () => child,
readyCheck: () => true, // sandbox is already Ready
pollIntervalMs: 60_000, // large interval so the poll doesn't fire first
Expand Down
46 changes: 45 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 @@ -90,10 +94,31 @@ const VISIBLE_PROGRESS_PATTERNS: readonly RegExp[] = [
/^✓ /,
];

const VM_READY_DETACH_OUTPUT_PATTERNS: readonly RegExp[] = [/Setting up NemoClaw/];

function matchesAny(line: string, patterns: readonly RegExp[]) {
return patterns.some((pattern) => pattern.test(line));
}

function selectedDrivers(env: NodeJS.ProcessEnv): string[] {
const raw =
env.OPENSHELL_DRIVERS ??
process.env.OPENSHELL_DRIVERS ??
(process.platform === "darwin" ? "vm" : "docker");
return raw
.split(",")
.map((driver) => driver.trim())
.filter(Boolean);
}

function getReadyCheckOutputPatterns(
env: NodeJS.ProcessEnv,
patterns: readonly RegExp[] | undefined,
): readonly RegExp[] {
if (patterns) return patterns;
return selectedDrivers(env).includes("vm") ? VM_READY_DETACH_OUTPUT_PATTERNS : [];
}

export function streamSandboxCreate(
command: string,
env: NodeJS.ProcessEnv = process.env,
Expand All @@ -110,6 +135,12 @@ export function streamSandboxCreate(
let pending = "";
let lastPrintedLine = "";
let sawProgress = false;
const readyCheckOutputPatterns = getReadyCheckOutputPatterns(
env,
options.readyCheckOutputPatterns,
);
let readyCheckOutputMatched = readyCheckOutputPatterns.length === 0;
let printedReadyCheckOutputWait = false;
let settled = false;
let polling = false;
const pollIntervalMs = options.pollIntervalMs || 2000;
Expand Down Expand Up @@ -172,6 +203,9 @@ export function streamSandboxCreate(
if (!line) return;
lines.push(line);
lastOutputAt = Date.now();
if (!readyCheckOutputMatched && matchesAny(line, readyCheckOutputPatterns)) {
readyCheckOutputMatched = true;
}
if (matchesAny(line, BUILD_PROGRESS_PATTERNS)) {
setPhase("build");
} else if (matchesAny(line, PULL_PROGRESS_PATTERNS)) {
Expand Down Expand Up @@ -238,6 +272,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 +344,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
6 changes: 2 additions & 4 deletions test/gateway-liveness-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,7 @@ describe("gateway liveness probe (#2020)", () => {
expect(dockerStart).toBeGreaterThanOrEqual(0);
expect(dockerEnd).toBeGreaterThan(dockerStart);
const dockerSection = content.slice(dockerStart, dockerEnd);
const calls = dockerSection.match(
/verifySandboxBridgeGatewayReachableOrExit\(exitOnFailure\)/g,
);
const calls = dockerSection.match(/verifySandboxBridgeGatewayReachableOrExit\(exitOnFailure/g);
expect(calls?.length).toBeGreaterThanOrEqual(3);

for (const marker of [
Expand All @@ -153,7 +151,7 @@ describe("gateway liveness probe (#2020)", () => {
expect(markerIdx).toBeGreaterThan(0);
const before = dockerSection.slice(0, markerIdx);
expect(
before.lastIndexOf("verifySandboxBridgeGatewayReachableOrExit(exitOnFailure)"),
before.lastIndexOf("verifySandboxBridgeGatewayReachableOrExit(exitOnFailure"),
).toBeGreaterThan(0);
}
});
Expand Down
Loading
Loading