From 9610b04a2cb876c0b231dc90c7d2f10b90508812 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 8 May 2026 07:01:42 -0700 Subject: [PATCH 1/5] fix(runtime): repair stale inference DNS routes --- src/commands/uninstall.ts | 3 +- src/lib/actions/dns/index.test.ts | 44 +++++++- src/lib/actions/dns/index.ts | 23 ++++- src/lib/actions/sandbox/connect.ts | 130 ++++++++++++++++++----- src/lib/onboard.ts | 37 ++++++- test/dns-proxy.test.ts | 6 +- test/nemohermes-alias.test.ts | 8 ++ test/onboard.test.ts | 23 +++++ test/policy-tiers-onboard.test.ts | 38 +++++++ test/sandbox-connect-inference.test.ts | 138 ++++++++++++++++++++++++- 10 files changed, 410 insertions(+), 40 deletions(-) diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 4e3e05687ae..bda41832d53 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import Command from "../lib/commands/uninstall"; -import { CLI_NAME } from "../lib/cli/branding"; import { withCommandDisplay } from "../lib/cli/command-display"; export default withCommandDisplay(Command, [ { - usage: `${CLI_NAME} uninstall`, + usage: "nemoclaw uninstall", description: "Run uninstall.sh (local only; no remote fallback)", group: "Cleanup", scope: "global", diff --git a/src/lib/actions/dns/index.test.ts b/src/lib/actions/dns/index.test.ts index ce4bc3eae45..00e50a905ed 100644 --- a/src/lib/actions/dns/index.test.ts +++ b/src/lib/actions/dns/index.test.ts @@ -158,7 +158,8 @@ describe("runSetupDnsProxy", () => { calls.push(args); const cmd = args.join(" "); if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get endpoints kube-dns")) return ok("10.43.0.10"); + if (cmd.includes("get service kube-dns")) return ok("10.43.0.10"); + if (cmd.includes("get endpoints kube-dns")) return ok("10.42.0.15"); if (cmd.includes("get pods -n openshell -o name")) return ok("pod/box[1]-abc\n"); if (cmd.includes("ip addr show")) return ok("10.200.0.1\n"); if (cmd.includes("cat /tmp/dns-proxy.pid")) return ok("12345\n"); @@ -182,13 +183,52 @@ describe("runSetupDnsProxy", () => { ); expect(result.exitCode).toBe(0); + expect(result.dnsUpstream).toBe("10.43.0.10"); expect(result.pod).toBe("box[1]-abc"); expect(result.verificationPass).toBe(4); + expect(calls.some((args) => args.join(" ").includes("get service kube-dns"))).toBe(true); + expect(calls.some((args) => args.join(" ").includes("get endpoints kube-dns"))).toBe(false); expect(calls.some((args) => args.includes("box[1]-abc"))).toBe(true); expect(calls.some((args) => args.join(" ").includes("nohup python3 -u /tmp/dns-proxy.py '10.43.0.10' '10.200.0.1'"))).toBe(true); expect(log).toHaveBeenCalledWith(" DNS verification: 4 passed, 0 failed"); }); + it("falls back to the CoreDNS pod endpoint when the kube-dns service IP is unavailable", () => { + const calls: string[][] = []; + const runDocker = vi.fn((args: string[]) => { + calls.push(args); + const cmd = args.join(" "); + if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); + if (cmd.includes("get service kube-dns")) return ok(""); + if (cmd.includes("get endpoints kube-dns")) return ok("10.42.0.15"); + if (cmd.includes("get pods -n openshell -o name")) return ok("pod/box-abc\n"); + if (cmd.includes("ip addr show")) return ok("10.200.0.1\n"); + if (cmd.includes("cat /tmp/dns-proxy.pid")) return ok("12345\n"); + if (cmd.includes("cat /tmp/dns-proxy.log")) return ok("dns-proxy: 10.200.0.1:53 -> 10.42.0.15:53 pid=12345\n"); + if (cmd.includes("python3 -c")) return ok("ok"); + if (cmd.includes("ls /run/netns/")) return ok("sandbox-ns\n"); + if (cmd.includes("test -x")) return ok(); + if (cmd.includes("cat /etc/resolv.conf")) return ok("nameserver 10.200.0.1\n"); + if (cmd.includes("getent hosts github.com")) return ok("140.82.112.4 github.com\n"); + return ok(); + }); + + const result = runSetupDnsProxy( + { gatewayName: "nemoclaw", sandboxName: "box" }, + { + env: { DOCKER_HOST: "unix:///tmp/fake-docker.sock" }, + log: vi.fn(), + runDocker, + sleep: vi.fn(), + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.dnsUpstream).toBe("10.42.0.15"); + expect(calls.some((args) => args.join(" ").includes("get service kube-dns"))).toBe(true); + expect(calls.some((args) => args.join(" ").includes("get endpoints kube-dns"))).toBe(true); + }); + it("rejects unsafe DNS upstreams before launching the forwarder", () => { const calls: string[][] = []; const result = runSetupDnsProxy( @@ -199,7 +239,7 @@ describe("runSetupDnsProxy", () => { calls.push(args); const cmd = args.join(" "); if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get endpoints kube-dns")) return ok("bad;rm\n"); + if (cmd.includes("get service kube-dns")) return ok("bad;rm\n"); return ok(); }, sleep: vi.fn(), diff --git a/src/lib/actions/dns/index.ts b/src/lib/actions/dns/index.ts index 99bb05d00de..a14ddfc4dcd 100644 --- a/src/lib/actions/dns/index.ts +++ b/src/lib/actions/dns/index.ts @@ -289,12 +289,31 @@ export function runSetupDnsProxy( kctl( runDocker, cluster, - ["get", "endpoints", "kube-dns", "-n", "kube-system", "-o", "jsonpath={.subsets[0].addresses[0].ip}"], + ["get", "service", "kube-dns", "-n", "kube-system", "-o", "jsonpath={.spec.clusterIP}"], dockerEnv, ), ).trim(); + if (!dnsUpstream || dnsUpstream.toLowerCase() === "none") { + log("WARNING: Could not discover kube-dns service IP. Falling back to CoreDNS pod IP."); + dnsUpstream = commandOutput( + kctl( + runDocker, + cluster, + [ + "get", + "endpoints", + "kube-dns", + "-n", + "kube-system", + "-o", + "jsonpath={.subsets[0].addresses[0].ip}", + ], + dockerEnv, + ), + ).trim(); + } if (!dnsUpstream) { - log("WARNING: Could not discover CoreDNS pod IP. Falling back to 8.8.8.8."); + log("WARNING: Could not discover CoreDNS service or pod IP. Falling back to 8.8.8.8."); log("WARNING: k8s-internal names (inference.local routing) will NOT work."); dnsUpstream = DEFAULT_DNS_UPSTREAM; } diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 534c4c433ba..598ce089213 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -16,7 +16,9 @@ import { } from "../../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; import * as registry from "../../state/registry"; +import type { SandboxEntry } from "../../state/registry"; import { ROOT } from "../../runner"; +import { runSetupDnsProxy } from "../dns"; import { ensureLiveSandboxOrExit } from "./gateway-state"; import { createSystemDeps as createSessionDeps, @@ -29,6 +31,9 @@ import { resolveOpenshell } from "../../adapters/openshell/resolve"; const agentRuntime = require("../../../../bin/lib/agent-runtime"); +const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; +const INFERENCE_ROUTE_PROBE_TIMEOUT_MS = 10_000; + export type SandboxConnectOptions = { probeOnly?: boolean; }; @@ -102,6 +107,7 @@ function runSandboxConnectProbe(sandboxName: string): void { process.exit(1); } if (processCheck.wasRunning) { + ensureSandboxInferenceRoute(sandboxName, { quiet: false }); if (processCheck.forwardRecovered) { console.log( ` Probe complete: ${agentName} gateway is running in '${sandboxName}'; restored dashboard port forward.`, @@ -112,9 +118,11 @@ function runSandboxConnectProbe(sandboxName: string): void { return; } if (processCheck.recovered) { + ensureSandboxInferenceRoute(sandboxName, { quiet: false }); console.log(` Probe complete: recovered ${agentName} gateway in '${sandboxName}'.`); return; } + ensureSandboxInferenceRoute(sandboxName, { quiet: false }); console.error( ` Probe failed: ${agentName} gateway is not running in '${sandboxName}' and automatic recovery failed.`, ); @@ -122,6 +130,99 @@ function runSandboxConnectProbe(sandboxName: string): void { process.exit(1); } +function isSandboxInferenceRouteHealthy(sandboxName: string): boolean { + const probe = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + [ + "OUT=/tmp/nemoclaw-inference-route-probe.out", + "HTTP_CODE=$(curl -sk -o \"$OUT\" -w '%{http_code}' --connect-timeout 3 --max-time 8 https://inference.local/v1/models 2>/dev/null || printf '000')", + "case \"$HTTP_CODE\" in 000|5*) printf 'BROKEN %s ' \"$HTTP_CODE\"; head -c 160 \"$OUT\" 2>/dev/null ;; *) printf 'OK %s' \"$HTTP_CODE\" ;; esac", + ].join("; "), + ], + { ignoreError: true, timeout: INFERENCE_ROUTE_PROBE_TIMEOUT_MS }, + ); + return probe.status === 0 && /^OK\s+[0-9]{3}\b/.test(probe.output.trim()); +} + +function repairSandboxInferenceRouteIfNeeded( + sandboxName: string, + { quiet = false }: { quiet?: boolean } = {}, +): boolean { + if (process.env.NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR === "1") return false; + if (isSandboxInferenceRouteHealthy(sandboxName)) return false; + + if (!quiet) { + console.log(""); + console.log(` inference.local is unavailable inside '${sandboxName}'. Repairing sandbox DNS proxy...`); + } + const repair = runSetupDnsProxy( + { gatewayName: NEMOCLAW_GATEWAY_NAME, sandboxName }, + { log: quiet ? () => undefined : console.log }, + ); + if (repair.exitCode !== 0) { + if (!quiet) { + console.error(" Warning: failed to repair sandbox DNS proxy."); + if (repair.message) console.error(` ${repair.message}`); + } + return false; + } + + const healthy = isSandboxInferenceRouteHealthy(sandboxName); + if (!quiet) { + if (healthy) { + console.log(" inference.local route repaired."); + } else { + console.error(" Warning: inference.local is still unavailable after DNS proxy repair."); + } + } + return healthy; +} + +function ensureSandboxInferenceRoute( + sandboxName: string, + { quiet = false }: { quiet?: boolean } = {}, +): SandboxEntry | null { + let sb: SandboxEntry | null = null; + try { + sb = registry.getSandbox(sandboxName); + if (sb && sb.provider && sb.model) { + const live = parseGatewayInference( + captureOpenshell(["inference", "get"], { + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }).output, + ); + if (!live || live.provider !== sb.provider || live.model !== sb.model) { + if (!quiet) { + console.log( + ` Switching inference route to ${sb.provider}/${sb.model} for sandbox '${sandboxName}'`, + ); + } + const swapResult = runOpenshell( + ["inference", "set", "--provider", sb.provider, "--model", sb.model, "--no-verify"], + { ignoreError: true }, + ); + if (swapResult.status !== 0 && !quiet) { + console.error( + ` ${YW}Warning: failed to switch inference route — connect will proceed anyway.${R}`, + ); + } + } + repairSandboxInferenceRouteIfNeeded(sandboxName, { quiet }); + } + } catch { + /* non-fatal — don't block connect on inference route repair */ + } + return sb; +} + function exitWithSpawnResult(result: SpawnLikeResult): void { if (result.status !== null) { process.exit(result.status); @@ -182,34 +283,7 @@ export async function connectSandbox( // When the user has multiple sandboxes with different providers, the // cluster-wide inference.local route may still point at the *other* // provider. Re-set it to match this sandbox's persisted config. - let sb; - try { - sb = registry.getSandbox(sandboxName); - if (sb && sb.provider && sb.model) { - const live = parseGatewayInference( - captureOpenshell(["inference", "get"], { - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }).output, - ); - if (!live || live.provider !== sb.provider || live.model !== sb.model) { - console.log( - ` Switching inference route to ${sb.provider}/${sb.model} for sandbox '${sandboxName}'`, - ); - const swapResult = runOpenshell( - ["inference", "set", "--provider", sb.provider, "--model", sb.model, "--no-verify"], - { ignoreError: true }, - ); - if (swapResult.status !== 0) { - console.error( - ` ${YW}Warning: failed to switch inference route — connect will proceed anyway.${R}`, - ); - } - } - } - } catch { - /* non-fatal — don't block connect on inference route swap failure */ - } + const sb = ensureSandboxInferenceRoute(sandboxName); const rawTimeout = process.env.NEMOCLAW_CONNECT_TIMEOUT; let timeout = 120; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a27513407a6..a88e9e5f1dc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8537,7 +8537,10 @@ function computeSetupPresetSuggestions( ): string[] { const { enabledChannels = null, webSearchConfig = null, provider = null } = options; const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; - const suggestions = tiers.resolveTierPresets(tierName).map((p) => p.name); + const suggestions = tiers + .resolveTierPresets(tierName) + .map((p) => p.name) + .filter((name) => !known || known.has(name)); const add = (name: string) => { if (suggestions.includes(name)) return; if (known && !known.has(name)) return; @@ -8551,6 +8554,32 @@ function computeSetupPresetSuggestions( return suggestions; } +const AGENT_POLICY_PRESET_EXCLUSIONS: Record = { + hermes: ["brave"], +}; + +function filterPolicyPresetsForAgent( + presets: T[], + agentName: string | null | undefined, +): T[] { + const excluded = AGENT_POLICY_PRESET_EXCLUSIONS[normalizeSandboxAgentName(agentName)]; + if (!excluded || excluded.length === 0) return presets; + const excludedSet = new Set(excluded); + return presets.filter((preset) => !excludedSet.has(preset.name)); +} + +function resolvePolicyPresetAgentName( + sandboxName: string, + explicitAgentName: string | null | undefined, +): string { + if (explicitAgentName) return normalizeSandboxAgentName(explicitAgentName); + try { + return normalizeSandboxAgentName(registry.getSandbox(sandboxName)?.agent); + } catch { + return "openclaw"; + } +} + async function setupPoliciesWithSelection( sandboxName: string, options: { @@ -8560,6 +8589,7 @@ async function setupPoliciesWithSelection( enabledChannels?: string[] | null; provider?: string | null; knownPresetNames?: string[]; + agentName?: string | null; } = {}, ) { const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; @@ -8570,7 +8600,8 @@ async function setupPoliciesWithSelection( step(8, 8, "Policy presets"); - const allPresets = policies.listPresets(); + const policyPresetAgentName = resolvePolicyPresetAgentName(sandboxName, options.agentName); + const allPresets = filterPolicyPresetsForAgent(policies.listPresets(), policyPresetAgentName); const applied = policies.getAppliedPresets(sandboxName); let chosen = selectedPresets; @@ -10278,6 +10309,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { : recordedMessagingChannels, webSearchConfig, provider, + agentName: agent?.name ?? null, onSelection: (policyPresets) => { onboardSession.updateSession((current: Session) => { current.policyPresets = policyPresets; @@ -10448,6 +10480,7 @@ module.exports = { arePolicyPresetsApplied, getSuggestedPolicyPresets, computeSetupPresetSuggestions, + filterPolicyPresetsForAgent, LOCAL_INFERENCE_PROVIDERS, presetsCheckboxSelector, selectPolicyTier, diff --git a/test/dns-proxy.test.ts b/test/dns-proxy.test.ts index 274914f0727..d992136cf03 100644 --- a/test/dns-proxy.test.ts +++ b/test/dns-proxy.test.ts @@ -57,7 +57,8 @@ shift # cluster name shift # kubectl cmd="$*" case "$cmd" in - *"get endpoints kube-dns"*) echo "10.43.0.10"; exit 0 ;; + *"get service kube-dns"*) echo "10.43.0.10"; exit 0 ;; + *"get endpoints kube-dns"*) echo "10.42.0.15"; exit 0 ;; *"get pods -n openshell -o name"*) echo "pod/box[1]-abc"; exit 0 ;; *"ip addr show"*) echo "10.200.0.1"; exit 0 ;; *"cat /tmp/dns-proxy.pid"*) echo "12345"; exit 0 ;; @@ -89,7 +90,8 @@ exit 0 expect(output).toContain("DNS verification: 4 passed, 0 failed"); const calls = fs.readFileSync(dockerLog, "utf-8"); - expect(calls).toContain("get endpoints kube-dns"); + expect(calls).toContain("get service kube-dns"); + expect(calls).not.toContain("get endpoints kube-dns"); expect(calls).toContain("kube-system"); expect(calls).toContain("nohup python3 -u /tmp/dns-proxy.py"); expect(calls).toContain("10.43.0.10"); diff --git a/test/nemohermes-alias.test.ts b/test/nemohermes-alias.test.ts index 47d961e98e5..3c469071057 100644 --- a/test/nemohermes-alias.test.ts +++ b/test/nemohermes-alias.test.ts @@ -89,6 +89,14 @@ describe("nemohermes alias", () => { expect(out).toContain("NemoHermes"); }); + it("routes nemohermes uninstall as a global command, not a sandbox connect command", () => { + const { code, out } = runHermes("uninstall --help"); + expect(code).toBe(0); + expect(out).toContain("NemoHermes Uninstaller"); + expect(out).toContain("internal uninstall run-plan"); + expect(out).not.toContain("uninstall connect"); + }); + it("NEMOCLAW_AGENT is set to hermes via the launcher", () => { // The launcher sets the env var before requiring dist/nemoclaw. // We verify indirectly: --version shows nemohermes branding which diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 2f7c3674124..7626fe39a40 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -52,6 +52,10 @@ type OnboardTestInternals = { classifySandboxCreateFailure: (output?: string) => { kind: string; uploadedToGateway: boolean }; compactText: (value?: string) => string; computeSetupPresetSuggestions: ShimFn; + filterPolicyPresetsForAgent: ( + presets: T[], + agentName?: string | null, + ) => T[]; formatEnvAssignment: (name: string, value: string) => string; findDashboardForwardOwner: ( forwardListOutput: string | null | undefined, @@ -176,6 +180,7 @@ const { classifySandboxCreateFailure, compactText, computeSetupPresetSuggestions, + filterPolicyPresetsForAgent, formatEnvAssignment, getNavigationChoice, getGatewayReuseState, @@ -471,6 +476,24 @@ describe("onboard helpers", () => { expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave"]); }); + it("filters tier defaults to known presets for agent-specific onboarding", () => { + const suggestions = computeSetupPresetSuggestions("balanced", { + enabledChannels: [], + knownPresetNames: known.filter((name) => name !== "brave"), + }); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); + }); + + it("omits Brave from Hermes onboarding presets", () => { + const allPresets = known.map((name) => ({ name })); + const hermesPresets = filterPolicyPresetsForAgent(allPresets, "hermes").map((p) => p.name); + const openclawPresets = filterPolicyPresetsForAgent(allPresets, "openclaw").map( + (p) => p.name, + ); + expect(hermesPresets).not.toContain("brave"); + expect(openclawPresets).toContain("brave"); + }); + it("forwards enabled messaging channels into the balanced tier suggestions", () => { const suggestions = computeSetupPresetSuggestions("balanced", { enabledChannels: ["telegram"], diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 83c2cdddb6b..a80f0838aae 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -288,6 +288,44 @@ console.log = (...args) => lines.push(args.join(" ")); assert.deepEqual(payload.applied, []); }); + it("omits Brave from Hermes policy preset selection", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const script = + buildPreamble({ + tierEnv: "balanced", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.getAppliedPresets = () => []; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agentName: "hermes" }); + process.stdout.write(JSON.stringify({ applied, appliedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok(!payload.applied.includes("brave"), `Hermes presets included Brave: ${payload.applied}`); + assert.ok( + !payload.appliedCalls.includes("brave"), + `Hermes applied Brave: ${payload.appliedCalls}`, + ); + assert.ok(payload.applied.includes("pypi"), "Hermes should still include normal dev presets"); + }); + // #2429: an unrecognised NEMOCLAW_POLICY_MODE used to hard-exit at step 8/8, // leaving the already-built sandbox with zero presets. We now warn and fall // back to the tier-derived suggestions so the sandbox stays usable, and hint diff --git a/test/sandbox-connect-inference.test.ts b/test/sandbox-connect-inference.test.ts index a70d9b4864e..98e5a4a2256 100644 --- a/test/sandbox-connect-inference.test.ts +++ b/test/sandbox-connect-inference.test.ts @@ -24,16 +24,22 @@ type SandboxEntryFixture = { policies?: string[]; }; +type SetupFixtureOptions = { + inferenceProbeResponses?: string[]; +}; + function setupFixture( sandboxEntry: SandboxEntryFixture, liveInferenceProvider: string | null, liveInferenceModel: string | null, + options: SetupFixtureOptions = {}, ) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inf-swap-")); const homeLocalBin = path.join(tmpDir, ".local", "bin"); const registryDir = path.join(tmpDir, ".nemoclaw"); const stateFile = path.join(tmpDir, "state.json"); const openshellPath = path.join(homeLocalBin, "openshell"); + const dockerPath = path.join(homeLocalBin, "docker"); const sandboxName = String(sandboxEntry.name); fs.mkdirSync(homeLocalBin, { recursive: true }); @@ -56,7 +62,15 @@ function setupFixture( inferenceBlock = `Gateway inference:\\n Not configured\\n`; } - fs.writeFileSync(stateFile, JSON.stringify({ inferenceSetCalls: [] })); + fs.writeFileSync( + stateFile, + JSON.stringify({ + dockerCalls: [], + inferenceProbeResponses: options.inferenceProbeResponses ?? ["OK 200"], + inferenceSetCalls: [], + sandboxExecCalls: [], + }), + ); // Fake openshell binary — records inference set calls, stubs everything else fs.writeFileSync( @@ -87,6 +101,20 @@ if (args[0] === "sandbox" && args[1] === "list") { process.exit(0); } +if (args[0] === "sandbox" && args[1] === "exec") { + state.sandboxExecCalls.push(args); + const command = args.join(" "); + if (!command.includes("inference.local/v1/models")) { + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.stdout.write("__NEMOCLAW_SANDBOX_EXEC_STARTED__\\nRUNNING\\n"); + process.exit(0); + } + const response = state.inferenceProbeResponses.shift() || "OK 200"; + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.stdout.write(response); + process.exit(0); +} + if (args[0] === "sandbox" && args[1] === "connect") { // Don't actually drop into a shell — just exit successfully process.exit(0); @@ -112,6 +140,71 @@ if (args[0] === "forward") { } // Default — succeed silently +process.exit(0); +`, + { mode: 0o755 }, + ); + + fs.writeFileSync( + dockerPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +const stateFile = ${JSON.stringify(stateFile)}; +const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); +state.dockerCalls.push(args); +fs.writeFileSync(stateFile, JSON.stringify(state)); +const cmd = args.join(" "); + +if (args[0] === "ps") { + process.stdout.write("openshell-cluster-nemoclaw\\n"); + process.exit(0); +} + +if (cmd.includes("get service kube-dns")) { + process.stdout.write("10.43.0.10"); + process.exit(0); +} +if (cmd.includes("get endpoints kube-dns")) { + process.stdout.write("10.42.0.15"); + process.exit(0); +} +if (cmd.includes("get pods -n openshell -o name")) { + process.stdout.write("pod/${sandboxName}-abc\\n"); + process.exit(0); +} +if (cmd.includes("ip addr show")) { + process.stdout.write("10.200.0.1\\n"); + process.exit(0); +} +if (cmd.includes("cat /tmp/dns-proxy.pid")) { + process.stdout.write("12345\\n"); + process.exit(0); +} +if (cmd.includes("cat /tmp/dns-proxy.log")) { + process.stdout.write("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\\n"); + process.exit(0); +} +if (cmd.includes("python3 -c")) { + process.stdout.write("ok"); + process.exit(0); +} +if (cmd.includes("ls /run/netns/")) { + process.stdout.write("sandbox-ns\\n"); + process.exit(0); +} +if (cmd.includes("test -x")) { + process.exit(cmd.includes("/usr/sbin/iptables") ? 0 : 1); +} +if (cmd.includes("cat /etc/resolv.conf")) { + process.stdout.write("nameserver 10.200.0.1\\n"); + process.exit(0); +} +if (cmd.includes("getent hosts github.com")) { + process.stdout.write("140.82.112.4 github.com\\n"); + process.exit(0); +} + process.exit(0); `, { mode: 0o755 }, @@ -131,7 +224,7 @@ function runConnect(tmpDir: string, sandboxName: string) { env: { ...process.env, HOME: tmpDir, - PATH: "/usr/bin:/bin", + PATH: `${path.join(tmpDir, ".local", "bin")}:/usr/bin:/bin`, NEMOCLAW_NO_CONNECT_HINT: "1", }, timeout: execTimeout(15_000), @@ -223,4 +316,45 @@ describe("sandbox connect inference route swap (#1248)", () => { expect(state.inferenceSetCalls.length).toBe(0); }, ); + + it( + "repairs the sandbox DNS proxy when inference.local returns 503", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "stale-dns-sandbox", + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + "nvidia-prod", + "nvidia/nemotron-3-super-120b-a12b", + { + inferenceProbeResponses: [ + 'BROKEN 503 {"error":"inference service unavailable"}', + "OK 200", + ], + }, + ); + + const result = runConnect(tmpDir, sandboxName); + expect(result.status).toBe(0); + + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + const dockerCalls = state.dockerCalls as string[][]; + const inferenceExecCalls = state.sandboxExecCalls.filter((call: string[]) => + JSON.stringify(call).includes("inference.local/v1/models"), + ); + expect(state.inferenceSetCalls.length).toBe(0); + expect(inferenceExecCalls.length).toBe(2); + expect(dockerCalls.some((call) => call.join(" ").includes("get service kube-dns"))).toBe(true); + expect(dockerCalls.some((call) => call.join(" ").includes("get endpoints kube-dns"))).toBe(false); + + const combined = (result.stdout || "") + (result.stderr || ""); + expect(combined).toContain("inference.local is unavailable inside 'stale-dns-sandbox'"); + expect(combined).toContain("inference.local route repaired"); + }, + ); }); From 5c91b8dacd9240c79d572514d12ea50a18117958 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 8 May 2026 08:33:09 -0700 Subject: [PATCH 2/5] fix(hermes): clamp policy presets during onboarding --- src/lib/onboard.ts | 51 +++++++++++++------ test/onboard.test.ts | 3 +- test/policy-tiers-onboard.test.ts | 83 +++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a88e9e5f1dc..ea673d2b6a7 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8580,6 +8580,25 @@ function resolvePolicyPresetAgentName( } } +function listPolicyPresetsForAgent( + sandboxName: string, + agentName: string | null | undefined, +): Array<{ name: string; description?: string }> { + const policyPresetAgentName = resolvePolicyPresetAgentName(sandboxName, agentName); + return [ + ...filterPolicyPresetsForAgent(policies.listPresets(), policyPresetAgentName), + ...policies.listCustomPresets(sandboxName), + ]; +} + +function clampPolicyPresetNames( + presetNames: string[], + allowedPresets: Array<{ name: string }>, +): string[] { + const knownPresets = new Set(allowedPresets.map((p) => p.name)); + return presetNames.filter((name) => knownPresets.has(name)); +} + async function setupPoliciesWithSelection( sandboxName: string, options: { @@ -8600,10 +8619,11 @@ async function setupPoliciesWithSelection( step(8, 8, "Policy presets"); - const policyPresetAgentName = resolvePolicyPresetAgentName(sandboxName, options.agentName); - const allPresets = filterPolicyPresetsForAgent(policies.listPresets(), policyPresetAgentName); - const applied = policies.getAppliedPresets(sandboxName); - let chosen = selectedPresets; + const allPresets = listPolicyPresetsForAgent(sandboxName, options.agentName); + const knownPresets = new Set(allPresets.map((p) => p.name)); + const currentAppliedPresets = policies.getAppliedPresets(sandboxName); + const applied = clampPolicyPresetNames(currentAppliedPresets, allPresets); + let chosen = selectedPresets ? clampPolicyPresetNames(selectedPresets, allPresets) : null; // Resume path: caller supplies the preset list from a previous run. if (chosen && chosen.length > 0) { @@ -8613,7 +8633,7 @@ async function setupPoliciesWithSelection( process.exit(1); } note(` [resume] Reapplying policy presets: ${chosen.join(", ")}`); - syncPresetSelection(sandboxName, applied, chosen); + syncPresetSelection(sandboxName, currentAppliedPresets, chosen); return chosen; } @@ -8663,7 +8683,6 @@ async function setupPoliciesWithSelection( console.warn(` Falling back to suggested presets for tier '${tierName}'.`); } - const knownPresets = new Set(allPresets.map((p) => p.name)); const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); if (invalidPresets.length > 0) { console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); @@ -8698,7 +8717,7 @@ async function setupPoliciesWithSelection( process.exit(1); } note(` [non-interactive] Applying policy presets: ${chosen.join(", ")}`); - syncPresetSelection(sandboxName, applied, chosen); + syncPresetSelection(sandboxName, currentAppliedPresets, chosen); return chosen; } @@ -8722,7 +8741,7 @@ async function setupPoliciesWithSelection( const accessByName: Record = {}; for (const p of resolvedPresets) accessByName[p.name] = p.access; - syncPresetSelection(sandboxName, applied, interactiveChoice, accessByName); + syncPresetSelection(sandboxName, currentAppliedPresets, interactiveChoice, accessByName); return interactiveChoice; } @@ -10278,17 +10297,21 @@ async function onboard(opts: OnboardOptions = {}): Promise { messagingChannels: Array.isArray(activeMessagingChannels) ? activeMessagingChannels : [], agent, }); + const recordedPolicyPresetsForAgent = clampPolicyPresetNames( + recordedPolicyPresets || [], + listPolicyPresetsForAgent(sandboxName, agent?.name ?? null), + ); const resumePolicies = - resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresets || []); + resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForAgent); if (resumePolicies) { - skippedStepMessage("policies", (recordedPolicyPresets || []).join(", ")); + skippedStepMessage("policies", recordedPolicyPresetsForAgent.join(", ")); onboardSession.markStepComplete( "policies", toSessionUpdates({ sandboxName, provider, model, - policyPresets: recordedPolicyPresets || [], + policyPresets: recordedPolicyPresetsForAgent, }), ); } else { @@ -10296,12 +10319,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { sandboxName, provider, model, - policyPresets: recordedPolicyPresets || [], + policyPresets: recordedPolicyPresetsForAgent, }); const appliedPolicyPresets = await setupPoliciesWithSelection(sandboxName, { selectedPresets: - Array.isArray(recordedPolicyPresets) && recordedPolicyPresets.length > 0 - ? recordedPolicyPresets + recordedPolicyPresetsForAgent.length > 0 + ? recordedPolicyPresetsForAgent : null, enabledChannels: selectedMessagingChannels.length > 0 diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 7626fe39a40..797fc69a033 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -149,6 +149,7 @@ function isOnboardTestInternals( typeof value.buildCompatibleEndpointSandboxSmokeScript === "function" && typeof value.buildSandboxConfigSyncScript === "function" && typeof value.classifySandboxCreateFailure === "function" && + typeof value.filterPolicyPresetsForAgent === "function" && typeof value.getDefaultSandboxNameForAgent === "function" && typeof value.getSandboxPromptDefault === "function" && typeof value.getRequestedSandboxAgentName === "function" && @@ -4079,7 +4080,7 @@ const { setupInference } = require(${onboardPath}); assert.match(source, /skippedStepMessage\("openclaw", sandboxName\)/); assert.match( source, - /skippedStepMessage\("policies", \(recordedPolicyPresets \|\| \[\]\)\.join\(", "\)\)/, + /skippedStepMessage\("policies", recordedPolicyPresetsForAgent\.join\(", "\)\)/, ); }); diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index a80f0838aae..3f7aaeecdbe 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -326,6 +326,89 @@ console.log = () => {}; assert.ok(payload.applied.includes("pypi"), "Hermes should still include normal dev presets"); }); + it("removes a previously-applied Brave preset during Hermes policy selection", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const script = + buildPreamble({ + tierEnv: "balanced", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ["brave", "npm"]; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { agentName: "hermes" }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.ok( + !payload.applied.includes("brave"), + `Hermes presets included Brave: ${payload.applied}`, + ); + assert.ok( + payload.removedCalls.includes("brave"), + `Hermes did not remove Brave: ${payload.removedCalls}`, + ); + assert.ok(!payload.appliedCalls.includes("brave"), `Hermes applied Brave: ${payload.appliedCalls}`); + }); + + it("clamps resumed Hermes policy presets to agent-allowed presets", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const script = + buildPreamble({ + tierEnv: "balanced", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ["brave"]; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { + agentName: "hermes", + selectedPresets: ["brave", "npm"], + }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.deepEqual(payload.applied, ["npm"]); + assert.deepEqual(payload.appliedCalls, ["npm"]); + assert.deepEqual(payload.removedCalls, ["brave"]); + }); + // #2429: an unrecognised NEMOCLAW_POLICY_MODE used to hard-exit at step 8/8, // leaving the already-built sandbox with zero presets. We now warn and fall // back to the tier-derived suggestions so the sandbox stays usable, and hint From 3df09bdaab309f948861fc9c173ea3a67b5659c8 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 8 May 2026 08:40:52 -0700 Subject: [PATCH 3/5] fix(onboard): preserve custom policy presets --- src/lib/onboard.ts | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ea673d2b6a7..9f15eace7a5 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8558,14 +8558,19 @@ const AGENT_POLICY_PRESET_EXCLUSIONS: Record = { hermes: ["brave"], }; +function isPolicyPresetExcludedForAgent( + presetName: string, + agentName: string | null | undefined, +): boolean { + const excluded = AGENT_POLICY_PRESET_EXCLUSIONS[normalizeSandboxAgentName(agentName)]; + return Boolean(excluded && excluded.includes(presetName)); +} + function filterPolicyPresetsForAgent( presets: T[], agentName: string | null | undefined, ): T[] { - const excluded = AGENT_POLICY_PRESET_EXCLUSIONS[normalizeSandboxAgentName(agentName)]; - if (!excluded || excluded.length === 0) return presets; - const excludedSet = new Set(excluded); - return presets.filter((preset) => !excludedSet.has(preset.name)); + return presets.filter((preset) => !isPolicyPresetExcludedForAgent(preset.name, agentName)); } function resolvePolicyPresetAgentName( @@ -8594,9 +8599,12 @@ function listPolicyPresetsForAgent( function clampPolicyPresetNames( presetNames: string[], allowedPresets: Array<{ name: string }>, + agentName: string | null | undefined, ): string[] { const knownPresets = new Set(allowedPresets.map((p) => p.name)); - return presetNames.filter((name) => knownPresets.has(name)); + return presetNames.filter( + (name) => knownPresets.has(name) && !isPolicyPresetExcludedForAgent(name, agentName), + ); } async function setupPoliciesWithSelection( @@ -8619,11 +8627,22 @@ async function setupPoliciesWithSelection( step(8, 8, "Policy presets"); - const allPresets = listPolicyPresetsForAgent(sandboxName, options.agentName); + const policyPresetAgentName = resolvePolicyPresetAgentName(sandboxName, options.agentName); + const allPresets = listPolicyPresetsForAgent(sandboxName, policyPresetAgentName); const knownPresets = new Set(allPresets.map((p) => p.name)); const currentAppliedPresets = policies.getAppliedPresets(sandboxName); - const applied = clampPolicyPresetNames(currentAppliedPresets, allPresets); - let chosen = selectedPresets ? clampPolicyPresetNames(selectedPresets, allPresets) : null; + const selectablePresets = [ + ...allPresets, + ...currentAppliedPresets.map((name) => ({ name })), + ]; + const applied = clampPolicyPresetNames( + currentAppliedPresets, + selectablePresets, + policyPresetAgentName, + ); + let chosen = selectedPresets + ? clampPolicyPresetNames(selectedPresets, selectablePresets, policyPresetAgentName) + : null; // Resume path: caller supplies the preset list from a previous run. if (chosen && chosen.length > 0) { @@ -10299,7 +10318,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { }); const recordedPolicyPresetsForAgent = clampPolicyPresetNames( recordedPolicyPresets || [], - listPolicyPresetsForAgent(sandboxName, agent?.name ?? null), + [ + ...listPolicyPresetsForAgent(sandboxName, agent?.name ?? null), + ...policies.getAppliedPresets(sandboxName).map((name) => ({ name })), + ], + agent?.name ?? null, ); const resumePolicies = resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForAgent); From 5c25526a057d8bfd193fa50f87a9b55136f417c7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 8 May 2026 21:46:59 -0700 Subject: [PATCH 4/5] docs: document inference route repair escape hatch --- .../skills/nemoclaw-user-reference/references/commands.md | 8 ++++++++ docs/reference/commands.md | 1 + 2 files changed, 9 insertions(+) diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 5be1e1a3cf7..3c294729f31 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -963,6 +963,14 @@ $ nemoclaw onboard If the pull exceeds the limit, onboarding emits the timeout in minutes plus a hint to raise this variable, and the partial download is preserved for the next attempt. +### Lifecycle Behavior Flags + +These flags change defaults for commands that manage existing sandboxes. + +| Variable | Format | Effect | +|----------|--------|--------| +| `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips the automatic DNS-proxy repair for stale `inference.local` routes during `nemoclaw connect` and `nemoclaw connect --probe-only`. Use only as a troubleshooting escape hatch. | + ## NemoHermes Alias `nemohermes` is a convenience alias that pre-selects the Hermes agent. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 2fcca1af2bc..43b94aa3ed9 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1061,6 +1061,7 @@ These flags change defaults for commands that manage existing sandboxes. | Variable | Format | Effect | |----------|--------|--------| | `NEMOCLAW_CLEANUP_GATEWAY` | `1`, `true`, or `yes` to enable; `0`, `false`, or `no` to disable | Sets the default for whether `nemoclaw destroy` removes the shared gateway when destroying the last sandbox. Command-line `--cleanup-gateway` and `--no-cleanup-gateway` still take precedence. | +| `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips the automatic DNS-proxy repair for stale `inference.local` routes during `nemoclaw connect` and `nemoclaw connect --probe-only`. Use only as a troubleshooting escape hatch. | ## NemoHermes Alias From a0426e4148b6840e95b6e3136d86696f76934471 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 8 May 2026 22:03:40 -0700 Subject: [PATCH 5/5] fix(onboard): use web-search capability for preset filtering Move setup policy preset filtering into the policy domain and drive Brave omission from the web-search support signal instead of an agent-name exclusion table. Preserve sandbox custom presets whose names collide with unsupported built-ins across resume and non-interactive flows. Document the intentional sandbox shell probe shape and the global nemohermes uninstall routing. --- src/commands/uninstall.ts | 2 + src/lib/actions/sandbox/connect.ts | 11 ++- src/lib/adapters/openshell/timeouts.ts | 3 + src/lib/onboard.ts | 112 +++++++------------------ src/lib/policies.ts | 43 ++++++++++ test/onboard.test.ts | 44 +++++++--- test/policy-tiers-onboard.test.ts | 82 ++++++++++++++---- 7 files changed, 184 insertions(+), 113 deletions(-) diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index bda41832d53..3dd3a7f5993 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -6,6 +6,8 @@ import { withCommandDisplay } from "../lib/cli/command-display"; export default withCommandDisplay(Command, [ { + // Keep the usage global even under the nemohermes alias; `nemohermes uninstall` + // is the package uninstaller, not a sandbox-scoped action. usage: "nemoclaw uninstall", description: "Run uninstall.sh (local only; no remote fallback)", group: "Cleanup", diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 8693c538d23..193047d5116 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -13,7 +13,10 @@ import { getOpenshellBinary, runOpenshell, } from "../../adapters/openshell/runtime"; -import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts"; +import { + OPENSHELL_INFERENCE_ROUTE_PROBE_TIMEOUT_MS, + OPENSHELL_PROBE_TIMEOUT_MS, +} from "../../adapters/openshell/timeouts"; import * as registry from "../../state/registry"; import type { SandboxEntry } from "../../state/registry"; import { ROOT } from "../../runner"; @@ -31,7 +34,6 @@ import { resolveOpenshell } from "../../adapters/openshell/resolve"; const agentRuntime = require("../../../../bin/lib/agent-runtime"); const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; -const INFERENCE_ROUTE_PROBE_TIMEOUT_MS = 10_000; export type SandboxConnectOptions = { probeOnly?: boolean; @@ -130,6 +132,9 @@ function runSandboxConnectProbe(sandboxName: string): void { } function isSandboxInferenceRouteHealthy(sandboxName: string): boolean { + // Keep the shell string inside the sandbox: curl write-out, body capture, + // and status classification must run as one bounded probe. sandboxName + // remains an argv value, so no user input is interpolated into the script. const probe = captureOpenshell( [ "sandbox", @@ -145,7 +150,7 @@ function isSandboxInferenceRouteHealthy(sandboxName: string): boolean { "case \"$HTTP_CODE\" in 000|5*) printf 'BROKEN %s ' \"$HTTP_CODE\"; head -c 160 \"$OUT\" 2>/dev/null ;; *) printf 'OK %s' \"$HTTP_CODE\" ;; esac", ].join("; "), ], - { ignoreError: true, timeout: INFERENCE_ROUTE_PROBE_TIMEOUT_MS }, + { ignoreError: true, timeout: OPENSHELL_INFERENCE_ROUTE_PROBE_TIMEOUT_MS }, ); return probe.status === 0 && /^OK\s+[0-9]{3}\b/.test(probe.output.trim()); } diff --git a/src/lib/adapters/openshell/timeouts.ts b/src/lib/adapters/openshell/timeouts.ts index 5fc0f3ae4e7..84ee8fc02e1 100644 --- a/src/lib/adapters/openshell/timeouts.ts +++ b/src/lib/adapters/openshell/timeouts.ts @@ -18,6 +18,9 @@ /** Quick probe — sandbox list, status, gateway info, forward list, ssh-config */ export const OPENSHELL_PROBE_TIMEOUT_MS = 15_000; +/** In-sandbox inference.local route probe used during connect recovery */ +export const OPENSHELL_INFERENCE_ROUTE_PROBE_TIMEOUT_MS = 10_000; + /** Mutating operations — provider create/delete, gateway select, forward start/stop */ export const OPENSHELL_OPERATION_TIMEOUT_MS = 30_000; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d863615090e..19f417952a8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8541,15 +8541,19 @@ function computeSetupPresetSuggestions( webSearchConfig?: WebSearchConfig | null; provider?: string | null; knownPresetNames?: string[] | null; + webSearchSupported?: boolean | null; } = {}, ): string[] { const { enabledChannels = null, webSearchConfig = null, provider = null } = options; const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; + const supportOptions = { webSearchSupported: options.webSearchSupported }; const suggestions = tiers .resolveTierPresets(tierName) .map((p) => p.name) + .filter((name) => policies.setupPolicyPresetSupported(name, supportOptions)) .filter((name) => !known || known.has(name)); const add = (name: string) => { + if (!policies.setupPolicyPresetSupported(name, supportOptions)) return; if (suggestions.includes(name)) return; if (known && !known.has(name)) return; suggestions.push(name); @@ -8562,62 +8566,6 @@ function computeSetupPresetSuggestions( return suggestions; } -const AGENT_POLICY_PRESET_EXCLUSIONS: Record = { - hermes: ["brave"], -}; - -function isPolicyPresetExcludedForAgent( - presetName: string, - agentName: string | null | undefined, -): boolean { - const excluded = AGENT_POLICY_PRESET_EXCLUSIONS[normalizeSandboxAgentName(agentName)]; - return Boolean(excluded && excluded.includes(presetName)); -} - -function filterPolicyPresetsForAgent( - presets: T[], - agentName: string | null | undefined, -): T[] { - return presets.filter((preset) => !isPolicyPresetExcludedForAgent(preset.name, agentName)); -} - -function resolvePolicyPresetAgentName( - sandboxName: string, - explicitAgentName: string | null | undefined, -): string { - if (explicitAgentName) return normalizeSandboxAgentName(explicitAgentName); - try { - return normalizeSandboxAgentName(registry.getSandbox(sandboxName)?.agent); - } catch { - return "openclaw"; - } -} - -function listPolicyPresetsForAgent( - sandboxName: string, - agentName: string | null | undefined, -): Array<{ name: string; description?: string }> { - const policyPresetAgentName = resolvePolicyPresetAgentName(sandboxName, agentName); - return [ - ...filterPolicyPresetsForAgent(policies.listPresets(), policyPresetAgentName), - ...policies.listCustomPresets(sandboxName), - ]; -} - -function clampPolicyPresetNames( - presetNames: string[], - allowedPresets: Array<{ name: string }>, - agentName: string | null | undefined, - customPresetNames: ReadonlySet = new Set(), -): string[] { - const knownPresets = new Set(allowedPresets.map((p) => p.name)); - return presetNames.filter((name) => { - if (!knownPresets.has(name)) return false; - if (customPresetNames.has(name)) return true; - return !isPolicyPresetExcludedForAgent(name, agentName); - }); -} - async function setupPoliciesWithSelection( sandboxName: string, options: { @@ -8627,7 +8575,7 @@ async function setupPoliciesWithSelection( enabledChannels?: string[] | null; provider?: string | null; knownPresetNames?: string[]; - agentName?: string | null; + webSearchSupported?: boolean | null; } = {}, ) { const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; @@ -8638,8 +8586,8 @@ async function setupPoliciesWithSelection( step(8, 8, "Policy presets"); - const policyPresetAgentName = resolvePolicyPresetAgentName(sandboxName, options.agentName); - const allPresets = listPolicyPresetsForAgent(sandboxName, policyPresetAgentName); + const supportOptions = { webSearchSupported: options.webSearchSupported }; + const allPresets = policies.listSetupPolicyPresets(sandboxName, supportOptions); const knownPresets = new Set(allPresets.map((p) => p.name)); const customPresetNames = new Set( policies.listCustomPresets(sandboxName).map((p: { name: string }) => p.name), @@ -8649,31 +8597,32 @@ async function setupPoliciesWithSelection( ...allPresets, ...currentAppliedPresets.map((name) => ({ name })), ]; - const applied = clampPolicyPresetNames( + const applied = policies.clampSetupPolicyPresetNames( currentAppliedPresets, selectablePresets, - policyPresetAgentName, + supportOptions, customPresetNames, ); let chosen = selectedPresets - ? clampPolicyPresetNames( + ? policies.clampSetupPolicyPresetNames( selectedPresets, selectablePresets, - policyPresetAgentName, + supportOptions, customPresetNames, ) : null; // Resume path: caller supplies the preset list from a previous run. - if (chosen && chosen.length > 0) { - if (onSelection) onSelection(chosen); + if (selectedPresets && selectedPresets.length > 0) { + const resumeSelection = chosen || []; + if (onSelection) onSelection(resumeSelection); if (!waitForSandboxReady(sandboxName)) { console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); process.exit(1); } - note(` [resume] Reapplying policy presets: ${chosen.join(", ")}`); - syncPresetSelection(sandboxName, currentAppliedPresets, chosen); - return chosen; + note(` [resume] Reapplying policy presets: ${resumeSelection.join(", ")}`); + syncPresetSelection(sandboxName, currentAppliedPresets, resumeSelection); + return resumeSelection; } // Tier selection — determines the default preset list for this install. @@ -8684,6 +8633,7 @@ async function setupPoliciesWithSelection( webSearchConfig, provider, knownPresetNames: allPresets.map((p) => p.name), + webSearchSupported: options.webSearchSupported, }); if (isNonInteractive()) { @@ -10131,7 +10081,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { } const webSearchSupportProbePath = fromDockerfile ? path.resolve(fromDockerfile) : null; - if (webSearchConfig && !agentSupportsWebSearch(agent, webSearchSupportProbePath)) { + const webSearchSupported = agentSupportsWebSearch(agent, webSearchSupportProbePath); + if (webSearchConfig && !webSearchSupported) { note( ` Web search is not yet supported by ${agent?.displayName ?? "this sandbox image"}. Clearing stale config.`, ); @@ -10347,28 +10298,29 @@ async function onboard(opts: OnboardOptions = {}): Promise { messagingChannels: Array.isArray(activeMessagingChannels) ? activeMessagingChannels : [], agent, }); - const recordedPolicyPresetsForAgent = clampPolicyPresetNames( + const policyPresetSupportOptions = { webSearchSupported }; + const recordedPolicyPresetsForSupport = policies.clampSetupPolicyPresetNames( recordedPolicyPresets || [], [ - ...listPolicyPresetsForAgent(sandboxName, agent?.name ?? null), + ...policies.listSetupPolicyPresets(sandboxName, policyPresetSupportOptions), ...policies.getAppliedPresets(sandboxName).map((name) => ({ name })), ], - agent?.name ?? null, + policyPresetSupportOptions, new Set( policies.listCustomPresets(sandboxName).map((p: { name: string }) => p.name), ), ); const resumePolicies = - resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForAgent); + resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForSupport); if (resumePolicies) { - skippedStepMessage("policies", recordedPolicyPresetsForAgent.join(", ")); + skippedStepMessage("policies", recordedPolicyPresetsForSupport.join(", ")); onboardSession.markStepComplete( "policies", toSessionUpdates({ sandboxName, provider, model, - policyPresets: recordedPolicyPresetsForAgent, + policyPresets: recordedPolicyPresetsForSupport, }), ); } else { @@ -10376,12 +10328,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { sandboxName, provider, model, - policyPresets: recordedPolicyPresetsForAgent, + policyPresets: recordedPolicyPresetsForSupport, }); const appliedPolicyPresets = await setupPoliciesWithSelection(sandboxName, { selectedPresets: - recordedPolicyPresetsForAgent.length > 0 - ? recordedPolicyPresetsForAgent + recordedPolicyPresetsForSupport.length > 0 + ? recordedPolicyPresetsForSupport : null, enabledChannels: selectedMessagingChannels.length > 0 @@ -10389,7 +10341,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { : recordedMessagingChannels, webSearchConfig, provider, - agentName: agent?.name ?? null, + webSearchSupported, onSelection: (policyPresets) => { onboardSession.updateSession((current: Session) => { current.policyPresets = policyPresets; @@ -10560,7 +10512,7 @@ module.exports = { arePolicyPresetsApplied, getSuggestedPolicyPresets, computeSetupPresetSuggestions, - filterPolicyPresetsForAgent, + filterSetupPolicyPresets: policies.filterSetupPolicyPresets, LOCAL_INFERENCE_PROVIDERS, presetsCheckboxSelector, selectPolicyTier, diff --git a/src/lib/policies.ts b/src/lib/policies.ts index c2dad0850ea..2158cb6f0a1 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -37,6 +37,10 @@ type SelectionOptions = { applied?: string[]; }; +type SetupPolicyPresetSupportOptions = { + webSearchSupported?: boolean | null; +}; + function isPolicyDocument(value: PolicyValue): value is PolicyDocument { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -116,6 +120,41 @@ function getMessagingPresetWarning(presetName: string): string | null { ].join("\n "); } +function setupPolicyPresetSupported( + name: string, + options: SetupPolicyPresetSupportOptions = {}, +): boolean { + return name !== "brave" || options.webSearchSupported !== false; +} + +function filterSetupPolicyPresets( + presets: T[], + options: SetupPolicyPresetSupportOptions = {}, +): T[] { + return presets.filter((preset) => setupPolicyPresetSupported(preset.name, options)); +} + +function listSetupPolicyPresets( + sandboxName: string, + options: SetupPolicyPresetSupportOptions = {}, +): PresetInfo[] { + return [...filterSetupPolicyPresets(listPresets(), options), ...listCustomPresets(sandboxName)]; +} + +function clampSetupPolicyPresetNames( + presetNames: string[], + allowedPresets: Array<{ name: string }>, + options: SetupPolicyPresetSupportOptions = {}, + customPresetNames: ReadonlySet = new Set(), +): string[] { + const knownPresets = new Set(allowedPresets.map((p) => p.name)); + return presetNames.filter((name) => { + if (!knownPresets.has(name)) return false; + if (customPresetNames.has(name)) return true; + return setupPolicyPresetSupported(name, options); + }); +} + /** * Extract just the network_policies entries (indented content under * the `network_policies:` key) from a preset file, stripping the @@ -941,6 +980,10 @@ export { loadPreset, getPresetEndpoints, getMessagingPresetWarning, + setupPolicyPresetSupported, + filterSetupPolicyPresets, + listSetupPolicyPresets, + clampSetupPolicyPresetNames, extractPresetEntries, parseCurrentPolicy, buildPolicySetCommand, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index d3da119e582..9e60a1e9e1e 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -52,9 +52,9 @@ type OnboardTestInternals = { classifySandboxCreateFailure: (output?: string) => { kind: string; uploadedToGateway: boolean }; compactText: (value?: string) => string; computeSetupPresetSuggestions: ShimFn; - filterPolicyPresetsForAgent: ( + filterSetupPolicyPresets: ( presets: T[], - agentName?: string | null, + options?: { webSearchSupported?: boolean | null }, ) => T[]; formatEnvAssignment: (name: string, value: string) => string; findDashboardForwardOwner: ( @@ -153,7 +153,7 @@ function isOnboardTestInternals( typeof value.classifySandboxCreateFailure === "function" && typeof value.hasChatCompletionsToolCall === "function" && typeof value.hasChatCompletionsToolCallLeak === "function" && - typeof value.filterPolicyPresetsForAgent === "function" && + typeof value.filterSetupPolicyPresets === "function" && typeof value.getDefaultSandboxNameForAgent === "function" && typeof value.getSandboxPromptDefault === "function" && typeof value.getRequestedSandboxAgentName === "function" && @@ -185,7 +185,7 @@ const { classifySandboxCreateFailure, compactText, computeSetupPresetSuggestions, - filterPolicyPresetsForAgent, + filterSetupPolicyPresets, formatEnvAssignment, getNavigationChoice, getGatewayReuseState, @@ -491,14 +491,25 @@ describe("onboard helpers", () => { expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); }); - it("omits Brave from Hermes onboarding presets", () => { + it("omits Brave when web search is unsupported", () => { const allPresets = known.map((name) => ({ name })); - const hermesPresets = filterPolicyPresetsForAgent(allPresets, "hermes").map((p) => p.name); - const openclawPresets = filterPolicyPresetsForAgent(allPresets, "openclaw").map( - (p) => p.name, - ); - expect(hermesPresets).not.toContain("brave"); - expect(openclawPresets).toContain("brave"); + const unsupportedPresets = filterSetupPolicyPresets(allPresets, { + webSearchSupported: false, + }).map((p) => p.name); + const supportedPresets = filterSetupPolicyPresets(allPresets, { + webSearchSupported: true, + }).map((p) => p.name); + expect(unsupportedPresets).not.toContain("brave"); + expect(supportedPresets).toContain("brave"); + }); + + it("drops Brave tier defaults when web search is unsupported", () => { + const suggestions = computeSetupPresetSuggestions("balanced", { + enabledChannels: [], + knownPresetNames: known, + webSearchSupported: false, + }); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); }); it("forwards enabled messaging channels into the balanced tier suggestions", () => { @@ -546,6 +557,15 @@ describe("onboard helpers", () => { expect(suggestions).toContain("brave"); }); + it("does not add Brave from webSearchConfig when web search is unsupported", () => { + const suggestions = computeSetupPresetSuggestions("restricted", { + webSearchConfig: { provider: "brave" }, + knownPresetNames: known, + webSearchSupported: false, + }); + expect(suggestions).not.toContain("brave"); + }); + it("adds local-inference for local providers", () => { const suggestions = computeSetupPresetSuggestions("balanced", { provider: "ollama-local", @@ -4287,7 +4307,7 @@ const { setupInference } = require(${onboardPath}); assert.match(source, /skippedStepMessage\("openclaw", sandboxName\)/); assert.match( source, - /skippedStepMessage\("policies", recordedPolicyPresetsForAgent\.join\(", "\)\)/, + /skippedStepMessage\("policies", recordedPolicyPresetsForSupport\.join\(", "\)\)/, ); }); diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 5656ba88171..eff3946856c 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -288,7 +288,7 @@ console.log = (...args) => lines.push(args.join(" ")); assert.deepEqual(payload.applied, []); }); - it("omits Brave from Hermes policy preset selection", () => { + it("omits Brave from policy preset selection when web search is unsupported", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); const script = buildPreamble({ @@ -307,7 +307,7 @@ console.log = () => {}; (async () => { try { - const applied = await setupPoliciesWithSelection("test-sb", { agentName: "hermes" }); + const applied = await setupPoliciesWithSelection("test-sb", { webSearchSupported: false }); process.stdout.write(JSON.stringify({ applied, appliedCalls }) + "\n"); } catch (err) { process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); @@ -318,15 +318,18 @@ console.log = () => {}; assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.ok(!payload.error, `unexpected error: ${payload.error}`); - assert.ok(!payload.applied.includes("brave"), `Hermes presets included Brave: ${payload.applied}`); + assert.ok( + !payload.applied.includes("brave"), + `Unsupported web-search presets included Brave: ${payload.applied}`, + ); assert.ok( !payload.appliedCalls.includes("brave"), - `Hermes applied Brave: ${payload.appliedCalls}`, + `Unsupported web-search flow applied Brave: ${payload.appliedCalls}`, ); - assert.ok(payload.applied.includes("pypi"), "Hermes should still include normal dev presets"); + assert.ok(payload.applied.includes("pypi"), "normal dev presets should still be included"); }); - it("removes a previously-applied Brave preset during Hermes policy selection", () => { + it("removes a previously-applied Brave preset when web search is unsupported", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); const script = buildPreamble({ @@ -347,7 +350,7 @@ console.log = () => {}; (async () => { try { - const applied = await setupPoliciesWithSelection("test-sb", { agentName: "hermes" }); + const applied = await setupPoliciesWithSelection("test-sb", { webSearchSupported: false }); process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); } catch (err) { process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); @@ -360,16 +363,19 @@ console.log = () => {}; assert.ok(!payload.error, `unexpected error: ${payload.error}`); assert.ok( !payload.applied.includes("brave"), - `Hermes presets included Brave: ${payload.applied}`, + `Unsupported web-search presets included Brave: ${payload.applied}`, ); assert.ok( payload.removedCalls.includes("brave"), - `Hermes did not remove Brave: ${payload.removedCalls}`, + `Unsupported web-search flow did not remove Brave: ${payload.removedCalls}`, + ); + assert.ok( + !payload.appliedCalls.includes("brave"), + `Unsupported web-search flow applied Brave: ${payload.appliedCalls}`, ); - assert.ok(!payload.appliedCalls.includes("brave"), `Hermes applied Brave: ${payload.appliedCalls}`); }); - it("clamps resumed Hermes policy presets to agent-allowed presets", () => { + it("clamps resumed policy presets to web-search-supported presets", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); const script = buildPreamble({ @@ -391,7 +397,7 @@ console.log = () => {}; (async () => { try { const applied = await setupPoliciesWithSelection("test-sb", { - agentName: "hermes", + webSearchSupported: false, selectedPresets: ["brave", "npm"], }); process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); @@ -409,7 +415,47 @@ console.log = () => {}; assert.deepEqual(payload.removedCalls, ["brave"]); }); - it("preserves a resumed Hermes custom preset whose name matches an excluded built-in", () => { + it("clamps an unsupported-only resumed policy preset list to empty", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); + const script = + buildPreamble({ + tierEnv: "balanced", + policyMode: "suggested", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +const removedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; +policies.getAppliedPresets = () => ["brave"]; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { + webSearchSupported: false, + selectedPresets: ["brave"], + }); + process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + assert.deepEqual(payload.applied, []); + assert.deepEqual(payload.appliedCalls, []); + assert.deepEqual(payload.removedCalls, ["brave"]); + }); + + it("preserves a resumed custom preset whose name matches an unsupported built-in", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); const script = buildPreamble({ @@ -432,7 +478,7 @@ console.log = () => {}; (async () => { try { const applied = await setupPoliciesWithSelection("test-sb", { - agentName: "hermes", + webSearchSupported: false, selectedPresets: ["brave", "npm"], }); process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); @@ -450,7 +496,7 @@ console.log = () => {}; assert.deepEqual(payload.removedCalls, []); }); - it("preserves a non-interactive Hermes custom preset whose name matches an excluded built-in", () => { + it("preserves a non-interactive custom preset whose name matches an unsupported built-in", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policies.js")); const script = buildPreamble({ @@ -472,7 +518,7 @@ console.log = () => {}; (async () => { try { - const applied = await setupPoliciesWithSelection("test-sb", { agentName: "hermes" }); + const applied = await setupPoliciesWithSelection("test-sb", { webSearchSupported: false }); process.stdout.write(JSON.stringify({ applied, appliedCalls, removedCalls }) + "\n"); } catch (err) { process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); @@ -485,11 +531,11 @@ console.log = () => {}; assert.ok(!payload.error, `unexpected error: ${payload.error}`); assert.ok( payload.applied.includes("brave"), - `Hermes custom Brave was dropped: ${payload.applied}`, + `custom Brave was dropped: ${payload.applied}`, ); assert.ok( !payload.appliedCalls.includes("brave"), - `Hermes re-applied Brave: ${payload.appliedCalls}`, + `custom Brave was re-applied: ${payload.appliedCalls}`, ); assert.deepEqual(payload.removedCalls, []); });