diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index c89f3ff0ebf..38ccd263cc1 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -54,6 +54,7 @@ describe("agent definitions", () => { "wechat", "whatsapp", ]); + expect(openclaw.inferenceProviderOptions).toEqual([]); expect(openclaw.legacyPaths?.startScript).toContain("scripts/nemoclaw-start.sh"); }); @@ -95,6 +96,12 @@ describe("agent definitions", () => { ); }); + it("treats an explicit agent flag as overriding NEMOCLAW_AGENT", () => { + process.env.NEMOCLAW_AGENT = "hermes"; + + expect(resolveAgentName({ agentFlag: "openclaw" })).toBe("openclaw"); + }); + it("rejects non-object manifest payloads", () => { const agentName = `invalid-top-level-manifest-${String(Date.now())}`; writeTempAgentManifest(agentName, ["- not", "- an", "- object"].join("\n")); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f897fcc94d2..d7accfd73e9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -81,6 +81,9 @@ const { hasWechatConfigDrift, toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); +const { + clearAgentScopedResumeState, +}: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); const { setupSelectedMessagingChannels, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); @@ -90,6 +93,12 @@ const { buildVllmMenuEntries }: typeof import("./onboard/vllm-menu") = require(" const { prepareModelRouterVenv, }: typeof import("./onboard/model-router-python") = require("./onboard/model-router-python"); +const { + isProcessRunning, + isRouterHealthy, + stopModelRouterProcess, + stopTrackedModelRouterForAgentChange, +}: typeof import("./onboard/model-router-process") = require("./onboard/model-router-process"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -380,6 +389,7 @@ import { decidePolicyCarryForward } from "./onboard/policy-carryforward"; import { getSuggestedPolicyPresets } from "./onboard/policy-presets"; import { computeSetupPresetSuggestions as computeSetupPresetSuggestionsImpl, + isStaleBuiltinBravePolicyPreset, setupPoliciesWithSelection as setupPoliciesWithSelectionImpl, type SetupPolicySelectionOptions, type SetupPresetSuggestionOptions, @@ -738,7 +748,6 @@ function loadBlueprintProfile( const ROUTER_HEALTH_RETRIES = 15; const ROUTER_HEALTH_INTERVAL_MS = 2000; -const ROUTER_HEALTH_TIMEOUT_MS = 3000; const MODEL_ROUTER_RELATIVE_DIR = path.join("nemoclaw-blueprint", "router", "llm-router"); const MODEL_ROUTER_VENV_DIR = path.join(os.homedir(), ".nemoclaw", "model-router-venv"); const MODEL_ROUTER_FINGERPRINT_FILE = ".nemoclaw-source-fingerprint"; @@ -758,59 +767,6 @@ const MODEL_ROUTER_FINGERPRINT_IGNORED_NAMES = new Set([ ]); const DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV = "NVIDIA_API_KEY"; -async function isRouterHealthy(port: number, timeoutMs = ROUTER_HEALTH_TIMEOUT_MS): Promise { - const http = require("http"); - return new Promise((resolve) => { - let settled = false; - const settle = (healthy: boolean) => { - if (settled) return; - settled = true; - resolve(healthy); - }; - const request = http - .get(`http://127.0.0.1:${port}/health`, (res: import("node:http").IncomingMessage) => { - res.resume(); - settle((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300); - }) - .on("error", () => settle(false)); - request.setTimeout(timeoutMs, () => { - request.destroy(); - settle(false); - }); - }); -} - -function isProcessRunning(pid: number | null | undefined): boolean { - if (!Number.isInteger(pid) || Number(pid) <= 0) return false; - try { - process.kill(Number(pid), 0); - return true; - } catch { - return false; - } -} - -async function stopModelRouterProcess(pid: number, port: number): Promise { - try { - process.kill(pid, "SIGTERM"); - } catch { - return; - } - for (let attempt = 0; attempt < 10; attempt++) { - await new Promise((resolve) => setTimeout(resolve, 500)); - if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return; - } - try { - process.kill(pid, "SIGKILL"); - } catch { - // already stopped - } - for (let attempt = 0; attempt < 5; attempt++) { - await new Promise((resolve) => setTimeout(resolve, 500)); - if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return; - } -} - function resolveHostCommandPath(commandName: string): string | null { const result = runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], { ignoreError: true, @@ -2443,16 +2399,6 @@ function getResumeConfigConflicts( }); } - const requestedAgent = opts.agent || process.env.NEMOCLAW_AGENT || null; - const recordedAgent = session?.agent || null; - if (requestedAgent && recordedAgent && requestedAgent !== recordedAgent) { - conflicts.push({ - field: "agent", - requested: requestedAgent, - recorded: recordedAgent, - }); - } - return conflicts; } @@ -5138,7 +5084,15 @@ async function createSandbox( } const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName); - const decision = decidePolicyCarryForward(previousEntry?.policies, process.env, isNonInteractive()); + const previousPoliciesForCarryForward = recreateForAgentDrift ? null : previousEntry?.policies; + if (recreateForAgentDrift && previousEntry?.policies && previousEntry.policies.length > 0) { + note(" Agent type changed; refreshing policy presets instead of carrying them forward."); + } + const decision = decidePolicyCarryForward( + previousPoliciesForCarryForward, + process.env, + isNonInteractive(), + ); onboardSession.updateSession((c: Session) => { c.policyPresets = decision.newPresets; return c; @@ -9355,8 +9309,27 @@ async function onboard(opts: OnboardOptions = {}): Promise { resume, canPrompt: !cannotPrompt, }); + const selectedAgentName = normalizeSandboxAgentName(agent?.name); + const recordedAgentName = normalizeSandboxAgentName(session?.agent); + let resumeAgentChanged = false; + let forceProviderSelectionForAgentChange = false; + if (resume && session && recordedAgentName !== selectedAgentName) { + resumeAgentChanged = true; + forceProviderSelectionForAgentChange = true; + note( + ` Agent changed from ${formatSandboxAgentName(recordedAgentName)} to ${formatSandboxAgentName(selectedAgentName)}; refreshing provider selection.`, + ); + // Agent changes are recoverable resume drift: refresh scoped state instead of rejecting --resume. + await stopTrackedModelRouterForAgentChange( + session, + loadBlueprintProfile("routed")?.router.port || 4000, + ); + onboardSession.updateSession((current: Session) => + clearAgentScopedResumeState(current, selectedAgentName), + ); + } setOnboardBrandingAgent(agent?.name || "openclaw"); - onboardSession.updateSession((s: Session) => { + session = onboardSession.updateSession((s: Session) => { s.agent = agent?.name ?? null; return s; }); @@ -9601,7 +9574,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { let preferredInferenceApi = session?.preferredInferenceApi || null; let nimContainer = session?.nimContainer || null; let webSearchConfig = session?.webSearchConfig || null; - let forceProviderSelection = false; + let forceProviderSelection = forceProviderSelectionForAgentChange; while (true) { const resumeProviderSelection = !forceProviderSelection && @@ -9824,6 +9797,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { ); const resumeSandbox = resume && + !resumeAgentChanged && !webSearchConfigChanged && !telegramConfigChanged && !sandboxGpuConfigChanged && @@ -9840,7 +9814,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { skippedStepMessage("sandbox", sandboxName); } else { if (resume && session?.steps?.sandbox?.status === "complete") { - if (webSearchConfigChanged) { + if (resumeAgentChanged) { + note( + " [resume] Agent selection changed; revalidating sandbox compatibility.", + ); + } else if (webSearchConfigChanged) { note(" [resume] Web Search configuration changed; recreating sandbox."); if (sandboxName) { registry.removeSandbox(sandboxName); @@ -10036,6 +10014,13 @@ async function onboard(opts: OnboardOptions = {}): Promise { policyPresetSupportOptions, customPolicyPresetNames, ); + recordedPolicyPresetsForSupport = recordedPolicyPresetsForSupport.filter( + (name) => + !isStaleBuiltinBravePolicyPreset(name, { + webSearchConfig, + customPresetNames: customPolicyPresetNames, + }), + ); if (recordedPolicyPresets) { recordedPolicyPresetsForSupport = mergeRequiredHermesToolGatewayPolicyPresets( recordedPolicyPresetsForSupport, @@ -10241,6 +10226,7 @@ module.exports = { hasStaleGateway, getRequestedSandboxNameHint, getResumeSandboxConflict, + clearAgentScopedResumeState, getSandboxReuseState, getSandboxStateFromOutputs, getPortConflictServiceHints, diff --git a/src/lib/onboard/agent-resume-state.ts b/src/lib/onboard/agent-resume-state.ts new file mode 100644 index 00000000000..7e338903081 --- /dev/null +++ b/src/lib/onboard/agent-resume-state.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Session } from "../state/onboard-session"; + +export function normalizeAgentNameForResumeState(agentName: string | null | undefined): string { + const trimmed = typeof agentName === "string" ? agentName.trim() : ""; + return trimmed && trimmed !== "openclaw" ? trimmed : "openclaw"; +} + +export function resetStepForAgentChange(session: Session, stepName: string): void { + const stepState = session.steps[stepName]; + if (!stepState) return; + stepState.status = "pending"; + stepState.startedAt = null; + stepState.completedAt = null; + stepState.error = null; +} + +export function clearAgentScopedResumeState( + session: Session, + selectedAgentName: string, +): Session { + const normalizedAgentName = normalizeAgentNameForResumeState(selectedAgentName); + session.agent = normalizedAgentName === "openclaw" ? null : normalizedAgentName; + session.provider = null; + session.model = null; + session.endpointUrl = null; + session.credentialEnv = null; + session.hermesAuthMethod = null; + session.hermesToolGateways = null; + session.preferredInferenceApi = null; + session.nimContainer = null; + session.routerPid = null; + session.routerCredentialHash = null; + session.policyPresets = null; + + const resetSteps = [ + "provider_selection", + "inference", + "sandbox", + "openclaw", + "agent_setup", + "policies", + ]; + for (const stepName of resetSteps) resetStepForAgentChange(session, stepName); + if (session.lastCompletedStep && resetSteps.includes(session.lastCompletedStep)) { + session.lastCompletedStep = + session.steps.gateway?.status === "complete" + ? "gateway" + : session.steps.preflight?.status === "complete" + ? "preflight" + : null; + } + if (session.lastStepStarted && resetSteps.includes(session.lastStepStarted)) { + session.lastStepStarted = null; + } + return session; +} diff --git a/src/lib/onboard/model-router-process.ts b/src/lib/onboard/model-router-process.ts new file mode 100644 index 00000000000..b9f73fb57d6 --- /dev/null +++ b/src/lib/onboard/model-router-process.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as http from "node:http"; +import type { Session } from "../state/onboard-session"; + +export const ROUTER_HEALTH_TIMEOUT_MS = 3000; + +export async function isRouterHealthy( + port: number, + timeoutMs = ROUTER_HEALTH_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + let settled = false; + const settle = (healthy: boolean) => { + if (settled) return; + settled = true; + resolve(healthy); + }; + const request = http + .get(`http://127.0.0.1:${port}/health`, (res: http.IncomingMessage) => { + res.resume(); + settle((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300); + }) + .on("error", () => settle(false)); + request.setTimeout(timeoutMs, () => { + request.destroy(); + settle(false); + }); + }); +} + +export function isProcessRunning(pid: number | null | undefined): boolean { + if (!Number.isInteger(pid) || Number(pid) <= 0) return false; + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } +} + +export async function stopModelRouterProcess(pid: number, port: number): Promise { + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } + for (let _attempt = 0; _attempt < 10; _attempt++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return; + } + try { + process.kill(pid, "SIGKILL"); + } catch { + // already stopped + } + for (let _attempt = 0; _attempt < 5; _attempt++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return; + } +} + +export async function stopTrackedModelRouterForAgentChange( + session: Pick | null, + port: number, +): Promise { + const recordedPid = session?.routerPid ?? null; + if (!recordedPid) return; + await stopModelRouterProcess(recordedPid, port); +} diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index bfb29500d40..a05ec9677f6 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -71,6 +71,20 @@ export type SetupPolicySelectionDeps = { env?: NodeJS.ProcessEnv; }; +export function isStaleBuiltinBravePolicyPreset( + name: string, + options: { + webSearchConfig?: WebSearchConfig | null; + customPresetNames?: ReadonlySet | null; + } = {}, +): boolean { + return ( + name === "brave" && + !options.webSearchConfig && + !options.customPresetNames?.has(name) + ); +} + export function computeSetupPresetSuggestions( deps: { policies: PoliciesApi; @@ -86,6 +100,7 @@ export function computeSetupPresetSuggestions( const suggestions = deps.tiers .resolveTierPresets(tierName) .map((preset) => preset.name) + .filter((name) => !isStaleBuiltinBravePolicyPreset(name, { webSearchConfig })) .filter((name) => deps.policies.setupPolicyPresetSupported(name, supportOptions)) .filter((name) => !known || known.has(name)); const add = (name: string) => { @@ -140,6 +155,8 @@ export async function setupPoliciesWithSelection( supportOptions, customPresetNames, ); + const isStaleBuiltinBrave = (name: string) => + isStaleBuiltinBravePolicyPreset(name, { webSearchConfig, customPresetNames }); const filterSupportedPresetNames = (presetNames: string[]) => presetNames.filter( (name) => @@ -233,6 +250,7 @@ export async function setupPoliciesWithSelection( const preserved: string[] = []; for (const name of applied) { if (chosenSet.has(name)) continue; + if (isStaleBuiltinBrave(name)) continue; chosen.push(name); chosenSet.add(name); preserved.push(name); @@ -254,7 +272,7 @@ export async function setupPoliciesWithSelection( const knownNames = new Set(allPresets.map((preset) => preset.name)); const extraSelected = [ - ...applied.filter((name) => knownNames.has(name)), + ...applied.filter((name) => knownNames.has(name) && !isStaleBuiltinBrave(name)), ...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)), ]; const resolvedPresets = await deps.selectTierPresetsAndAccess(tierName, allPresets, extraSelected); diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index a884aab05aa..f28d7b25174 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -14,7 +14,7 @@ const { enabledChannels?: string[] | null; knownPresetNames: string[]; provider?: string | null; - webSearchConfig?: { provider?: string | null } | null; + webSearchConfig?: { fetchEnabled?: boolean; provider?: string | null } | null; webSearchSupported?: boolean | null; }, ) => string[]; @@ -108,6 +108,16 @@ describe("onboard policy preset suggestions", () => { enabledChannels: [], knownPresetNames: known, }); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); + }); + + it("adds Brave to balanced tier defaults only when web search is configured", () => { + const suggestions = computeSetupPresetSuggestions("balanced", { + enabledChannels: [], + knownPresetNames: known, + webSearchConfig: { fetchEnabled: true }, + webSearchSupported: true, + }); expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave"]); }); @@ -147,7 +157,7 @@ describe("onboard policy preset suggestions", () => { }); expect(suggestions).toContain("telegram"); expect(suggestions).toContain("npm"); - expect(suggestions).toContain("brave"); + expect(suggestions).not.toContain("brave"); const multi = computeSetupPresetSuggestions("balanced", { enabledChannels: ["discord", "slack"], diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index 9e3aec75873..0c0c680e94e 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -195,17 +195,17 @@ console.log = () => {}; `expected chosen to preserve local-inference, got ${JSON.stringify(payload.chosen)}`, ); - // Nothing should be removed — every applied preset is either a tier - // default or a user-added extra that the additive policy preserves. + // User-added extras stay additive, but built-in Brave is no longer + // preserved after Brave search was declined. assert.deepEqual( payload.removedCalls, - [], - `expected no removals, got ${JSON.stringify(payload.removedCalls)}`, + ["brave"], + `expected only stale built-in Brave to be removed, got ${JSON.stringify(payload.removedCalls)}`, ); - // Final state should still contain every previously-applied preset. + // Final state should still contain every non-Brave previously-applied preset. const finalSorted = payload.finalApplied.slice().sort(); - assert.deepEqual(finalSorted, ["brave", "brew", "huggingface", "local-inference", "npm", "pypi"]); + assert.deepEqual(finalSorted, ["brew", "huggingface", "local-inference", "npm", "pypi"]); }); // Custom presets loaded via `policy-add --from-file` / `--from-dir` are @@ -241,8 +241,8 @@ console.log = () => {}; ); assert.deepEqual( payload.removedCalls, - [], - `expected no removals, got ${JSON.stringify(payload.removedCalls)}`, + ["brave"], + `expected only stale built-in Brave to be removed, got ${JSON.stringify(payload.removedCalls)}`, ); }); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index e05ecf72ca8..9b5111a402f 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -35,6 +35,10 @@ type OnboardTestInternals = { requestedSandboxName: string; recordedSandboxName: string; } | null>; + clearAgentScopedResumeState: >( + session: T, + selectedAgentName: string, + ) => T; pullAndResolveBaseImageDigest: () => { digest: string | null; ref: string } | null; SANDBOX_BASE_IMAGE: string; }; @@ -80,6 +84,7 @@ const { getRequestedSandboxNameHint, getResumeConfigConflicts, getResumeSandboxConflict, + clearAgentScopedResumeState, SANDBOX_BASE_IMAGE, } = onboardTestInternals; @@ -383,7 +388,7 @@ startGateway(null).catch(() => {}); } }); - it("detects resume conflicts when a different agent is requested", () => { + it("does not treat a requested agent change as a hard resume conflict", () => { expect( getResumeConfigConflicts( { @@ -392,13 +397,7 @@ startGateway(null).catch(() => {}); }, { agent: "hermes" }, ), - ).toEqual([ - { - field: "agent", - requested: "hermes", - recorded: "openclaw", - }, - ]); + ).toEqual([]); }); it("allows resume when requested agent matches recorded agent", () => { @@ -413,6 +412,62 @@ startGateway(null).catch(() => {}); ).toEqual([]); }); + it("clears agent-scoped provider state when a resume switches from Hermes to OpenClaw", () => { + const completeStep = { + status: "complete", + startedAt: "2026-05-19T00:00:00.000Z", + completedAt: "2026-05-19T00:01:00.000Z", + error: null, + }; + const session = { + agent: "hermes", + provider: "hermes-provider", + model: "moonshotai/kimi-k2.6", + endpointUrl: "https://inference-api.nousresearch.com/v1", + credentialEnv: "NOUS_API_KEY", + hermesAuthMethod: "oauth", + hermesToolGateways: ["nous-web"], + preferredInferenceApi: "openai-completions", + nimContainer: "nim-hermes", + routerPid: 123, + routerCredentialHash: "hash", + policyPresets: ["nous-web", "brave"], + lastCompletedStep: "policies", + lastStepStarted: "policies", + steps: { + preflight: { ...completeStep }, + gateway: { ...completeStep }, + provider_selection: { ...completeStep }, + inference: { ...completeStep }, + sandbox: { ...completeStep }, + openclaw: { ...completeStep }, + agent_setup: { ...completeStep }, + policies: { ...completeStep }, + }, + }; + + const cleared = clearAgentScopedResumeState(session, "openclaw") as typeof session; + + expect(cleared.agent).toBeNull(); + expect(cleared.provider).toBeNull(); + expect(cleared.model).toBeNull(); + expect(cleared.endpointUrl).toBeNull(); + expect(cleared.credentialEnv).toBeNull(); + expect(cleared.hermesAuthMethod).toBeNull(); + expect(cleared.hermesToolGateways).toBeNull(); + expect(cleared.preferredInferenceApi).toBeNull(); + expect(cleared.nimContainer).toBeNull(); + expect(cleared.routerPid).toBeNull(); + expect(cleared.routerCredentialHash).toBeNull(); + expect(cleared.policyPresets).toBeNull(); + expect(cleared.steps.gateway.status).toBe("complete"); + expect(cleared.steps.provider_selection.status).toBe("pending"); + expect(cleared.steps.sandbox.status).toBe("pending"); + expect(cleared.steps.policies.status).toBe("pending"); + expect(cleared.lastCompletedStep).toBe("gateway"); + expect(cleared.lastStepStarted).toBeNull(); + }); + it("returns a future-shell PATH hint for user-local openshell installs", () => { expect(getFutureShellPathHint("/home/test/.local/bin", "/usr/local/bin:/usr/bin")).toBe( 'export PATH="/home/test/.local/bin:$PATH"', diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 416f735ade6..15f094d2f6b 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -384,6 +384,95 @@ console.log = () => {}; ); }); + it("removes a previously-applied built-in Brave preset when Brave search is declined", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.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.applyPresets = (_sandbox, names) => { for (const name of names) 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", { + webSearchConfig: null, + webSearchSupported: true, + }); + 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"), + `Declined Brave search flow kept built-in Brave: ${payload.applied}`, + ); + assert.ok( + payload.removedCalls.includes("brave"), + `Declined Brave search flow did not remove built-in Brave: ${payload.removedCalls}`, + ); + assert.ok( + !payload.appliedCalls.includes("brave"), + `Declined Brave search flow applied built-in Brave: ${payload.appliedCalls}`, + ); + }); + + it("keeps explicitly requested built-in Brave when web search is supported", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); + const script = + buildPreamble({ + tierEnv: "balanced", + policyMode: "custom", + policyPresets: "brave,npm", + stubOpenshellBin: true, + runCaptureReturn: "Running", + }) + + String.raw` +const policies = require(${policiesPath}); +const appliedCalls = []; +policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; +policies.getAppliedPresets = () => []; + +console.log = () => {}; + +(async () => { + try { + const applied = await setupPoliciesWithSelection("test-sb", { + webSearchConfig: null, + webSearchSupported: true, + }); + 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.deepEqual(payload.applied, ["brave", "npm"]); + assert.deepEqual(payload.appliedCalls, ["brave", "npm"]); + }); + it("clamps resumed policy presets to web-search-supported presets", () => { const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); const script =