From af5344b804247a23bfc79b3ad7e08eefe7f58cf5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 7 Jul 2026 11:29:45 -0400 Subject: [PATCH 1/3] fix(desktop): treat baked build env vars as satisfying required agent config Internal (Block) builds bake provider credentials like DATABRICKS_HOST into the binary via BUZZ_BUILD_AGENT_ENV. The backend readiness gate already honors this via resolve_effective_agent_env() Layer 1, but the frontend requirement computation was unaware, surfacing a spurious amber Required badge on DATABRICKS_HOST and (since the EditAgentDialog refactor in #1540) blocking Save in the Edit dialog. New get_baked_build_env_keys Tauri command exposes key names only (never values) from baked_build_env(). Frontend useBakedBuildEnvKeysQuery() + getBakedSatisfiedEnvKeys() filter baked-satisfied keys out of requiredEnvKeys and missingEnvKeys across all three dialog consumers (useRequiredCredentialState, CreateAgentDialog, PersonaDialog), mirroring the existing file-config satisfaction pattern. OSS builds return [] so OSS behavior is unchanged. --- desktop/scripts/check-file-sizes.mjs | 7 +- .../src-tauri/src/commands/agent_config.rs | 21 +++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 4 +- desktop/src/features/agents/hooks.ts | 25 +++ .../features/agents/ui/CreateAgentDialog.tsx | 41 +++-- .../src/features/agents/ui/PersonaDialog.tsx | 14 +- .../ui/createAgentLocalModeGate.test.mjs | 153 ++++++++++++++++++ .../agents/ui/personaDialogPickers.tsx | 37 +++++ .../agents/ui/useRequiredCredentialState.ts | 45 ++++-- desktop/src/shared/api/tauri.ts | 13 ++ 11 files changed, 327 insertions(+), 34 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 54e51ebbdb..6bedd646bf 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -116,14 +116,17 @@ const overrides = new Map([ // linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field. // config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines. // +26 lines from PRs landing on main between prior rebase and this rebase. - ["src/shared/api/tauri.ts", 1375], + // baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. + ["src/shared/api/tauri.ts", 1388], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with // CreateAgentDialog). +23 lines of gate wiring. Queued to split. // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds // ~16 lines. Queued to split. - ["src/features/agents/ui/PersonaDialog.tsx", 1032], + // baked-env-required-badge: useBakedBuildEnvKeysQuery + bakedEnvKeys wiring + // adds ~6 lines. Queued to split. + ["src/features/agents/ui/PersonaDialog.tsx", 1038], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 59547764db..12cedcf184 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -184,6 +184,27 @@ pub fn get_runtime_file_config(runtime_id: String) -> Option Vec { + crate::managed_agents::baked_build_env() + .into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, _)| k) + .collect() +} + /// Get the full config surface for a managed agent. /// /// Returns normalized + advanced config from all available tiers. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 401d910b4c..80dbaa2ba3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -529,6 +529,7 @@ pub fn run() { discover_agent_models, get_agent_config_surface, get_runtime_file_config, + get_baked_build_env_keys, put_agent_session_config, mesh_availability, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 06d8a8bc71..4489f70fc0 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,6 +1,8 @@ mod agent_env; pub(crate) mod agent_events; -pub(crate) use agent_env::{build_buzz_agent_provider_defaults, discovery_env_with_baked_floor}; +pub(crate) use agent_env::{ + baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, +}; mod backend; pub(crate) mod config_bridge; mod discovery; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 39b087d5c4..44f40b66db 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -15,6 +15,7 @@ import { discoverBackendProviders, discoverManagedAgentPrereqs, getAgentConfigSurface, + getBakedBuildEnvKeys, getManagedAgentLog, getRuntimeFileConfig, installAcpRuntime, @@ -647,6 +648,30 @@ export function useRuntimeFileConfigQuery( }); } +export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const; + +/** + * Query the key names of baked build env vars. + * + * Internal (Block) builds bake provider credentials into the binary at compile + * time. This query returns the *key names only* so dialogs can treat baked keys + * as satisfying their requirements — mirroring the backend readiness gate. + * + * The value is a compile-time constant, so `staleTime: Infinity` is correct. + * In web-dev and E2E contexts where the Tauri command doesn't exist the query + * fails soft and resolves to `undefined` without crashing (same class as + * `useRuntimeFileConfigQuery`). + */ +export function useBakedBuildEnvKeysQuery(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: bakedBuildEnvKeysQueryKey, + queryFn: () => getBakedBuildEnvKeys(), + enabled: options?.enabled ?? true, + staleTime: Infinity, + refetchInterval: false, + }); +} + export function useTeamsQuery() { return useQuery({ queryKey: teamsQueryKey, diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 3f31d97156..4677c89919 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -5,6 +5,7 @@ import { useAcpRuntimesQuery, useAvailableAcpRuntimes, useBackendProvidersQuery, + useBakedBuildEnvKeysQuery, useCreateManagedAgentMutation, useManagedAgentPrereqsQuery, useRuntimeFileConfigQuery, @@ -41,6 +42,7 @@ import { meshPrepareRelayMeshClient } from "@/shared/api/tauriMesh"; import type { MeshServeTarget } from "@/shared/api/tauriMesh"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; import { + getBakedSatisfiedEnvKeys, requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, shouldClearKnownModelForSelectionScope, @@ -182,28 +184,45 @@ export function CreateAgentDialog({ selectedRuntimeId, { enabled: open }, ); + const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); + + // All required keys for the selected runtime + provider. + const allRequiredEnvKeys = React.useMemo( + () => + requiredCredentialEnvKeys( + selectedRuntimeId, + runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", + ), + [selectedRuntimeId, provider], + ); + + // Keys covered by the baked build env (Block-internal builds only) — silenced, + // produce no amber row or info row. + const bakedSatisfiedEnvKeys = React.useMemo( + () => getBakedSatisfiedEnvKeys(allRequiredEnvKeys, envVars, bakedEnvKeys), + [allRequiredEnvKeys, envVars, bakedEnvKeys], + ); + // Credential keys satisfied by the runtime file config (e.g. goose config.yaml). // These are shown as "Set in goose config" rows rather than amber required rows. const fileSatisfiedEnvKeys = React.useMemo(() => { if (!runtimeFileConfig) return [] as string[]; - const allKeys = requiredCredentialEnvKeys( - selectedRuntimeId, - runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", - ); - return allKeys.filter( + return allRequiredEnvKeys.filter( (key) => (envVars[key] ?? "").length === 0 && + !bakedSatisfiedEnvKeys.includes(key) && runtimeFileConfig.satisfiedEnvKeys.includes(key), ); - }, [runtimeFileConfig, selectedRuntimeId, provider, envVars]); + }, [runtimeFileConfig, allRequiredEnvKeys, envVars, bakedSatisfiedEnvKeys]); const requiredEnvKeys = React.useMemo( () => - requiredCredentialEnvKeys( - selectedRuntimeId, - runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", - ).filter((key) => !fileSatisfiedEnvKeys.includes(key)), - [selectedRuntimeId, provider, fileSatisfiedEnvKeys], + allRequiredEnvKeys.filter( + (key) => + !bakedSatisfiedEnvKeys.includes(key) && + !fileSatisfiedEnvKeys.includes(key), + ), + [allRequiredEnvKeys, bakedSatisfiedEnvKeys, fileSatisfiedEnvKeys], ); // Clear model when provider scope changes, mirroring EditAgentDialog. diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index a108d52351..250b64edd3 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -69,7 +69,7 @@ import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; -import { useRuntimeFileConfigQuery } from "../hooks"; +import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks"; type PersonaDialogProps = { open: boolean; @@ -431,7 +431,9 @@ export function PersonaDialog({ const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { enabled: open, }); + const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); const localModeGate = computeLocalModeGate({ + bakedEnvKeys, envVars, isProviderMode: false, model, @@ -440,12 +442,16 @@ export function PersonaDialog({ runtimeFileConfig, useMesh: false, }); - // Required keys for EnvVarsEditor amber rows: exclude file-satisfied keys - // so they render in the "Set in goose config" row instead. + // Required keys for EnvVarsEditor amber rows: only those still unresolved + // (not baked-satisfied and not file-satisfied). const requiredEnvKeys = requiredCredentialEnvKeys( runtime, trimmedProvider, - ).filter((key) => !localModeGate.fileSatisfiedEnvKeys.includes(key)); + ).filter( + (key) => + localModeGate.missingEnvKeys.includes(key) || + localModeGate.fileSatisfiedEnvKeys.includes(key), + ); // Provider required-ness is a static property of the runtime — it does not // change based on whether the field is currently filled. Using the dynamic // missingNormalizedFields check would flip the asterisk off once a value is diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs index e8e24370e5..22db480d51 100644 --- a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -21,6 +21,7 @@ import assert from "node:assert/strict"; import { computeLocalModeGate, + getBakedSatisfiedEnvKeys, requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, } from "./personaDialogPickers.tsx"; @@ -410,3 +411,155 @@ test("localMode_goose_envPlusFileConfig_bothEmpty_stillRequired", () => { ); assert.equal(result.satisfied, false, "gate must not be satisfied"); }); + +// ── Baked build env satisfaction ────────────────────────────────────────── + +test("baked_databricksHost_silencesRequirement", () => { + // Scenario: goose + databricks_v2, DATABRICKS_HOST baked in (Block build). + // The gate must NOT flag DATABRICKS_HOST as missing or required. + const result = computeLocalModeGate({ + bakedEnvKeys: ["DATABRICKS_HOST"], + envVars: {}, + isProviderMode: false, + model: "some-model", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.ok( + !result.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must NOT appear in missingEnvKeys when satisfied by baked env", + ); + assert.equal( + result.satisfied, + true, + "gate must be satisfied when all requirements are covered by baked env", + ); +}); + +test("baked_databricksHost_andAgentLocal_agentLocalWins_keyNotRequired", () => { + // Scenario: DATABRICKS_HOST in both baked env AND agent-local. + // The key must not appear as required — agent-local takes precedence at spawn + // time and the key is clearly satisfied. + const result = computeLocalModeGate({ + bakedEnvKeys: ["DATABRICKS_HOST"], + envVars: { DATABRICKS_HOST: "https://agent.example.com/" }, + isProviderMode: false, + model: "some-model", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.ok( + !result.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must not be in missingEnvKeys when set in agent-local env", + ); + assert.equal(result.satisfied, true, "gate must be satisfied"); +}); + +test("baked_emptyOrUndefined_behaviorUnchanged", () => { + // Scenario: no baked env (OSS build). DATABRICKS_HOST must still be required. + const resultUndefined = computeLocalModeGate({ + bakedEnvKeys: undefined, + envVars: {}, + isProviderMode: false, + model: "some-model", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + const resultEmpty = computeLocalModeGate({ + bakedEnvKeys: [], + envVars: {}, + isProviderMode: false, + model: "some-model", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.ok( + resultUndefined.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must be required when bakedEnvKeys is undefined", + ); + assert.ok( + resultEmpty.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must be required when bakedEnvKeys is empty", + ); + assert.equal( + resultUndefined.satisfied, + false, + "gate must not be satisfied (undefined baked)", + ); + assert.equal( + resultEmpty.satisfied, + false, + "gate must not be satisfied (empty baked)", + ); +}); + +test("baked_satisfiedKey_doesNotCountAsMissing_noSaveBlock", () => { + // A baked-satisfied key must not appear in missingEnvKeys (which drives the + // save-blocking requiredEnvKeyMissing flag in useRequiredCredentialState). + const result = computeLocalModeGate({ + bakedEnvKeys: ["DATABRICKS_HOST"], + envVars: {}, + isProviderMode: false, + model: "some-model", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.deepEqual( + result.missingEnvKeys, + [], + "missingEnvKeys must be empty when all required keys are baked-satisfied", + ); + assert.deepEqual( + result.fileSatisfiedEnvKeys, + [], + "baked-satisfied keys must not appear in fileSatisfiedEnvKeys", + ); +}); + +// ── getBakedSatisfiedEnvKeys pure function ────────────────────────────────── + +test("getBakedSatisfiedEnvKeys_bakedKeyAndNoAgentLocal_returnsBakedKey", () => { + const result = getBakedSatisfiedEnvKeys(["DATABRICKS_HOST"], {}, [ + "DATABRICKS_HOST", + ]); + assert.deepEqual(result, ["DATABRICKS_HOST"]); +}); + +test("getBakedSatisfiedEnvKeys_agentLocalSet_keyNotBakedSatisfied", () => { + // Agent-local value wins — the key is already satisfied by the agent's own + // env, so it doesn't need baked satisfaction. + const result = getBakedSatisfiedEnvKeys( + ["DATABRICKS_HOST"], + { DATABRICKS_HOST: "https://user.example.com/" }, + ["DATABRICKS_HOST"], + ); + assert.deepEqual( + result, + [], + "key with agent-local value must not be baked-satisfied", + ); +}); + +test("getBakedSatisfiedEnvKeys_undefinedBaked_returnsEmpty", () => { + const result = getBakedSatisfiedEnvKeys(["DATABRICKS_HOST"], {}, undefined); + assert.deepEqual(result, []); +}); + +test("getBakedSatisfiedEnvKeys_emptyBaked_returnsEmpty", () => { + const result = getBakedSatisfiedEnvKeys(["DATABRICKS_HOST"], {}, []); + assert.deepEqual(result, []); +}); diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index 574dabff79..9faa8e2818 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -353,6 +353,29 @@ export function getDefaultPersonaRuntime(runtimes: AcpRuntimeCatalogEntry[]) { ); } +/** + * Filter a required-key list down to those satisfied by the baked build env. + * + * A key is baked-satisfied when the agent has no local value for it AND the + * baked env (compile-time, Block-internal builds) contains it. This mirrors the + * backend readiness gate Layer 1 (`resolve_effective_agent_env`) so the dialogs + * don't surface a spurious "Required" badge for keys that are already baked in. + * + * OSS builds have an empty baked env, so this always returns `[]` there — + * OSS behavior is unchanged. + */ +export function getBakedSatisfiedEnvKeys( + requiredKeys: readonly string[], + envVars: Record, + bakedEnvKeys: readonly string[] | undefined, +): string[] { + if (!bakedEnvKeys || bakedEnvKeys.length === 0) return []; + const bakedSet = new Set(bakedEnvKeys); + return requiredKeys.filter( + (key) => (envVars[key] ?? "").length === 0 && bakedSet.has(key), + ); +} + /** * Pure local-mode readiness gate for Create (no existing agent, no config * surface query). Returns the missing normalized fields (provider, model) and @@ -368,6 +391,7 @@ export function getDefaultPersonaRuntime(runtimes: AcpRuntimeCatalogEntry[]) { * their own gates. Pass isProviderMode=true or useMesh=true to bypass. */ export function computeLocalModeGate({ + bakedEnvKeys, envVars, isProviderMode, model, @@ -376,6 +400,11 @@ export function computeLocalModeGate({ runtimeFileConfig, useMesh, }: { + /** Optional baked build env key names (Block-internal builds only). + * When provided, requirements already covered by the baked env are silenced, + * mirroring `resolve_effective_agent_env` Layer 1 in the backend readiness + * gate. Absent (or empty) on OSS builds — existing call sites are unaffected. */ + bakedEnvKeys?: readonly string[]; envVars: Record; isProviderMode: boolean; model: string; @@ -436,11 +465,19 @@ export function computeLocalModeGate({ : ""; const requiredKeys = requiredCredentialEnvKeys(runtimeId, providerForKeys); + // Keys satisfied by the baked build env (Block-internal builds only). + const bakedSatisfiedSet = new Set( + getBakedSatisfiedEnvKeys(requiredKeys, envVars, bakedEnvKeys), + ); + const missingEnvKeys: string[] = []; const fileSatisfiedEnvKeys: string[] = []; for (const key of requiredKeys) { if ((envVars[key] ?? "").length > 0) { // Set in Buzz env — satisfied, no action. + } else if (bakedSatisfiedSet.has(key)) { + // Not in Buzz env but covered by the baked build env — silenced. + // Don't add to fileSatisfiedEnvKeys; baked keys produce no info row. } else if (fileSatisfiedKeys.has(key)) { // Not in Buzz env but present in the runtime config file — silenced. fileSatisfiedEnvKeys.push(key); diff --git a/desktop/src/features/agents/ui/useRequiredCredentialState.ts b/desktop/src/features/agents/ui/useRequiredCredentialState.ts index 4cc064970e..380e025288 100644 --- a/desktop/src/features/agents/ui/useRequiredCredentialState.ts +++ b/desktop/src/features/agents/ui/useRequiredCredentialState.ts @@ -1,8 +1,12 @@ import * as React from "react"; -import { useRuntimeFileConfigQuery } from "@/features/agents/hooks"; +import { + useBakedBuildEnvKeysQuery, + useRuntimeFileConfigQuery, +} from "@/features/agents/hooks"; import { + getBakedSatisfiedEnvKeys, requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, } from "./personaDialogPickers"; @@ -61,30 +65,39 @@ export function useRequiredCredentialState(params: { { enabled: open }, ); + const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); + + // All required keys for this runtime + provider combination. + const allRequiredKeys = React.useMemo( + () => + requiredCredentialEnvKeys(prospectiveRuntimeId, providerForRequiredKeys), + [prospectiveRuntimeId, providerForRequiredKeys], + ); + + // Keys covered by the baked build env — silenced, produce no info row. + const bakedSatisfiedKeys = React.useMemo( + () => getBakedSatisfiedEnvKeys(allRequiredKeys, envVars, bakedEnvKeys), + [allRequiredKeys, envVars, bakedEnvKeys], + ); + const fileSatisfiedEnvKeys = React.useMemo(() => { if (!runtimeFileConfig) return [] as string[]; - return requiredCredentialEnvKeys( - prospectiveRuntimeId, - providerForRequiredKeys, - ).filter( + return allRequiredKeys.filter( (key) => (envVars[key] ?? "").length === 0 && + !bakedSatisfiedKeys.includes(key) && runtimeFileConfig.satisfiedEnvKeys.includes(key), ); - }, [ - runtimeFileConfig, - prospectiveRuntimeId, - providerForRequiredKeys, - envVars, - ]); + }, [runtimeFileConfig, allRequiredKeys, envVars, bakedSatisfiedKeys]); const requiredEnvKeys = React.useMemo( () => - requiredCredentialEnvKeys( - prospectiveRuntimeId, - providerForRequiredKeys, - ).filter((key) => !fileSatisfiedEnvKeys.includes(key)), - [prospectiveRuntimeId, providerForRequiredKeys, fileSatisfiedEnvKeys], + allRequiredKeys.filter( + (key) => + !bakedSatisfiedKeys.includes(key) && + !fileSatisfiedEnvKeys.includes(key), + ), + [allRequiredKeys, bakedSatisfiedKeys, fileSatisfiedEnvKeys], ); const requiredEnvKeyMissing = React.useMemo( diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c48d59cec5..eb6f6ee513 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1283,6 +1283,19 @@ export async function getRuntimeFileConfig( ); } +/** + * Return the key names of all non-empty baked build env vars. + * + * Internal (Block) builds bake provider credentials into the binary at compile + * time. This returns the *key names only* — never the values — so dialogs can + * treat them as satisfied without exposing secrets to the frontend. + * + * OSS builds return an empty array (no baked env). + */ +export async function getBakedBuildEnvKeys(): Promise { + return invokeTauri("get_baked_build_env_keys"); +} + type RawUpdateManagedAgentResponse = { agent: RawManagedAgent; profile_sync_error: string | null; From f13cca67d86e5e2edcf0836192ae29a7e967cd00 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 7 Jul 2026 12:15:50 -0400 Subject: [PATCH 2/3] fix(desktop): correct PersonaDialog requiredEnvKeys and gate auto-expand on baked-keys settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs introduced in the initial baked-env PR: 1. PersonaDialog.tsx used allowlist semantics for requiredEnvKeys (include if in missingEnvKeys OR fileSatisfiedEnvKeys), which caused file-satisfied keys to render twice in EnvVarsEditor — once as an amber locked row and once as an info row — and caused filled required keys to drop out of the amber locked row on first character typed (focus loss). Fixed to exclusion semantics matching the other consumers: all required keys except baked-satisfied and file-satisfied ones. 2. useRequiredCredentialState auto-expanded the Advanced section before the baked-keys query settled, causing a first-open flicker: bakedEnvKeys was undefined, baked-covered keys transiently appeared missing, Advanced expanded, then the requirement disappeared when the query resolved. Fixed by gating the auto-expand on isFetched from the query. 3. useBakedBuildEnvKeysQuery lacked retry: false, causing 3 silent retries in web/E2E contexts where the Tauri command doesn't exist. Also extends getBakedSatisfiedEnvKeys JSDoc with the UX asymmetry rationale and the #1448 precedence insertion point note, and adds the missing "Queued to split." trailer to the tauri.ts file-size override comment. --- desktop/scripts/check-file-sizes.mjs | 6 +++--- desktop/src/features/agents/hooks.ts | 1 + .../src/features/agents/ui/PersonaDialog.tsx | 20 +++++++++++++------ .../agents/ui/personaDialogPickers.tsx | 10 ++++++++++ .../agents/ui/useRequiredCredentialState.ts | 15 +++++++++++--- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6bedd646bf..5d192a1a00 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -116,7 +116,7 @@ const overrides = new Map([ // linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field. // config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines. // +26 lines from PRs landing on main between prior rebase and this rebase. - // baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. + // baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. Queued to split. ["src/shared/api/tauri.ts", 1388], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog @@ -125,8 +125,8 @@ const overrides = new Map([ // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds // ~16 lines. Queued to split. // baked-env-required-badge: useBakedBuildEnvKeysQuery + bakedEnvKeys wiring - // adds ~6 lines. Queued to split. - ["src/features/agents/ui/PersonaDialog.tsx", 1038], + // + correct exclusion-semantics for requiredEnvKeys adds ~14 lines. Queued to split. + ["src/features/agents/ui/PersonaDialog.tsx", 1046], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 44f40b66db..82ab6055ea 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -669,6 +669,7 @@ export function useBakedBuildEnvKeysQuery(options?: { enabled?: boolean }) { enabled: options?.enabled ?? true, staleTime: Infinity, refetchInterval: false, + retry: false, }); } diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index 250b64edd3..aa68bdef4d 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -43,6 +43,7 @@ import { CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, formatRuntimeOptionLabel, + getBakedSatisfiedEnvKeys, getDefaultLlmProviderLabel, getDefaultPersonaRuntime, getModelSelectValue, @@ -442,15 +443,22 @@ export function PersonaDialog({ runtimeFileConfig, useMesh: false, }); - // Required keys for EnvVarsEditor amber rows: only those still unresolved - // (not baked-satisfied and not file-satisfied). - const requiredEnvKeys = requiredCredentialEnvKeys( + // Required keys for EnvVarsEditor amber locked rows: all required keys except + // those silenced by baked env or file config. Filled keys stay in the amber + // row (exclusion semantics, not missing-only), matching the other consumers. + const allRequiredEnvKeys = requiredCredentialEnvKeys( runtime, trimmedProvider, - ).filter( + ); + const bakedSatisfiedPersonaKeys = getBakedSatisfiedEnvKeys( + allRequiredEnvKeys, + envVars, + bakedEnvKeys, + ); + const requiredEnvKeys = allRequiredEnvKeys.filter( (key) => - localModeGate.missingEnvKeys.includes(key) || - localModeGate.fileSatisfiedEnvKeys.includes(key), + !bakedSatisfiedPersonaKeys.includes(key) && + !localModeGate.fileSatisfiedEnvKeys.includes(key), ); // Provider required-ness is a static property of the runtime — it does not // change based on whether the field is currently filled. Using the dynamic diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index 9faa8e2818..c3b4cd07a8 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -363,6 +363,16 @@ export function getDefaultPersonaRuntime(runtimes: AcpRuntimeCatalogEntry[]) { * * OSS builds have an empty baked env, so this always returns `[]` there — * OSS behavior is unchanged. + * + * **UX asymmetry:** baked-satisfied keys are FULLY silenced — no amber Required + * row, no "Set in config" info row. This differs from file-satisfied keys, which + * render an info row ("Set in goose config"). Baked env is invisible + * infrastructure; surfacing it would be noise for users. + * + * **Future precedence insertion point:** PR #1448 (global agent variables) will + * slot in between baked and file satisfaction. Intended precedence when both + * land: baked < global < file for silencing; agent-local value always wins for + * display and spawn. */ export function getBakedSatisfiedEnvKeys( requiredKeys: readonly string[], diff --git a/desktop/src/features/agents/ui/useRequiredCredentialState.ts b/desktop/src/features/agents/ui/useRequiredCredentialState.ts index 380e025288..635217c1ad 100644 --- a/desktop/src/features/agents/ui/useRequiredCredentialState.ts +++ b/desktop/src/features/agents/ui/useRequiredCredentialState.ts @@ -65,7 +65,8 @@ export function useRequiredCredentialState(params: { { enabled: open }, ); - const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); + const { data: bakedEnvKeys, isFetched: bakedEnvKeysFetched } = + useBakedBuildEnvKeysQuery({ enabled: open }); // All required keys for this runtime + provider combination. const allRequiredKeys = React.useMemo( @@ -106,17 +107,25 @@ export function useRequiredCredentialState(params: { ); // Auto-expand Advanced on the missing→present-requirement transition only. + // Wait for the baked-keys query to settle before firing: on first dialog open + // the query is still in-flight so bakedEnvKeys is undefined, which transiently + // marks baked-covered keys as missing. An errored query still counts as settled + // (fail-closed for badge/save purposes, but no premature expand). const previousMissing = React.useRef(false); React.useEffect(() => { if (!open) { previousMissing.current = false; return; } - if (requiredEnvKeyMissing && !previousMissing.current) { + if ( + requiredEnvKeyMissing && + !previousMissing.current && + bakedEnvKeysFetched + ) { setShowAdvancedFields(true); } previousMissing.current = requiredEnvKeyMissing; - }, [open, requiredEnvKeyMissing, setShowAdvancedFields]); + }, [open, requiredEnvKeyMissing, bakedEnvKeysFetched, setShowAdvancedFields]); return { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing }; } From 8b14773cde9c18b799c9556d2f9df247de03898e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 7 Jul 2026 12:17:38 -0400 Subject: [PATCH 3/3] test(desktop): pin requiredEnvKeys exclusion semantics and save-block path for baked env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new pure-function tests: - requiredEnvKeys_exclusionSemantics_filledKeyStaysInAmberRow: regression guard for the allowlist bug — a filled required key must stay in the amber locked row list (all-minus-excluded, not missing-only). - requiredEnvKeys_exclusionSemantics_bakedKeyDropsFromAmberRow: a baked- satisfied key must be excluded from the amber row list. - saveBlock_bakedSatisfiedKey_notMissing: hasMissingRequiredEnvKey returns false after filtering out baked-satisfied keys, pinning the hook path in useRequiredCredentialState without React rendering machinery. - saveBlock_noFilterNoBaked_stillMissing: control case — same key is required and missing when baked env is empty (OSS build baseline). --- .../ui/createAgentLocalModeGate.test.mjs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs index 22db480d51..56235a7045 100644 --- a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -25,6 +25,7 @@ import { requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, } from "./personaDialogPickers.tsx"; +import { hasMissingRequiredEnvKey } from "./personaRuntimeModel.ts"; // ── Core predicate: provider-selection support ───────────────────────────── @@ -563,3 +564,75 @@ test("getBakedSatisfiedEnvKeys_emptyBaked_returnsEmpty", () => { const result = getBakedSatisfiedEnvKeys(["DATABRICKS_HOST"], {}, []); assert.deepEqual(result, []); }); + +// ── requiredEnvKeys exclusion semantics (PersonaDialog / useRequiredCredentialState) ── + +test("requiredEnvKeys_exclusionSemantics_filledKeyStaysInAmberRow", () => { + // A filled required key must stay in the amber locked row (exclusion semantics, + // not missing-only). Regression guard for the allowlist bug fixed in review. + // The gate returns missingEnvKeys (empty), not filledKeys — the amber row list + // is derived independently as allRequired minus baked/file-satisfied. + const allKeys = requiredCredentialEnvKeys("goose", "databricks_v2"); + const envVarsWithKey = { DATABRICKS_HOST: "https://filled.example.com/" }; + const bakedSatisfied = getBakedSatisfiedEnvKeys(allKeys, envVarsWithKey, []); + // No baked env, no file config: all keys must stay in the amber row list + // regardless of whether they are filled. + const requiredForEditor = allKeys.filter( + (key) => !bakedSatisfied.includes(key), + ); + assert.ok( + requiredForEditor.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must remain in the amber row list even when filled (exclusion semantics)", + ); +}); + +test("requiredEnvKeys_exclusionSemantics_bakedKeyDropsFromAmberRow", () => { + // A baked-satisfied key must be excluded from the amber row list. + const allKeys = requiredCredentialEnvKeys("goose", "databricks_v2"); + const bakedSatisfied = getBakedSatisfiedEnvKeys(allKeys, {}, [ + "DATABRICKS_HOST", + ]); + const requiredForEditor = allKeys.filter( + (key) => !bakedSatisfied.includes(key), + ); + assert.ok( + !requiredForEditor.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must be excluded from the amber row list when baked-satisfied", + ); +}); + +// ── Save-block path: hasMissingRequiredEnvKey with baked filter ────────────── + +test("saveBlock_bakedSatisfiedKey_notMissing", () => { + // The save-block gate (hasMissingRequiredEnvKey) must return false when the + // only unset required key is baked-satisfied. Pins the hook path exercised by + // useRequiredCredentialState without needing React rendering machinery. + const allKeys = requiredCredentialEnvKeys("goose", "databricks_v2"); + const bakedSatisfied = getBakedSatisfiedEnvKeys(allKeys, {}, [ + "DATABRICKS_HOST", + ]); + // requiredEnvKeys after filtering out baked-satisfied keys (mirrors + // useRequiredCredentialState's requiredEnvKeys memo). + const requiredAfterFilter = allKeys.filter( + (key) => !bakedSatisfied.includes(key), + ); + assert.equal( + hasMissingRequiredEnvKey(requiredAfterFilter, {}), + false, + "hasMissingRequiredEnvKey must be false when the only unset required key is baked-satisfied", + ); +}); + +test("saveBlock_noFilterNoBaked_stillMissing", () => { + // Control: without baked env the same key is still required and missing. + const allKeys = requiredCredentialEnvKeys("goose", "databricks_v2"); + const bakedSatisfied = getBakedSatisfiedEnvKeys(allKeys, {}, []); + const requiredAfterFilter = allKeys.filter( + (key) => !bakedSatisfied.includes(key), + ); + assert.equal( + hasMissingRequiredEnvKey(requiredAfterFilter, {}), + true, + "hasMissingRequiredEnvKey must be true when the required key is absent and not baked", + ); +});