diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2b197d9abfc..a0ea8f9cf87 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4597,7 +4597,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, runOpenshell: runCoreGatewayOpenshell, redact }), preparePolicyPresetResumeSelection: (name, options) => - preparePolicyPresetResumeSelection({ policies }, name, options), + preparePolicyPresetResumeSelection({ policies, tiers }, name, options), arePolicyPresetsApplied, skippedStepMessage, recordStateSkipped, diff --git a/src/lib/onboard/policy-preset-reconciliation.ts b/src/lib/onboard/policy-preset-reconciliation.ts index efb3953daf9..ac68ab9e60e 100644 --- a/src/lib/onboard/policy-preset-reconciliation.ts +++ b/src/lib/onboard/policy-preset-reconciliation.ts @@ -84,6 +84,7 @@ export function isStaleBuiltinBravePolicyPreset( options: { webSearchConfig?: WebSearchConfig | null; customPresetNames?: ReadonlySet | null; + tierDefaultPresetNames?: ReadonlySet | null; } = {}, ): boolean { return isStaleBuiltinWebSearchPolicyPreset(name, options); @@ -94,9 +95,17 @@ export function isStaleBuiltinWebSearchPolicyPreset( options: { webSearchConfig?: WebSearchConfig | null; customPresetNames?: ReadonlySet | null; + tierDefaultPresetNames?: ReadonlySet | null; } = {}, ): boolean { if (options.customPresetNames?.has(name)) return false; + // brave/tavily double as a tier's default egress preset (e.g. Brave Search API + // host access on the Balanced/Open tiers) AND the built-in web-search provider + // preset. When the preset is a default of the tier being applied it is a tier + // egress default, not a stale web-search leftover — keep it regardless of the + // web-search provider choice. The Restricted tier lists no such default, so a + // genuinely stale brave/tavily there still prunes. (#6844) + if (options.tierDefaultPresetNames?.has(name)) return false; if (name === "nous-web") { return Boolean( options.webSearchConfig && webSearchProviderForConfig(options.webSearchConfig) === "tavily", @@ -114,14 +123,23 @@ export function createUnavailablePolicyPresetPruner(options: { webSearchConfig?: WebSearchConfig | null; customPresetNames?: ReadonlySet | null; customOwnsObservability?: boolean; -}): (presetNames: string[], pruning?: { preserveExplicitWebSearch?: boolean }) => string[] { +}): ( + presetNames: string[], + pruning?: { + preserveExplicitWebSearch?: boolean; + tierDefaultPresetNames?: ReadonlySet | null; + }, +) => string[] { // Custom and interactive selections may explicitly opt into a built-in web-search // preset without storing provider config. Inactive observability remains ineligible. return (presetNames, pruning = {}) => pruneDisabledMessagingPolicyPresets(presetNames, options.disabledChannels).filter( (name) => (pruning.preserveExplicitWebSearch || - !isStaleBuiltinWebSearchPolicyPreset(name, options)) && + !isStaleBuiltinWebSearchPolicyPreset(name, { + ...options, + tierDefaultPresetNames: pruning.tierDefaultPresetNames, + })) && !isInactiveObservabilityPolicyPreset(name, options), ); } diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts index 5237cef8587..d70f3022703 100644 --- a/src/lib/onboard/policy-resume-selection.test.ts +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -87,6 +87,70 @@ describe("preparePolicyPresetResumeSelection web search reconciliation", () => { }); }); +function tiers(defaults: Record) { + return { + resolveTierPresets: (tierName: string) => (defaults[tierName] ?? []).map((name) => ({ name })), + }; +} + +describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", () => { + const BALANCED = { balanced: ["npm", "brave"], restricted: [] as string[] }; + + it("preserves brave on reuse when it is a Balanced-tier default and web search is off", () => { + const result = preparePolicyPresetResumeSelection( + { policies: policies(), tiers: tiers(BALANCED) }, + "alpha", + { + recordedPolicyPresets: ["npm", "brave"], + agent: "openclaw", + webSearchConfig: null, + webSearchSupported: true, + tierName: "balanced", + }, + ); + + // brave is a Balanced default (an egress preset), not a stale web-search + // leftover — it must survive reuse just like npm, and no reconcile is needed. + expect(result.policyPresets).toEqual(["npm", "brave"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(false); + }); + + it("still prunes a stale brave on the Restricted tier (no brave default)", () => { + const result = preparePolicyPresetResumeSelection( + { policies: policies(), tiers: tiers(BALANCED) }, + "alpha", + { + recordedPolicyPresets: ["npm", "brave"], + agent: "openclaw", + webSearchConfig: null, + webSearchSupported: true, + tierName: "restricted", + }, + ); + + expect(result.policyPresets).toEqual(["npm"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + }); + + it("keeps brave on Balanced even when web search is set to a different provider", () => { + const result = preparePolicyPresetResumeSelection( + { policies: policies(), tiers: tiers(BALANCED) }, + "alpha", + { + recordedPolicyPresets: ["npm", "brave"], + agent: "openclaw", + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + webSearchConfigChanged: true, + webSearchSupported: true, + tierName: "balanced", + }, + ); + + // brave stays as the tier egress default; tavily is added as the active provider. + expect(result.policyPresets).toEqual(["npm", "brave", "tavily"]); + }); +}); + describe("preparePolicyPresetResumeSelection observability reconciliation", () => { it("adds the local OTLP preset only while Deep Agents Code observability is enabled", () => { const enabled = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index 497348ef584..90efc6f88e3 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -45,8 +45,12 @@ type PoliciesApi = { ): string[]; }; +type TiersApi = { + resolveTierPresets(tierName: string): Preset[]; +}; + export function preparePolicyPresetResumeSelection( - deps: { policies: PoliciesApi }, + deps: { policies: PoliciesApi; tiers?: TiersApi }, sandboxName: string, options: { recordedPolicyPresets: string[] | null; @@ -100,10 +104,18 @@ export function preparePolicyPresetResumeSelection( supportOptions, customPolicyPresetNames, ); + // Defaults of the recorded/active tier (e.g. `brave` on Balanced) are tier + // egress presets, not stale web-search leftovers — exempt them from the + // web-search staleness prune so re-onboard reuse preserves them. (#6844) + const tierDefaultPresetNames = + options.tierName && deps.tiers + ? new Set(deps.tiers.resolveTierPresets(options.tierName).map((preset) => preset.name)) + : null; const isStaleBuiltinWebSearch = (name: string) => isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, + tierDefaultPresetNames, }); const isInactiveObservability = (name: string) => isInactiveObservabilityPolicyPreset(name, { diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 3bf8168fcdf..90d0e27a5e8 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -320,6 +320,12 @@ async function setupPoliciesWithSelectionInner( // branch before presets are recorded, so its persisted tier must also win // over a new prompt or non-interactive default. const recordedTierName = options.tierName ?? deps.getRecordedPolicyTier?.(sandboxName) ?? null; + // Defaults of the recorded tier (e.g. `brave` on Balanced) are tier egress + // presets, not stale web-search leftovers — exempt them from pruning so a + // reconcile-triggered reuse reapply preserves them. (#6844) + const recordedTierDefaultPresetNames = recordedTierName + ? new Set(deps.tiers.resolveTierPresets(recordedTierName).map((preset) => preset.name)) + : null; if (chosen !== null) { const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { @@ -334,7 +340,9 @@ async function setupPoliciesWithSelectionInner( customPresetNames, customOwnsObservability, }); - chosen = pruneUnavailablePresets(chosen); + chosen = pruneUnavailablePresets(chosen, { + tierDefaultPresetNames: recordedTierDefaultPresetNames, + }); } if (selectedPresets !== null) {