From 2e443dac425d87aa789a7b2c4a6cd65348c692ab Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 14 Jul 2026 18:39:38 +0800 Subject: [PATCH 1/5] fix(onboard): preserve tier-default brave/tavily on re-onboard reuse On the re-onboard reuse path, a Balanced-tier default preset that doubles as a built-in web-search provider preset (`brave`, `tavily`) was silently dropped even when the policy tier and web-search choice were unchanged. The other tier defaults (`npm`, `pypi`, `huggingface`, `brew`) persisted, so only the search-provider egress (e.g. `api.search.brave.com`) was narrowed out of the reapplied set. Root cause: `isStaleBuiltinWebSearchPolicyPreset` treats `brave`/`tavily` as a stale web-search leftover whenever no matching web-search provider is configured, without distinguishing the case where the same preset is a default egress preset of the tier being applied. `preparePolicyPresetResume- Selection` pruned it, which forced a reconcile and a reapply that dropped it. Fix: add a `tierDefaultPresetNames` exemption to the staleness predicate and thread the recorded tier's defaults through the resume-selection prune and the reuse reapply prune. A preset that is a default of the applied tier is a tier egress default, not a stale web-search leftover, so it is preserved regardless of the web-search provider choice. The Restricted tier lists no such default, so a genuinely stale `brave`/`tavily` there still prunes, and provider-switch replacement (brave -> tavily) still adds the active provider. Fixes #6844 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yanyun Liao --- src/lib/onboard.ts | 2 +- .../onboard/policy-preset-reconciliation.ts | 22 ++++++- .../onboard/policy-resume-selection.test.ts | 64 +++++++++++++++++++ src/lib/onboard/policy-resume-selection.ts | 14 +++- src/lib/onboard/policy-selection.ts | 10 ++- 5 files changed, 107 insertions(+), 5 deletions(-) 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) { From 2c00468ebcdeb8b586fe207f353f3737395cb74d Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 14 Jul 2026 19:09:24 +0800 Subject: [PATCH 2/5] fix(onboard): guard tier-default resolution against unknown recorded tiers resolveTierPresets throws on an unknown tier, and the recorded tier on the reuse/resume path can be a non-canonical value (e.g. a display label or an authoritative rebuild tier not yet registered). Guard both new tier-default lookups on getTier (which returns null for unknown tiers) so a non-tier reuse path keeps its prior behavior instead of throwing. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yanyun Liao --- src/lib/onboard/policy-resume-selection.test.ts | 1 + src/lib/onboard/policy-resume-selection.ts | 3 ++- src/lib/onboard/policy-selection.ts | 10 +++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts index d70f3022703..efab639430d 100644 --- a/src/lib/onboard/policy-resume-selection.test.ts +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -89,6 +89,7 @@ describe("preparePolicyPresetResumeSelection web search reconciliation", () => { function tiers(defaults: Record) { return { + getTier: (tierName: string) => (tierName in defaults ? { name: tierName } : null), resolveTierPresets: (tierName: string) => (defaults[tierName] ?? []).map((name) => ({ name })), }; } diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index 90efc6f88e3..4aaa20f1db0 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -46,6 +46,7 @@ type PoliciesApi = { }; type TiersApi = { + getTier(tierName: string): unknown; resolveTierPresets(tierName: string): Preset[]; }; @@ -108,7 +109,7 @@ export function preparePolicyPresetResumeSelection( // 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 + options.tierName && deps.tiers?.getTier(options.tierName) ? new Set(deps.tiers.resolveTierPresets(options.tierName).map((preset) => preset.name)) : null; const isStaleBuiltinWebSearch = (name: string) => diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 90d0e27a5e8..148205f3c94 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -323,9 +323,13 @@ async function setupPoliciesWithSelectionInner( // 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; + // getTier returns null for an unknown/non-canonical recorded tier; guard on it + // so resolveTierPresets (which throws on unknown tiers) is only called for a + // real tier, leaving non-tier reuse paths on their prior behavior. + const recordedTierDefaultPresetNames = + recordedTierName && deps.tiers.getTier(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, { From 135f56b4711722c50ba753776514ce1fd097a650 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 14 Jul 2026 20:52:54 +0800 Subject: [PATCH 3/5] refactor(onboard): scope tier-default exemption via shared provenance classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the parallel `tierDefaultPresetNames` set threaded through the reuse prune sites with the existing `classifyPresetProvenance` classifier: a preset whose provenance is `source: "tier"` (a default of the applied tier) is not a stale web-search leftover. Pruning and the policy-list display now share one notion of why a preset is present, instead of a second, parallel one. - Drops the injected `tiers` dep from preparePolicyPresetResumeSelection and the getTier/resolveTierPresets guard in setupPoliciesWithSelection; the classifier fails safe on an unknown/non-canonical tier (getTier -> null -> not "tier"). - Exemption stays scoped exactly to the applied tier: Restricted lists no such default, so a genuinely stale brave/tavily there is still pruned. Added a boundary test that a non-tier-default tavily on Balanced is still pruned. - Fresh suggested onboard is intentionally unchanged: omitting brave egress without web-search is conservative-egress by design, not part of this bug. Design annotation: - invalidState: on reuse, an already-applied tier-default egress preset (brave/tavily on Balanced/Open) is pruned as a stale web-search leftover and its egress narrowed, on an unchanged tier. - sourceBoundary: isStaleBuiltinWebSearchPolicyPreset, the single predicate all reuse/resume prune sites share. - whyNotSourceFix: preset provenance is intentionally not persisted (preset-provenance.ts is display-only by design); reuse its classifier rather than reverse that decision. - regressionTest: policy-resume-selection.test.ts — preserve on Balanced, still-prune on Restricted, keep-with-switched-provider, prune-non-tier-default. - removalCondition: if preset provenance becomes persisted per-preset, replace the current-tier inference with the stored source. Verified end-to-end on our DGX aarch64 test host (GPU) against a local Ollama sandbox on main: reuse re-onboard preserves brave; the x86_64 path is unchanged. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yanyun Liao --- src/lib/onboard.ts | 2 +- .../onboard/policy-preset-reconciliation.ts | 35 +++++--- .../onboard/policy-resume-selection.test.ts | 82 +++++++++---------- src/lib/onboard/policy-resume-selection.ts | 19 ++--- src/lib/onboard/policy-selection.ts | 17 +--- 5 files changed, 76 insertions(+), 79 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a0ea8f9cf87..2b197d9abfc 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, tiers }, name, options), + preparePolicyPresetResumeSelection({ policies }, name, options), arePolicyPresetsApplied, skippedStepMessage, recordStateSkipped, diff --git a/src/lib/onboard/policy-preset-reconciliation.ts b/src/lib/onboard/policy-preset-reconciliation.ts index ac68ab9e60e..e8471ee58bb 100644 --- a/src/lib/onboard/policy-preset-reconciliation.ts +++ b/src/lib/onboard/policy-preset-reconciliation.ts @@ -13,6 +13,7 @@ import { mergeRequiredObservabilityPolicyPresets, } from "./observability-policy-presets"; import { mergeRequiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; +import { classifyPresetProvenance } from "../policy/preset-provenance"; import { filterSuppressedAgentRequiredPresets } from "./policy-tier-suppression"; export type RequiredSetupPolicyPresetOptions = { @@ -84,7 +85,8 @@ export function isStaleBuiltinBravePolicyPreset( options: { webSearchConfig?: WebSearchConfig | null; customPresetNames?: ReadonlySet | null; - tierDefaultPresetNames?: ReadonlySet | null; + tierName?: string | null; + agentName?: string | null; } = {}, ): boolean { return isStaleBuiltinWebSearchPolicyPreset(name, options); @@ -95,17 +97,28 @@ export function isStaleBuiltinWebSearchPolicyPreset( options: { webSearchConfig?: WebSearchConfig | null; customPresetNames?: ReadonlySet | null; - tierDefaultPresetNames?: ReadonlySet | null; + tierName?: string | null; + agentName?: string | 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; + // preset. When the preset is a default of the applied tier it is a tier egress + // default, not a stale web-search leftover — keep it regardless of the web-search + // provider choice. Reuse the single provenance classifier so pruning and the + // policy-list display agree on WHY a preset is present, and so the exemption is + // scoped exactly to the applied tier (Restricted lists no such default → still + // pruned). classifyPresetProvenance's getTier() returns null for an unknown / + // non-canonical tier, so this fails safe (unknown → not "tier" → not exempt). (#6844) + if ( + classifyPresetProvenance(name, { + tierName: options.tierName, + agentName: options.agentName, + }).source === "tier" + ) { + return false; + } if (name === "nous-web") { return Boolean( options.webSearchConfig && webSearchProviderForConfig(options.webSearchConfig) === "tavily", @@ -127,7 +140,7 @@ export function createUnavailablePolicyPresetPruner(options: { presetNames: string[], pruning?: { preserveExplicitWebSearch?: boolean; - tierDefaultPresetNames?: ReadonlySet | null; + tierName?: string | null; }, ) => string[] { // Custom and interactive selections may explicitly opt into a built-in web-search @@ -137,8 +150,10 @@ export function createUnavailablePolicyPresetPruner(options: { (name) => (pruning.preserveExplicitWebSearch || !isStaleBuiltinWebSearchPolicyPreset(name, { - ...options, - tierDefaultPresetNames: pruning.tierDefaultPresetNames, + webSearchConfig: options.webSearchConfig, + customPresetNames: options.customPresetNames, + tierName: pruning.tierName, + agentName: options.agent, })) && !isInactiveObservabilityPolicyPreset(name, options), ); diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts index efab639430d..cde2b2f385b 100644 --- a/src/lib/onboard/policy-resume-selection.test.ts +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -87,28 +87,18 @@ describe("preparePolicyPresetResumeSelection web search reconciliation", () => { }); }); -function tiers(defaults: Record) { - return { - getTier: (tierName: string) => (tierName in defaults ? { name: tierName } : null), - resolveTierPresets: (tierName: string) => (defaults[tierName] ?? []).map((name) => ({ name })), - }; -} - describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", () => { - const BALANCED = { balanced: ["npm", "brave"], restricted: [] as string[] }; + // These exercise the real tiers.yaml through classifyPresetProvenance (no tier + // stub): `brave` is a Balanced default, and Restricted lists no such default. 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", - }, - ); + const result = preparePolicyPresetResumeSelection({ policies: policies() }, "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. @@ -117,39 +107,47 @@ describe("preparePolicyPresetResumeSelection tier-default preservation (#6844)", }); 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", - }, - ); + const result = preparePolicyPresetResumeSelection({ policies: policies() }, "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", - }, - ); + const result = preparePolicyPresetResumeSelection({ policies: policies() }, "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"]); }); + + it("still prunes tavily on Balanced when it is not a tier default and web search is off", () => { + // Boundary: the exemption is scoped to real tier defaults. tavily is NOT a + // Balanced default (brave is), so a leftover tavily with no matching provider + // is still a stale web-search preset and must be pruned. + const result = preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { + recordedPolicyPresets: ["npm", "tavily"], + agent: "openclaw", + webSearchConfig: null, + webSearchSupported: true, + tierName: "balanced", + }); + + expect(result.policyPresets).toEqual(["npm"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + }); }); describe("preparePolicyPresetResumeSelection observability reconciliation", () => { diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index 4aaa20f1db0..62d6be1a11c 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -45,13 +45,8 @@ type PoliciesApi = { ): string[]; }; -type TiersApi = { - getTier(tierName: string): unknown; - resolveTierPresets(tierName: string): Preset[]; -}; - export function preparePolicyPresetResumeSelection( - deps: { policies: PoliciesApi; tiers?: TiersApi }, + deps: { policies: PoliciesApi }, sandboxName: string, options: { recordedPolicyPresets: string[] | null; @@ -106,17 +101,15 @@ export function preparePolicyPresetResumeSelection( 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?.getTier(options.tierName) - ? new Set(deps.tiers.resolveTierPresets(options.tierName).map((preset) => preset.name)) - : null; + // egress presets, not stale web-search leftovers — pass the recorded tier + + // agent so the shared predicate exempts them via provenance and re-onboard + // reuse preserves them. (#6844) const isStaleBuiltinWebSearch = (name: string) => isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, - tierDefaultPresetNames, + tierName: options.tierName, + agentName: options.agent, }); const isInactiveObservability = (name: string) => isInactiveObservabilityPolicyPreset(name, { diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 148205f3c94..cec749205c9 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -320,16 +320,6 @@ 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) - // getTier returns null for an unknown/non-canonical recorded tier; guard on it - // so resolveTierPresets (which throws on unknown tiers) is only called for a - // real tier, leaving non-tier reuse paths on their prior behavior. - const recordedTierDefaultPresetNames = - recordedTierName && deps.tiers.getTier(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, { @@ -344,9 +334,10 @@ async function setupPoliciesWithSelectionInner( customPresetNames, customOwnsObservability, }); - chosen = pruneUnavailablePresets(chosen, { - tierDefaultPresetNames: recordedTierDefaultPresetNames, - }); + // Pass the recorded tier so the pruner exempts that tier's egress defaults + // (e.g. `brave` on Balanced) via provenance — a reconcile-triggered reuse + // reapply must not narrow an applied tier default. (#6844) + chosen = pruneUnavailablePresets(chosen, { tierName: recordedTierName }); } if (selectedPresets !== null) { From 4e1dac1ef759b00f2dc5f21cbfbc0e32d6a73dba Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 14 Jul 2026 10:36:53 -0700 Subject: [PATCH 4/5] test(onboard): cover tier-default reuse reapply Signed-off-by: Charan Jagwani --- test/policy-tiers-onboard.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 18280a6ccd2..3f99945c756 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -483,6 +483,30 @@ describe("policy tier setup", () => { assert.deepEqual(result.appliedCalls, ["brave", "npm"]); }); + it("preserves a recorded Balanced tier default during resumed reapply (#6844)", async () => { + const result = await runPolicySetup( + { + currentApplied: ["npm", "brave"], + recordedPolicyTier: "balanced", + }, + { + selectedPresets: ["npm", "brave"], + webSearchConfig: null, + webSearchSupported: true, + }, + ); + + assert.deepEqual(result.applied, ["npm", "brave"]); + assert.deepEqual(result.syncCalls, [ + { + sandboxName: "test-sb", + current: ["npm", "brave"], + selected: ["npm", "brave"], + }, + ]); + assert.deepEqual(result.removedCalls, []); + }); + it("clamps resumed policy presets to web-search-supported presets", async () => { const result = await runPolicySetup( { From 3aa85e1242999af7faa0ecbfb9049e295c50e60e Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 14 Jul 2026 10:56:43 -0700 Subject: [PATCH 5/5] test(onboard): distinguish recorded tier reuse Signed-off-by: Charan Jagwani --- test/policy-tiers-onboard.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 3f99945c756..ce0c6ab45df 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -486,6 +486,7 @@ describe("policy tier setup", () => { it("preserves a recorded Balanced tier default during resumed reapply (#6844)", async () => { const result = await runPolicySetup( { + tierName: "restricted", currentApplied: ["npm", "brave"], recordedPolicyTier: "balanced", },