diff --git a/agents/hermes/config/messaging-config.ts b/agents/hermes/config/messaging-config.ts index 3e28556b7a..5cf584d86d 100644 --- a/agents/hermes/config/messaging-config.ts +++ b/agents/hermes/config/messaging-config.ts @@ -32,6 +32,11 @@ export function buildMessagingEnvLines( const discordAllowedUsers = collectDiscordAllowedUsers(allowedIds, discordGuilds); if (discordAllowedUsers.length > 0) { envLines.push(`DISCORD_ALLOWED_USERS=${discordAllowedUsers.join(",")}`); + } else if ( + enabledChannels.has("discord") && + Object.keys(discordGuilds).filter((guildId) => guildId.trim()).length > 0 + ) { + envLines.push("DISCORD_ALLOW_ALL_USERS=true"); } if (allowedIds.telegram?.length) { envLines.push(`TELEGRAM_ALLOWED_USERS=${allowedIds.telegram.map(String).join(",")}`); diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index 82c98dd093..97cc72d928 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -229,6 +229,14 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: "*.discord.gg" + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index 0134c667bd..1106e4fff2 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -194,6 +194,14 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: "*.discord.gg" + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 39bf36e683..4c605d93d0 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -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 diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml index 142a1139dd..eb8c58fd8e 100644 --- a/agents/openclaw/policy-permissive.yaml +++ b/agents/openclaw/policy-permissive.yaml @@ -160,9 +160,20 @@ network_policies: access: full - host: gateway.discord.gg port: 443 - protocol: rest + protocol: websocket enforcement: enforce - access: full + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: "*.discord.gg" + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml index 817c50661b..0369c48280 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml @@ -195,6 +195,14 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: "*.discord.gg" + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/nemoclaw-blueprint/policies/presets/discord.yaml index af8cad661c..775e580524 100644 --- a/nemoclaw-blueprint/policies/presets/discord.yaml +++ b/nemoclaw-blueprint/policies/presets/discord.yaml @@ -34,6 +34,14 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: "*.discord.gg" + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 04ce645a7e..cb59f11b61 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -22,6 +22,10 @@ import type { SandboxEntry } from "../../state/registry"; import { ROOT } from "../../runner"; import { runSetupDnsProxy } from "../dns"; import { ensureLiveSandboxOrExit } from "./gateway-state"; +import { + applyOpenShellVmDnsMonkeypatch, + shouldApplyVmDnsMonkeypatch, +} from "./vm-dns-monkeypatch"; import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, @@ -155,13 +159,72 @@ function isSandboxInferenceRouteHealthy(sandboxName: string): boolean { return probe.status === 0 && /^OK\s+[0-9]{3}\b/.test(probe.output.trim()); } +function shouldUseLegacyDnsProxyRepair(sb: SandboxEntry | null): boolean { + return sb?.openshellDriver !== "vm"; +} + +function reapplyVmInferenceRoute(sandboxName: string, sb: SandboxEntry | null): boolean { + if (!sb?.provider || !sb.model) return false; + runOpenshell( + ["inference", "set", "--provider", sb.provider, "--model", sb.model, "--no-verify"], + { ignoreError: true }, + ); + return isSandboxInferenceRouteHealthy(sandboxName); +} + function repairSandboxInferenceRouteIfNeeded( sandboxName: string, + sb: SandboxEntry | null, { quiet = false }: { quiet?: boolean } = {}, ): boolean { if (process.env.NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR === "1") return false; if (isSandboxInferenceRouteHealthy(sandboxName)) return false; + if (!shouldUseLegacyDnsProxyRepair(sb)) { + if (shouldApplyVmDnsMonkeypatch(sb)) { + if (!quiet) { + console.log(""); + console.log( + ` inference.local is unavailable inside '${sandboxName}'. Applying OpenShell VM DNS monkeypatch...`, + ); + } + const patch = applyOpenShellVmDnsMonkeypatch(sandboxName, sb); + if (patch.ok && isSandboxInferenceRouteHealthy(sandboxName)) { + if (!quiet) { + console.log(" inference.local route repaired."); + } + return true; + } + if (!quiet) { + if (!patch.ok && patch.reason) { + console.error( + ` Warning: OpenShell VM DNS monkeypatch did not apply: ${patch.reason}`, + ); + } else if (patch.ok) { + console.error( + " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", + ); + } + } + } + + if (!quiet) { + console.log(""); + console.log(` inference.local is unavailable inside '${sandboxName}'. Reapplying OpenShell inference route...`); + } + const healthy = reapplyVmInferenceRoute(sandboxName, sb); + if (!quiet) { + if (healthy) { + console.log(" inference.local route repaired."); + } else { + console.error( + ` Warning: inference.local is still unavailable through the OpenShell ${sb?.openshellDriver || "non-legacy"} gateway path.`, + ); + } + } + return healthy; + } + if (!quiet) { console.log(""); console.log(` inference.local is unavailable inside '${sandboxName}'. Repairing sandbox DNS proxy...`); @@ -219,7 +282,7 @@ function ensureSandboxInferenceRoute( ); } } - repairSandboxInferenceRouteIfNeeded(sandboxName, { quiet }); + repairSandboxInferenceRouteIfNeeded(sandboxName, sb, { quiet }); } } catch { /* non-fatal — don't block connect on inference route repair */ diff --git a/src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts b/src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts new file mode 100644 index 0000000000..2c50f10822 --- /dev/null +++ b/src/lib/actions/sandbox/vm-dns-monkeypatch.test.ts @@ -0,0 +1,382 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: vi.fn(), +})); + +import { + applyOpenShellVmDnsMonkeypatch, + shouldApplyVmDnsMonkeypatch, +} from "./vm-dns-monkeypatch"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vm-dns-monkeypatch-")); + tempDirs.push(dir); + return dir; +} + +function sandboxRootfs(stateDir: string, sandboxId = "abc"): string { + return path.join(stateDir, "vm-driver", "sandboxes", sandboxId, "rootfs"); +} + +function sandboxDir(stateDir: string, sandboxId = "abc"): string { + return path.join(stateDir, "vm-driver", "sandboxes", sandboxId); +} + +function writeRecognizedInit(rootfs: string): void { + fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); + fs.writeFileSync( + path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), + [ + "elif ip link show eth0 >/dev/null 2>&1; then", + " if [ ! -s /etc/resolv.conf ]; then", + ' echo "nameserver 8.8.8.8" > /etc/resolv.conf', + ' echo "nameserver 8.8.4.4" >> /etc/resolv.conf', + " fi", + "fi", + "", + ].join("\n"), + ); +} + +function writeRootfsFiles(rootfs: string, resolver: string): void { + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.writeFileSync(path.join(rootfs, "etc", "resolv.conf"), resolver); + writeRecognizedInit(rootfs); +} + +describe("OpenShell VM DNS monkeypatch", () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not attempt non-VM sandboxes", () => { + const capture = vi.fn(); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "kubernetes" }, + { + capture, + env: {}, + platform: "darwin", + stateDir: makeTempDir(), + }, + ); + + expect(result).toMatchObject({ + attempted: false, + changed: false, + ok: false, + status: "skipped", + }); + expect(result.reason).toContain("not an OpenShell VM sandbox"); + expect(capture).not.toHaveBeenCalled(); + }); + + it("does not attempt non-Darwin VM sandboxes unless force-enabled", () => { + const capture = vi.fn(); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture, + env: {}, + platform: "linux", + stateDir: makeTempDir(), + }, + ); + + expect(result).toMatchObject({ + attempted: false, + changed: false, + ok: false, + status: "skipped", + }); + expect(result.reason).toContain("not running on macOS"); + expect(capture).not.toHaveBeenCalled(); + expect(shouldApplyVmDnsMonkeypatch({ openshellDriver: "vm" }, "linux", {})).toBe(false); + expect( + shouldApplyVmDnsMonkeypatch({ openshellDriver: "vm" }, "linux", { + NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH: "1", + }), + ).toBe(true); + }); + + it("honors the VM DNS monkeypatch kill switch", () => { + const capture = vi.fn(); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture, + env: { NEMOCLAW_DISABLE_VM_DNS_MONKEYPATCH: "1" }, + platform: "darwin", + stateDir: makeTempDir(), + }, + ); + + expect(result).toMatchObject({ + attempted: false, + changed: false, + ok: false, + status: "skipped", + }); + expect(result.reason).toContain("disabled"); + expect(capture).not.toHaveBeenCalled(); + }); + + it("puts gvproxy DNS first while preserving resolver options and private resolvers", () => { + const stateDir = makeTempDir(); + const rootfs = sandboxRootfs(stateDir); + writeRootfsFiles( + rootfs, + [ + "search corp.example", + "options ndots:5", + "nameserver 8.8.8.8", + "nameserver 10.0.0.2", + "nameserver 192.168.127.1", + "nameserver 10.0.0.2", + "nameserver 8.8.4.4", + "", + ].join("\n"), + ); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: true, + ok: true, + status: "applied", + }); + expect(fs.readFileSync(path.join(rootfs, "etc", "resolv.conf"), "utf-8")).toBe( + [ + "nameserver 192.168.127.1", + "search corp.example", + "options ndots:5", + "nameserver 10.0.0.2", + "", + ].join("\n"), + ); + expect( + fs.readFileSync(path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), "utf-8"), + ).toContain('echo "nameserver ${GVPROXY_GATEWAY_IP}" > /etc/resolv.conf'); + }); + + it("is idempotent when resolver and init script are already patched", () => { + const stateDir = makeTempDir(); + const rootfs = sandboxRootfs(stateDir); + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); + fs.writeFileSync( + path.join(rootfs, "etc", "resolv.conf"), + "nameserver 192.168.127.1\nsearch corp.example\noptions ndots:5\n", + ); + fs.writeFileSync( + path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), + 'echo "nameserver ${GVPROXY_GATEWAY_IP}" > /etc/resolv.conf\n', + ); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: false, + ok: true, + status: "already-present", + }); + }); + + it("returns a soft failure when the VM rootfs is missing", () => { + const stateDir = makeTempDir(); + fs.mkdirSync(sandboxDir(stateDir), { recursive: true }); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: false, + ok: false, + status: "failed", + }); + expect(result.reason).toContain("VM rootfs not found"); + }); + + it("returns a specific unsupported-layout reason for ext4-style VM root disks", () => { + const stateDir = makeTempDir(); + const dir = sandboxDir(stateDir); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "rootfs.ext4"), ""); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: false, + ok: false, + status: "failed", + }); + expect(result.reason).toContain("ext4 root disk layout"); + expect(result.reason).toContain("rootfs DNS monkeypatch no longer applies"); + }); + + it("refuses unknown init-script shapes without rewriting resolver files", () => { + const stateDir = makeTempDir(); + const rootfs = sandboxRootfs(stateDir); + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); + const resolverPath = path.join(rootfs, "etc", "resolv.conf"); + const originalResolver = "nameserver 8.8.8.8\n"; + fs.writeFileSync(resolverPath, originalResolver); + fs.writeFileSync(path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), "echo unknown\n"); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: false, + ok: false, + status: "failed", + }); + expect(result.reason).toContain("init script shape not recognized"); + expect(fs.readFileSync(resolverPath, "utf-8")).toBe(originalResolver); + }); + + it("refuses resolver symlinks that escape the VM rootfs", () => { + const stateDir = makeTempDir(); + const rootfs = sandboxRootfs(stateDir); + const outside = path.join(stateDir, "outside-resolv.conf"); + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.writeFileSync(outside, "nameserver 8.8.8.8\n"); + fs.symlinkSync(outside, path.join(rootfs, "etc", "resolv.conf")); + writeRecognizedInit(rootfs); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: false, + ok: false, + status: "failed", + }); + expect(result.reason).toContain("resolves outside VM rootfs"); + expect(fs.readFileSync(outside, "utf-8")).toBe("nameserver 8.8.8.8\n"); + }); + + it("refuses dangling resolver symlinks before writing", () => { + const stateDir = makeTempDir(); + const rootfs = sandboxRootfs(stateDir); + const outside = path.join(stateDir, "missing-resolv.conf"); + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.symlinkSync(outside, path.join(rootfs, "etc", "resolv.conf")); + writeRecognizedInit(rootfs); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: false, + ok: false, + status: "failed", + }); + expect(result.reason).toContain("dangling symlink"); + expect(fs.existsSync(outside)).toBe(false); + }); + + it("returns a warning result instead of throwing when rootfs files cannot be patched", () => { + const stateDir = makeTempDir(); + const rootfs = sandboxRootfs(stateDir); + fs.mkdirSync(path.join(rootfs, "etc", "resolv.conf"), { recursive: true }); + writeRecognizedInit(rootfs); + + const result = applyOpenShellVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + capture: () => ({ status: 0, output: "Id: abc\n" }), + platform: "darwin", + stateDir, + }, + ); + + expect(result).toMatchObject({ + attempted: true, + changed: false, + ok: false, + rootfs: fs.realpathSync.native(rootfs), + status: "failed", + }); + expect(result.reason).toContain("failed to patch VM DNS files"); + }); +}); diff --git a/src/lib/actions/sandbox/vm-dns-monkeypatch.ts b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts new file mode 100644 index 0000000000..73235f8084 --- /dev/null +++ b/src/lib/actions/sandbox/vm-dns-monkeypatch.ts @@ -0,0 +1,393 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + type CaptureOpenshellResult, + stripAnsi, +} from "../../adapters/openshell/client"; +import { captureOpenshell } from "../../adapters/openshell/runtime"; +import type { SandboxEntry } from "../../state/registry"; + +const GVPROXY_DNS = "192.168.127.1"; +const INIT_SCRIPT_RELATIVE_PATH = ["srv", "openshell-vm-sandbox-init.sh"] as const; +const RESOLV_CONF_RELATIVE_PATH = ["etc", "resolv.conf"] as const; +const GVPROXY_RESOLVER_LINE = "nameserver ${GVPROXY_GATEWAY_IP}"; +const PUBLIC_FALLBACK_DNS = new Set(["8.8.8.8", "8.8.4.4"]); +const INIT_PUBLIC_FALLBACK_BLOCK_RE = + /^([ \t]*)if\s+\[\s*!\s+-s\s+\/etc\/resolv\.conf\s*\]\s*;\s*then\s*\r?\n[ \t]*(?:echo|printf)\b[^\n]*8\.8\.8\.8[^\n]*>\s*\/etc\/resolv\.conf[^\n]*\r?\n[ \t]*(?:echo|printf)\b[^\n]*8\.8\.4\.4[^\n]*>>\s*\/etc\/resolv\.conf[^\n]*\r?\n[ \t]*fi/gm; +const INIT_ETH0_PUBLIC_FALLBACK_RE = + /ip\s+link\s+show\s+eth0[\s\S]{0,2000}nameserver\s+8\.8\.8\.8[\s\S]{0,2000}nameserver\s+8\.8\.4\.4/; + +type CaptureFn = ( + args: string[], + opts: { ignoreError?: boolean; timeout?: number }, +) => CaptureOpenshellResult; + +export type VmDnsMonkeypatchStatus = "skipped" | "applied" | "already-present" | "failed"; + +export type VmDnsMonkeypatchResult = { + attempted: boolean; + changed: boolean; + ok: boolean; + reason?: string; + rootfs?: string; + status?: VmDnsMonkeypatchStatus; +}; + +export function shouldApplyVmDnsMonkeypatch( + entry: Pick | null | undefined, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.NEMOCLAW_DISABLE_VM_DNS_MONKEYPATCH === "1") return false; + if (entry?.openshellDriver !== "vm") return false; + return platform === "darwin" || env.NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH === "1"; +} + +function dockerDriverGatewayStateDir(env: NodeJS.ProcessEnv, homeDir: string): string { + const configured = env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; + if (configured && configured.trim()) return path.resolve(configured.trim()); + return path.join(homeDir, ".local", "state", "nemoclaw", "openshell-docker-gateway"); +} + +export function parseSandboxIdFromGetOutput(output: string): string | null { + const match = stripAnsi(output).match(/^\s*(?:Id|ID):\s*([A-Za-z0-9._-]+)\s*$/m); + return match?.[1] ?? null; +} + +function readTextFileIfPresent(filePath: string): string | null { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isPathInside(childPath: string, parentPath: string): boolean { + const relative = path.relative(parentPath, childPath); + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function realpathIfPresent(filePath: string): string | null { + try { + return fs.realpathSync.native(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function lstatIfPresent(filePath: string): fs.Stats | null { + try { + return fs.lstatSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function fail(reason: string, rootfs?: string, changed = false): VmDnsMonkeypatchResult { + return { + attempted: true, + changed, + ok: false, + reason, + rootfs, + status: "failed", + }; +} + +function skipped(reason: string): VmDnsMonkeypatchResult { + return { + attempted: false, + changed: false, + ok: false, + reason, + status: "skipped", + }; +} + +function shouldSkipVmDnsMonkeypatch( + entry: Pick | null | undefined, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | null { + if (env.NEMOCLAW_DISABLE_VM_DNS_MONKEYPATCH === "1") { + return "disabled by NEMOCLAW_DISABLE_VM_DNS_MONKEYPATCH=1"; + } + if (entry?.openshellDriver !== "vm") return "not an OpenShell VM sandbox"; + if (platform !== "darwin" && env.NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH !== "1") { + return "not running on macOS"; + } + return null; +} + +function ext4RootDiskCandidates(sandboxDir: string): string[] { + try { + return fs + .readdirSync(sandboxDir) + .filter((entry) => /(?:^|[-_.])(?:rootfs|root|disk).*(?:ext4|\.img$|\.raw$)/i.test(entry)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +function resolveTargetInsideRootfs( + rootfsReal: string, + relativePath: readonly string[], + opts: { mustExist?: boolean } = {}, +): { ok: true; path: string } | { ok: false; reason: string } { + const target = path.join(rootfsReal, ...relativePath); + const targetReal = realpathIfPresent(target); + if (targetReal) { + if (!isPathInside(targetReal, rootfsReal)) { + return { + ok: false, + reason: `refusing to patch ${path.join(...relativePath)} because it resolves outside VM rootfs: ${targetReal}`, + }; + } + return { ok: true, path: targetReal }; + } + + if (lstatIfPresent(target)?.isSymbolicLink()) { + return { + ok: false, + reason: `refusing to patch ${path.join(...relativePath)} because it is a dangling symlink: ${target}`, + }; + } + + if (opts.mustExist) { + return { + ok: false, + reason: `OpenShell VM file not found: ${target}`, + }; + } + + const parentReal = realpathIfPresent(path.dirname(target)); + if (!parentReal) { + return { + ok: false, + reason: `OpenShell VM directory not found: ${path.dirname(target)}`, + }; + } + const resolvedTarget = path.join(parentReal, path.basename(target)); + if (!isPathInside(resolvedTarget, rootfsReal)) { + return { + ok: false, + reason: `refusing to patch ${path.join(...relativePath)} because its parent resolves outside VM rootfs: ${resolvedTarget}`, + }; + } + return { ok: true, path: resolvedTarget }; +} + +function normalizeResolver(current: string): string { + const lines = current.replace(/\r\n/g, "\n").split("\n"); + const next: string[] = [`nameserver ${GVPROXY_DNS}`]; + const seenNameservers = new Set([GVPROXY_DNS, ...PUBLIC_FALLBACK_DNS]); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const nameserverMatch = trimmed.match(/^nameserver\s+(\S+)(?:\s+.*)?$/); + if (nameserverMatch) { + const resolver = nameserverMatch[1]; + if (seenNameservers.has(resolver)) continue; + seenNameservers.add(resolver); + next.push(line.trimEnd()); + continue; + } + + next.push(line.trimEnd()); + } + + return `${next.join("\n")}\n`; +} + +function buildGvproxyDnsBlock(indent: string): string { + return [ + `${indent}if [ -n "\${GVPROXY_GATEWAY_IP:-}" ]; then`, + `${indent} echo "${GVPROXY_RESOLVER_LINE}" > /etc/resolv.conf`, + `${indent}else`, + `${indent} echo "nameserver ${GVPROXY_DNS}" > /etc/resolv.conf`, + `${indent}fi`, + ].join("\n"); +} + +function buildGuestInitPatch(initPath: string): + | { ok: true; changed: boolean; content?: string } + | { ok: false; reason: string } { + if (path.basename(initPath) !== INIT_SCRIPT_RELATIVE_PATH.at(-1)) { + return { + ok: false, + reason: `refusing to patch unexpected OpenShell VM init script path: ${initPath}`, + }; + } + + const original = readTextFileIfPresent(initPath); + if (original === null) { + return { + ok: false, + reason: `OpenShell VM init script not found: ${initPath}`, + }; + } + if (original.includes(GVPROXY_RESOLVER_LINE)) return { ok: true, changed: false }; + + const hasGvproxyEvidence = + original.includes("GVPROXY_GATEWAY_IP") || INIT_ETH0_PUBLIC_FALLBACK_RE.test(original); + if (!hasGvproxyEvidence) { + return { + ok: false, + reason: "OpenShell VM init script shape not recognized; no gvproxy DNS evidence found", + }; + } + + const patched = original.replace(INIT_PUBLIC_FALLBACK_BLOCK_RE, (match, indent: string) => + buildGvproxyDnsBlock(indent), + ); + if (patched === original) { + return { + ok: false, + reason: "OpenShell VM init script public-DNS fallback block was not recognized", + }; + } + if (!patched.includes(GVPROXY_RESOLVER_LINE)) { + return { + ok: false, + reason: "OpenShell VM init script patch did not produce the gvproxy resolver line", + }; + } + return { ok: true, changed: true, content: patched }; +} + +export function applyOpenShellVmDnsMonkeypatch( + sandboxName: string, + entry: Pick | null | undefined, + deps: { + capture?: CaptureFn; + env?: NodeJS.ProcessEnv; + homeDir?: string; + platform?: NodeJS.Platform; + stateDir?: string; + } = {}, +): VmDnsMonkeypatchResult { + const env = deps.env ?? process.env; + const platform = deps.platform ?? process.platform; + const skipReason = shouldSkipVmDnsMonkeypatch(entry, platform, env); + if (skipReason) { + return skipped(skipReason); + } + + const capture = deps.capture ?? captureOpenshell; + const get = capture(["sandbox", "get", sandboxName], { + ignoreError: true, + timeout: 10_000, + }); + const sandboxId = parseSandboxIdFromGetOutput(get.output || ""); + if (!sandboxId) { + return fail("could not resolve OpenShell sandbox id"); + } + + const stateDir = + deps.stateDir ?? dockerDriverGatewayStateDir(env, deps.homeDir ?? os.homedir()); + const stateDirPath = path.resolve(stateDir); + const stateDirReal = realpathIfPresent(stateDirPath); + if (!stateDirReal) { + return fail(`OpenShell VM state directory not found: ${stateDirPath}`); + } + + let changed = false; + let rootfsContext: string | undefined; + try { + const sandboxDir = path.join(stateDirReal, "vm-driver", "sandboxes", sandboxId); + const sandboxDirReal = realpathIfPresent(sandboxDir); + if (!sandboxDirReal) { + return fail(`OpenShell VM sandbox directory not found: ${sandboxDir}`); + } + + const sandboxesDirReal = path.join(stateDirReal, "vm-driver", "sandboxes"); + if (!isPathInside(sandboxDirReal, sandboxesDirReal)) { + return fail( + `refusing to patch VM sandbox because its directory resolves outside OpenShell state: ${sandboxDirReal}`, + ); + } + + const rootfs = path.join(sandboxDirReal, "rootfs"); + const rootfsReal = realpathIfPresent(rootfs); + if (!rootfsReal) { + const diskCandidates = ext4RootDiskCandidates(sandboxDirReal); + if (diskCandidates.length > 0) { + return fail( + `OpenShell VM sandbox appears to use an ext4 root disk layout (${diskCandidates.join(", ")}); NemoClaw's rootfs DNS monkeypatch no longer applies`, + ); + } + return fail(`VM rootfs not found: ${rootfs}`); + } + rootfsContext = rootfsReal; + if (!isPathInside(rootfsReal, sandboxDirReal)) { + return fail( + `refusing to patch VM DNS because rootfs resolves outside OpenShell sandbox directory: ${rootfsReal}`, + rootfsReal, + ); + } + + const initScript = resolveTargetInsideRootfs(rootfsReal, INIT_SCRIPT_RELATIVE_PATH, { + mustExist: true, + }); + if (!initScript.ok) return fail(initScript.reason, rootfsReal); + + const resolvConf = resolveTargetInsideRootfs(rootfsReal, RESOLV_CONF_RELATIVE_PATH); + if (!resolvConf.ok) return fail(resolvConf.reason, rootfsReal); + + const initPatch = buildGuestInitPatch(initScript.path); + if (!initPatch.ok) return fail(initPatch.reason, rootfsReal); + + const currentResolver = readTextFileIfPresent(resolvConf.path) ?? ""; + const desiredResolver = normalizeResolver(currentResolver); + if (currentResolver !== desiredResolver) { + fs.writeFileSync(resolvConf.path, desiredResolver); + changed = true; + } + + if (initPatch.changed && initPatch.content !== undefined) { + fs.writeFileSync(initScript.path, initPatch.content); + changed = true; + } + + const verifiedInit = readTextFileIfPresent(initScript.path) ?? ""; + if (!verifiedInit.includes(GVPROXY_RESOLVER_LINE)) { + return fail( + "OpenShell VM init script patch verification failed: gvproxy resolver line missing", + rootfsReal, + changed, + ); + } + + return { + attempted: true, + changed, + ok: true, + rootfs: rootfsReal, + status: changed ? "applied" : "already-present", + }; + } catch (error) { + return { + attempted: true, + changed, + ok: false, + reason: `failed to patch VM DNS files: ${errorMessage(error)}`, + rootfs: rootfsContext, + status: "failed", + }; + } +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 4da549d7eb..edd7702b86 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1971,20 +1971,14 @@ function getMessagingChannelForEnvKey(envKey: string): string | null { return null; } -function getKnownMessagingChannels(channels: string[] | null | undefined): string[] { - if (!Array.isArray(channels)) return []; - const known = new Set(MESSAGING_CHANNELS.map((channel) => channel.name)); - return [...new Set(channels.filter((channel) => known.has(channel)))]; -} function getRecordedMessagingChannelsForResume( resume: boolean, - session: Session | null, + session: Session | null, sandboxName: string | null, ): string[] | null { - if (!resume || !isNonInteractive() || !Array.isArray(session?.messagingChannels)) { - return null; - } - return getKnownMessagingChannels(session.messagingChannels); + return require("./onboard/messaging-reuse").getNonInteractiveStoredMessagingChannels( + resume, session?.messagingChannels, sandboxName, MESSAGING_CHANNELS, (envKey: string) => Boolean(getCredential(envKey) || normalizeCredentialValue(process.env[envKey])), + registry.getSandbox.bind(registry), registry.getDisabledChannels.bind(registry), providerExistsInGateway, isNonInteractive()); } /** @@ -4833,7 +4827,7 @@ function getSandboxRuntimeRegistryFields( sandboxGpuEnabled: config.sandboxGpuEnabled, sandboxGpuMode: config.mode, sandboxGpuDevice: config.sandboxGpuDevice, - openshellDriver: isLinuxDockerDriverGatewayEnabled() ? "docker" : "kubernetes", + openshellDriver: isLinuxDockerDriverGatewayEnabled() ? (process.platform === "darwin" ? "vm" : "docker") : "kubernetes", openshellVersion: getInstalledOpenshellVersion( runCaptureOpenshell(["--version"], { ignoreError: true }), ), @@ -6085,11 +6079,12 @@ async function createSandbox( ? builtImageMatch[1] : `openshell/sandbox-from:${buildId}`; + const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); registry.registerSandbox({ name: sandboxName, model: model || null, provider: provider || null, - ...getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig), + ...sandboxRuntimeFields, ...getSandboxAgentRegistryFields(agent, !fromDockerfile), imageTag: resolvedImageTag, providerCredentialHashes: @@ -6128,13 +6123,15 @@ async function createSandbox( // DNS proxy — run a forwarder in the sandbox pod so the isolated // sandbox namespace can resolve hostnames (fixes #626). - if (!isLinuxDockerDriverGatewayEnabled()) { + if (sandboxRuntimeFields.openshellDriver === "kubernetes") { console.log(" Setting up sandbox DNS proxy..."); runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], { ignoreError: true, }); } + require("./onboard/vm-dns-monkeypatch").applyOnboardVmDnsMonkeypatch(sandboxName, sandboxRuntimeFields); + // Check that messaging providers exist in the gateway (sandbox attachment // cannot be verified via CLI yet — only gateway-level existence is checked). for (const p of messagingProviders) { @@ -10633,12 +10630,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { nextWebSearchConfig = await configureWebSearch(null, agent, webSearchSupportProbePath); } startRecordedStep("sandbox", { provider, model }); - const recordedMessagingChannels = getRecordedMessagingChannelsForResume(resume, session); + const recordedMessagingChannels = getRecordedMessagingChannelsForResume(resume, session, sandboxName); if (recordedMessagingChannels) { selectedMessagingChannels = recordedMessagingChannels; if (selectedMessagingChannels.length > 0) { note( - ` [resume] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`, + ` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`, ); } } else { diff --git a/src/lib/onboard/messaging-reuse.test.ts b/src/lib/onboard/messaging-reuse.test.ts new file mode 100644 index 0000000000..a41aa107c1 --- /dev/null +++ b/src/lib/onboard/messaging-reuse.test.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + getMessagingProviderNamesForChannel, + getNonInteractiveStoredMessagingChannels, +} from "./messaging-reuse"; + +const messagingChannels = [ + { name: "discord", envKey: "DISCORD_BOT_TOKEN" }, + { name: "slack", envKey: "SLACK_BOT_TOKEN" }, +]; + +describe("onboard messaging reuse", () => { + it("requires both Slack providers before reusing a stored Slack channel", () => { + expect(getMessagingProviderNamesForChannel("assistant", "slack")).toEqual([ + "assistant-slack-bridge", + "assistant-slack-app", + ]); + + const reusedChannels = getNonInteractiveStoredMessagingChannels( + false, + null, + "assistant", + messagingChannels, + () => false, + () => ({ messagingChannels: ["slack"] }), + () => [], + (provider) => provider === "assistant-slack-bridge", + true, + ); + + expect(reusedChannels).toBeNull(); + }); + + it("reuses stored Slack channels when both Slack providers exist", () => { + const reusedChannels = getNonInteractiveStoredMessagingChannels( + false, + null, + "assistant", + messagingChannels, + () => false, + () => ({ messagingChannels: ["slack"] }), + () => [], + (provider) => + provider === "assistant-slack-bridge" || provider === "assistant-slack-app", + true, + ); + + expect(reusedChannels).toEqual(["slack"]); + }); + + it("normalizes empty resume messaging channels to null", () => { + const reusedChannels = getNonInteractiveStoredMessagingChannels( + true, + ["unknown"], + "assistant", + messagingChannels, + () => false, + () => ({ messagingChannels: ["discord"] }), + () => [], + () => true, + true, + ); + + expect(reusedChannels).toBeNull(); + }); +}); diff --git a/src/lib/onboard/messaging-reuse.ts b/src/lib/onboard/messaging-reuse.ts new file mode 100644 index 0000000000..10b71a0e55 --- /dev/null +++ b/src/lib/onboard/messaging-reuse.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type MessagingChannel = { name: string; envKey: string }; +type SandboxEntry = { messagingChannels?: string[] | null } | null | undefined; + +export 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`, `${sandboxName}-slack-app`]; + return []; +} + +function getKnownMessagingChannels( + channels: string[] | null | undefined, + messagingChannels: readonly MessagingChannel[], +): string[] { + if (!Array.isArray(channels)) return []; + const known = new Set(messagingChannels.map((channel) => channel.name)); + return [...new Set(channels.filter((channel) => known.has(channel)))]; +} + +export function getNonInteractiveStoredMessagingChannels( + resume: boolean, + sessionChannels: string[] | null | undefined, + sandboxName: string | null, + messagingChannels: readonly MessagingChannel[], + hasMessagingToken: (envKey: string) => boolean, + getSandbox: (sandboxName: string) => SandboxEntry, + getDisabledChannels: (sandboxName: string) => string[], + providerExists: (providerName: string) => boolean, + nonInteractive: boolean, +): string[] | null { + if (!nonInteractive) return null; + if (resume && Array.isArray(sessionChannels)) { + const knownSessionChannels = getKnownMessagingChannels(sessionChannels, messagingChannels); + return knownSessionChannels.length > 0 ? knownSessionChannels : null; + } + if (resume || !sandboxName || messagingChannels.some((channel) => hasMessagingToken(channel.envKey))) { + return null; + } + + const configuredChannels = getKnownMessagingChannels( + getSandbox(sandboxName)?.messagingChannels, + messagingChannels, + ); + const disabledChannels = new Set(getDisabledChannels(sandboxName)); + const reusableChannels = configuredChannels.filter((channel) => { + if (disabledChannels.has(channel)) return false; + const providers = getMessagingProviderNamesForChannel(sandboxName, channel); + return providers.length > 0 && providers.every((provider) => providerExists(provider)); + }); + return reusableChannels.length > 0 ? reusableChannels : null; +} diff --git a/src/lib/onboard/vm-dns-monkeypatch.ts b/src/lib/onboard/vm-dns-monkeypatch.ts new file mode 100644 index 0000000000..6ed57f7c0a --- /dev/null +++ b/src/lib/onboard/vm-dns-monkeypatch.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + applyOpenShellVmDnsMonkeypatch, + type VmDnsMonkeypatchResult, +} from "../actions/sandbox/vm-dns-monkeypatch"; + +type OnboardVmDnsMonkeypatchDeps = { + apply?: typeof applyOpenShellVmDnsMonkeypatch; + log?: (message: string) => void; + warn?: (message: string) => void; +}; + +export function applyOnboardVmDnsMonkeypatch( + sandboxName: string, + runtime: { openshellDriver?: string | null }, + deps: OnboardVmDnsMonkeypatchDeps = {}, +): void { + const apply = deps.apply ?? applyOpenShellVmDnsMonkeypatch; + const log = deps.log ?? console.log; + const warn = deps.warn ?? console.error; + const vmDnsPatch: VmDnsMonkeypatchResult = apply(sandboxName, { + openshellDriver: runtime.openshellDriver, + }); + if (vmDnsPatch.ok && vmDnsPatch.changed) { + log(" ✓ Applied OpenShell VM DNS monkeypatch"); + } else if (vmDnsPatch.ok && vmDnsPatch.attempted) { + log(" OpenShell VM DNS monkeypatch already present"); + } else if ( + vmDnsPatch.status === "skipped" && + runtime.openshellDriver === "vm" && + vmDnsPatch.reason + ) { + log(` OpenShell VM DNS monkeypatch skipped: ${vmDnsPatch.reason}`); + } else if (vmDnsPatch.attempted && !vmDnsPatch.ok && vmDnsPatch.reason) { + warn(` Warning: OpenShell VM DNS monkeypatch did not apply: ${vmDnsPatch.reason}`); + } +} diff --git a/src/lib/sandbox/create-stream.test.ts b/src/lib/sandbox/create-stream.test.ts index 08f30fbade..96d210de7b 100644 --- a/src/lib/sandbox/create-stream.test.ts +++ b/src/lib/sandbox/create-stream.test.ts @@ -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(); @@ -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, }); @@ -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, @@ -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; @@ -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("can abort a stuck create stream from a failure check", async () => { vi.useFakeTimers(); @@ -170,7 +232,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 @@ -190,6 +252,27 @@ describe("sandbox-create-stream", () => { }); }); + it("recovers when required startup output is the final partial line", 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.stderr.emit("data", Buffer.from("Created sandbox: demo\nSetting up NemoClaw")); + child.emit("close", 255); + + await expect(promise).resolves.toMatchObject({ + status: 0, + forcedReady: true, + output: expect.stringContaining("Setting up NemoClaw"), + }); + }); + it("returns non-zero when readyCheck is false at close time", async () => { const child = new FakeChild(); const promise = streamSandboxCreate("echo create", process.env, { diff --git a/src/lib/sandbox/create-stream.ts b/src/lib/sandbox/create-stream.ts index 3d451b12b3..7f0bdd3bf6 100644 --- a/src/lib/sandbox/create-stream.ts +++ b/src/lib/sandbox/create-stream.ts @@ -19,6 +19,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 @@ -91,10 +95,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, @@ -111,6 +136,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; @@ -173,6 +204,9 @@ export function streamSandboxCreate( if (!line) return; lines.push(line); lastOutputAt = Date.now(); + if (!readyCheckOutputMatched && matchesAny(line, readyCheckOutputPatterns)) { + readyCheckOutputMatched = true; + } if (/^ {2}Built image /.test(line)) { setPhase("create"); } else if (matchesAny(line, BUILD_PROGRESS_PATTERNS)) { @@ -201,10 +235,17 @@ export function streamSandboxCreate( parts.forEach(flushLine); } + function flushPendingLine() { + if (!pending) return; + const trailing = pending; + pending = ""; + flushLine(trailing); + } + function finish(status: number, overrides: Partial = {}) { if (settled) return; settled = true; - if (pending) flushLine(pending); + flushPendingLine(); if (readyTimer) clearInterval(readyTimer); clearInterval(heartbeatTimer); resolvePromise({ @@ -241,6 +282,16 @@ export function streamSandboxCreate( } if (ready) { 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}`); @@ -317,9 +368,10 @@ export function streamSandboxCreate( child.on("close", (code) => { // One last ready-check: the sandbox may have become Ready between the // last poll tick and the stream exit (e.g. SSH 255 after "Created sandbox:"). + flushPendingLine(); if (code && code !== 0 && options.readyCheck) { try { - if (options.readyCheck()) { + if (options.readyCheck() && readyCheckOutputMatched) { finish(0, { forcedReady: true }); return; } diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index af032383ed..db3e799bc1 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -162,6 +162,34 @@ describe("agents/hermes/generate-config.ts", () => { expect(config.discord.require_mention).toBe(false); }); + it("allows Discord server members when no explicit user allowlist is configured", () => { + const { envFile } = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["discord"]), + NEMOCLAW_DISCORD_GUILDS_B64: encodeJson({ + "1491590992753590594": { + requireMention: false, + }, + }), + }); + + expect(envFile).toContain("DISCORD_ALLOW_ALL_USERS=true\n"); + expect(envFile).not.toContain("DISCORD_ALLOWED_USERS="); + }); + + it("does not allow all Discord users for empty guild config keys", () => { + const { envFile } = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["discord"]), + NEMOCLAW_DISCORD_GUILDS_B64: encodeJson({ + " ": { + requireMention: false, + }, + }), + }); + + expect(envFile).not.toContain("DISCORD_ALLOW_ALL_USERS=true\n"); + expect(envFile).not.toContain("DISCORD_ALLOWED_USERS="); + }); + it("does not emit generic platforms blocks for Telegram or Slack messaging tokens", () => { const { config, envFile } = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["telegram", "slack"]), diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 04879d16eb..6e030b6e4c 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -16,6 +16,7 @@ import { buildChain, buildControlUiUrls } from "../dist/lib/dashboard/contract.j import { NAME_ALLOWED_FORMAT } from "../dist/lib/name-validation.js"; import { hasOpenShellVmDriverChildProcessFromPsOutput } from "../dist/lib/onboard/vm-driver-process.js"; import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox/build-context.js"; +import { applyOnboardVmDnsMonkeypatch } from "../dist/lib/onboard/vm-dns-monkeypatch.js"; import { testTimeoutOptions } from "./helpers/timeouts"; type ShimScalar = string | number | boolean | null | undefined; @@ -2970,7 +2971,7 @@ const { loadAgent } = require(${agentDefsPath}); expect(getGatewayReuseState("", "")).toBe("missing"); }); - it("prints doctor logs automatically when gateway fails to start (#1605)", () => { + it("prints doctor logs automatically when gateway fails to start (#1605)", testTimeoutOptions(20_000), () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-diag-")); const fakeBin = path.join(tmpDir, "bin"); @@ -3400,6 +3401,106 @@ startGateway(null).catch(() => {}); ).toBe(false); }); + it("runs the OpenShell VM DNS monkeypatch after sandbox registration", () => { + const onboardSource = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf8", + ); + + assert.match( + onboardSource, + /registry\.setDefault\(sandboxName\);[\s\S]*applyOnboardVmDnsMonkeypatch\(sandboxName, sandboxRuntimeFields\)/, + ); + }); + + it("logs applied only when the onboard VM DNS monkeypatch changes files", () => { + const changedLogs: string[] = []; + applyOnboardVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + apply: () => ({ + attempted: true, + changed: true, + ok: true, + status: "applied", + }), + log: (message) => changedLogs.push(message), + warn: (message) => changedLogs.push(message), + }, + ); + + const unchangedLogs: string[] = []; + applyOnboardVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + apply: () => ({ + attempted: true, + changed: false, + ok: true, + status: "already-present", + }), + log: (message) => unchangedLogs.push(message), + warn: (message) => unchangedLogs.push(message), + }, + ); + + expect(changedLogs).toEqual([" ✓ Applied OpenShell VM DNS monkeypatch"]); + expect(unchangedLogs).toEqual([" OpenShell VM DNS monkeypatch already present"]); + expect(unchangedLogs.join("\n")).not.toContain("Applied"); + }); + + it("logs skipped VM DNS monkeypatch state for VM sandboxes", () => { + const logs: string[] = []; + + applyOnboardVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + apply: () => ({ + attempted: false, + changed: false, + ok: false, + reason: "disabled by NEMOCLAW_DISABLE_VM_DNS_MONKEYPATCH=1", + status: "skipped", + }), + log: (message) => logs.push(message), + warn: (message) => logs.push(message), + }, + ); + + expect(logs).toEqual([ + " OpenShell VM DNS monkeypatch skipped: disabled by NEMOCLAW_DISABLE_VM_DNS_MONKEYPATCH=1", + ]); + }); + + it("warns without aborting when the onboard VM DNS monkeypatch fails", () => { + const warnings: string[] = []; + + expect(() => + applyOnboardVmDnsMonkeypatch( + "demo", + { openshellDriver: "vm" }, + { + apply: () => ({ + attempted: true, + changed: false, + ok: false, + reason: "VM rootfs not found", + status: "failed", + }), + log: (message) => warnings.push(message), + warn: (message) => warnings.push(message), + }, + ), + ).not.toThrow(); + + expect(warnings).toEqual([ + " Warning: OpenShell VM DNS monkeypatch did not apply: VM rootfs not found", + ]); + }); + it("writes sandbox sync scripts to a temp file for stdin redirection", () => { const scriptFile = writeSandboxConfigSyncFile("echo test"); try { @@ -3832,7 +3933,7 @@ const { setupInference } = require(${onboardPath}); ); }); - it("configures Model Router as a host provider while sandboxes keep inference.local", () => { + it("configures Model Router as a host provider while sandboxes keep inference.local", testTimeoutOptions(60_000), () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-inference-")); const fakeBin = path.join(tmpDir, "bin"); @@ -4241,7 +4342,7 @@ const { setupInference } = require(${onboardPath}); } }); - it("prefers the managed Model Router command over PATH", () => { + it("prefers the managed Model Router command over PATH", testTimeoutOptions(60_000), () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-managed-")); const fakeBin = path.join(tmpDir, "bin"); @@ -4422,7 +4523,7 @@ const { setupInference } = require(${onboardPath}); } }); - it("refreshes stale managed Model Router command when source fingerprint changes", () => { + it("refreshes stale managed Model Router command when source fingerprint changes", testTimeoutOptions(60_000), () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-refresh-")); const fakeBin = path.join(tmpDir, "bin"); @@ -5350,9 +5451,9 @@ ${webSearchVerifySource}`; assert.match( source, - // #2753: sandboxName is intentionally absent from the options here so - // the session does not record a name before createSandbox completes. - /startRecordedStep\("sandbox", \{ provider, model \}\);\s*const recordedMessagingChannels = getRecordedMessagingChannelsForResume\(resume, session\);[\s\S]*?selectedMessagingChannels = recordedMessagingChannels;[\s\S]*?selectedMessagingChannels = await setupMessagingChannels\(\);[\s\S]*?const messagingChannelConfig = readMessagingChannelConfigFromEnv\(\);[\s\S]*?onboardSession\.updateSession\(\(current[^)]*\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*current\.messagingChannelConfig = messagingChannelConfig;\s*return current;\s*\}\);[\s\S]*?sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*opts\.controlUiPort \|\| null,\s*sandboxGpuConfig,\s*\);/, + // #2753: sandboxName is read for resume hints here, but the session still + // does not persist a sandbox name before createSandbox completes. + /startRecordedStep\("sandbox", \{ provider, model \}\);\s*const recordedMessagingChannels = getRecordedMessagingChannelsForResume\(resume, session, sandboxName\);[\s\S]*?selectedMessagingChannels = recordedMessagingChannels;[\s\S]*?selectedMessagingChannels = await setupMessagingChannels\(\);[\s\S]*?const messagingChannelConfig = readMessagingChannelConfigFromEnv\(\);[\s\S]*?onboardSession\.updateSession\(\(current[^)]*\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*current\.messagingChannelConfig = messagingChannelConfig;\s*return current;\s*\}\);[\s\S]*?sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*opts\.controlUiPort \|\| null,\s*sandboxGpuConfig,\s*\);/, ); }); @@ -8190,6 +8291,7 @@ const { createSandbox } = require(${onboardPath}); HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", + OPENSHELL_DRIVERS: "docker", }, timeout: 15000, }); @@ -8884,6 +8986,22 @@ const { setupMessagingChannels } = require(${onboardPath}); }, ); + it("non-interactive onboard reuses stored messaging channels when bridge providers exist", () => { + const source = fs.readFileSync(path.join(import.meta.dirname, "../src/lib/onboard.ts"), "utf-8"); + const reuseSource = fs.readFileSync( + path.join(import.meta.dirname, "../src/lib/onboard/messaging-reuse.ts"), + "utf-8", + ); + assert.match( + reuseSource, + /function getNonInteractiveStoredMessagingChannels\([\s\S]*?getSandbox\(sandboxName\)[\s\S]*?providerExists\(provider\)/, + ); + assert.match( + source, + /getRecordedMessagingChannelsForResume\(resume, session, sandboxName\)[\s\S]*?selectedMessagingChannels = await setupMessagingChannels\(\)/, + ); + }); + it( "interactive setupMessagingChannels drops slack when prompted token fails tokenFormat check (#1912)", { timeout: 60_000 }, @@ -9196,7 +9314,7 @@ const { setupMessagingChannels, MESSAGING_CHANNELS } = require(${onboardPath}); } }); - it("uses the custom Dockerfile parent directory as build context when --from is given", async () => { + it("uses the custom Dockerfile parent directory as build context when --from is given", testTimeoutOptions(60_000), async () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-dockerfile-")); const fakeBin = path.join(tmpDir, "bin"); diff --git a/test/policies.test.ts b/test/policies.test.ts index 81a89f2db2..70e5925f4e 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -832,6 +832,11 @@ exit 1 host: "gateway.discord.gg", credentialRewrite: true, }, + { + preset: "discord", + host: "*.discord.gg", + credentialRewrite: true, + }, { preset: "slack", host: "wss-primary.slack.com", @@ -929,6 +934,7 @@ exit 1 ]; const cases = [ "gateway.discord.gg", + "*.discord.gg", "wss-primary.slack.com", "wss-backup.slack.com", ]; diff --git a/test/sandbox-connect-inference.test.ts b/test/sandbox-connect-inference.test.ts index 98e5a4a225..192ce25bf4 100644 --- a/test/sandbox-connect-inference.test.ts +++ b/test/sandbox-connect-inference.test.ts @@ -21,11 +21,13 @@ type SandboxEntryFixture = { provider?: string | null; nimContainer?: string | null; gpuEnabled?: boolean; + openshellDriver?: string | null; policies?: string[]; }; type SetupFixtureOptions = { inferenceProbeResponses?: string[]; + inferenceSetStatus?: number; }; function setupFixture( @@ -92,7 +94,7 @@ if (args[0] === "gateway" && args[1] === "info") { } if (args[0] === "sandbox" && args[1] === "get" && args[2] === ${JSON.stringify(sandboxName)}) { - process.stdout.write("Sandbox:\\n\\n Id: abc\\n Name: ${sandboxName}\\n Phase: Ready\\n"); + process.stdout.write("Sandbox:\\n\\n \\x1b[2mId:\\x1b[0m abc\\n Name: ${sandboxName}\\n Phase: Ready\\n"); process.exit(0); } @@ -128,7 +130,7 @@ if (args[0] === "inference" && args[1] === "get") { if (args[0] === "inference" && args[1] === "set") { state.inferenceSetCalls.push(args.slice(2)); fs.writeFileSync(stateFile, JSON.stringify(state)); - process.exit(0); + process.exit(${JSON.stringify(options.inferenceSetStatus ?? 0)}); } if (args[0] === "logs") { @@ -213,7 +215,40 @@ process.exit(0); return { tmpDir, stateFile, sandboxName }; } -function runConnect(tmpDir: string, sandboxName: string) { +function createVmRootfs(tmpDir: string, sandboxId = "abc") { + const rootfs = path.join( + tmpDir, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway", + "vm-driver", + "sandboxes", + sandboxId, + "rootfs", + ); + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); + fs.writeFileSync( + path.join(rootfs, "etc", "resolv.conf"), + "nameserver 8.8.8.8\nnameserver 8.8.4.4\n", + ); + fs.writeFileSync( + path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), + [ + "elif ip link show eth0 >/dev/null 2>&1; then", + " if [ ! -s /etc/resolv.conf ]; then", + ' echo "nameserver 8.8.8.8" > /etc/resolv.conf', + ' echo "nameserver 8.8.4.4" >> /etc/resolv.conf', + " fi", + "fi", + "", + ].join("\n"), + ); + return rootfs; +} + +function runConnect(tmpDir: string, sandboxName: string, extraEnv: NodeJS.ProcessEnv = {}) { const repoRoot = path.join(import.meta.dirname, ".."); return spawnSync( process.execPath, @@ -226,6 +261,7 @@ function runConnect(tmpDir: string, sandboxName: string) { HOME: tmpDir, PATH: `${path.join(tmpDir, ".local", "bin")}:/usr/bin:/bin`, NEMOCLAW_NO_CONNECT_HINT: "1", + ...extraEnv, }, timeout: execTimeout(15_000), }, @@ -327,6 +363,7 @@ describe("sandbox connect inference route swap (#1248)", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", gpuEnabled: false, + openshellDriver: "docker", policies: [], }, "nvidia-prod", @@ -357,4 +394,186 @@ describe("sandbox connect inference route swap (#1248)", () => { expect(combined).toContain("inference.local route repaired"); }, ); + + it( + "does not run legacy DNS proxy repair for VM sandboxes", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "vm-sandbox", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + openshellDriver: "vm", + policies: [], + }, + "nvidia-prod", + "nvidia/nemotron-3-super-120b-a12b", + { + inferenceProbeResponses: [ + 'BROKEN 503 {"error":"inference service unavailable"}', + 'BROKEN 503 {"error":"inference service unavailable"}', + ], + }, + ); + + const result = runConnect(tmpDir, sandboxName, { + NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH: "1", + }); + expect(result.status).toBe(0); + + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + expect(state.inferenceSetCalls.length).toBe(1); + expect(state.dockerCalls.length).toBe(0); + + const combined = (result.stdout || "") + (result.stderr || ""); + expect(combined).toContain("OpenShell VM DNS monkeypatch did not apply"); + expect(combined).toContain("Reapplying OpenShell inference route"); + expect(combined).toContain("OpenShell vm gateway path"); + }, + ); + + it( + "uses the macOS VM DNS monkeypatch without legacy DNS repair or route reset when it restores inference.local", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "vm-dns-sandbox", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + openshellDriver: "vm", + policies: [], + }, + "nvidia-prod", + "nvidia/nemotron-3-super-120b-a12b", + { + inferenceProbeResponses: [ + 'BROKEN 503 {"error":"inference service unavailable"}', + "OK 200", + ], + }, + ); + const rootfs = createVmRootfs(tmpDir); + + const result = runConnect(tmpDir, sandboxName, { + NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH: "1", + }); + expect(result.status).toBe(0); + + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + expect(state.inferenceSetCalls.length).toBe(0); + expect(state.dockerCalls.length).toBe(0); + expect(fs.readFileSync(path.join(rootfs, "etc", "resolv.conf"), "utf-8")).toBe( + "nameserver 192.168.127.1\n", + ); + expect( + fs.readFileSync(path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), "utf-8"), + ).toContain('nameserver ${GVPROXY_GATEWAY_IP}'); + + const combined = (result.stdout || "") + (result.stderr || ""); + expect(combined).toContain("Applying OpenShell VM DNS monkeypatch"); + expect(combined).toContain("inference.local route repaired"); + expect(combined).not.toContain("Reapplying OpenShell inference route"); + expect(combined).not.toContain("Repairing sandbox DNS proxy"); + }, + ); + + it( + "falls back to OpenShell inference route reapply when the VM DNS monkeypatch applies but inference.local stays broken", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "vm-dns-still-broken", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + openshellDriver: "vm", + policies: [], + }, + "nvidia-prod", + "nvidia/nemotron-3-super-120b-a12b", + { + inferenceProbeResponses: [ + 'BROKEN 503 {"error":"inference service unavailable"}', + 'BROKEN 503 {"error":"inference service unavailable"}', + "OK 200", + ], + }, + ); + const rootfs = createVmRootfs(tmpDir); + + const result = runConnect(tmpDir, sandboxName, { + NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH: "1", + }); + expect(result.status).toBe(0); + + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + expect(state.inferenceSetCalls.length).toBe(1); + expect(state.dockerCalls.length).toBe(0); + expect(fs.readFileSync(path.join(rootfs, "etc", "resolv.conf"), "utf-8")).toBe( + "nameserver 192.168.127.1\n", + ); + + const combined = (result.stdout || "") + (result.stderr || ""); + expect(combined).toContain("Applying OpenShell VM DNS monkeypatch"); + expect(combined).toContain( + "OpenShell VM DNS monkeypatch completed but inference.local is still unavailable", + ); + expect(combined).toContain("Reapplying OpenShell inference route"); + expect(combined).toContain("inference.local route repaired"); + }, + ); + + it( + "probes VM inference health after route reapply even when inference set exits nonzero", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "vm-route-set-nonzero", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + openshellDriver: "vm", + policies: [], + }, + "nvidia-prod", + "nvidia/nemotron-3-super-120b-a12b", + { + inferenceProbeResponses: [ + 'BROKEN 503 {"error":"inference service unavailable"}', + "OK 200", + ], + inferenceSetStatus: 1, + }, + ); + + const result = runConnect(tmpDir, sandboxName, { + NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH: "1", + }); + expect(result.status).toBe(0); + + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + expect(state.inferenceSetCalls).toEqual([ + [ + "--provider", + "nvidia-prod", + "--model", + "nvidia/nemotron-3-super-120b-a12b", + "--no-verify", + ], + ]); + expect(state.dockerCalls.length).toBe(0); + + const combined = (result.stdout || "") + (result.stderr || ""); + expect(combined).toContain("OpenShell VM DNS monkeypatch did not apply"); + expect(combined).toContain("Reapplying OpenShell inference route"); + expect(combined).toContain("inference.local route repaired"); + expect(combined).not.toContain("OpenShell vm gateway path"); + }, + ); }); diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index 8efe0681fd..012967ed1c 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -614,6 +614,24 @@ EOF expect(src).toContain("lock_rc_files"); }); + it("hermes non-root fallback uses mutable-default config verification", () => { + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); + const nonRootStart = src.indexOf('# ── Non-root fallback'); + const rootStart = src.indexOf('# ── Root path', nonRootStart); + expect(nonRootStart).toBeGreaterThanOrEqual(0); + expect(rootStart).toBeGreaterThan(nonRootStart); + const nonRootBlock = src.slice(nonRootStart, rootStart); + const rootBlock = src.slice(rootStart); + + expect(nonRootBlock).toContain('verify_config_integrity_if_locked "${HERMES_DIR}"'); + expect(nonRootBlock).not.toContain( + 'verify_config_integrity "${HERMES_DIR}" "${HERMES_HASH_FILE}"', + ); + expect(rootBlock).toContain( + 'verify_config_integrity "${HERMES_DIR}" "${HERMES_HASH_FILE}"', + ); + }); + it("hermes start.sh rewrites configure guard rc blocks through the symlink-safe helper", () => { const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); const helperFn = src.match(/rewrite_rc_marker_block\(\) \{([\s\S]*?)^}/m); diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index b79f677537..398e95518a 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -344,6 +344,7 @@ describe("base sandbox policy", () => { (h) => h === "discord.com" || h === "gateway.discord.gg" || + h === "*.discord.gg" || h === "cdn.discordapp.com" || h === "media.discordapp.net", ); @@ -522,6 +523,13 @@ describe("messaging WebSocket presets", () => { credentialRewrite: true, data: loadYaml(DISCORD_PRESET_PATH), }, + { + name: "discord", + policyKey: "discord", + host: "*.discord.gg", + credentialRewrite: true, + data: loadYaml(DISCORD_PRESET_PATH), + }, { name: "slack", policyKey: "slack",