diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 54e51ebbdb..5d192a1a00 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. Queued to split. + ["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 + // + 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-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..82ab6055ea 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,31 @@ 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, + retry: 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..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, @@ -69,7 +70,7 @@ import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; -import { useRuntimeFileConfigQuery } from "../hooks"; +import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks"; type PersonaDialogProps = { open: boolean; @@ -431,7 +432,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 +443,23 @@ 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. - 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((key) => !localModeGate.fileSatisfiedEnvKeys.includes(key)); + ); + const bakedSatisfiedPersonaKeys = getBakedSatisfiedEnvKeys( + allRequiredEnvKeys, + envVars, + bakedEnvKeys, + ); + const requiredEnvKeys = allRequiredEnvKeys.filter( + (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 // 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..56235a7045 100644 --- a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -21,9 +21,11 @@ import assert from "node:assert/strict"; import { computeLocalModeGate, + getBakedSatisfiedEnvKeys, requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, } from "./personaDialogPickers.tsx"; +import { hasMissingRequiredEnvKey } from "./personaRuntimeModel.ts"; // ── Core predicate: provider-selection support ───────────────────────────── @@ -410,3 +412,227 @@ 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, []); +}); + +// ── 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", + ); +}); diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index 574dabff79..c3b4cd07a8 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -353,6 +353,39 @@ 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. + * + * **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[], + 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 +401,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 +410,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 +475,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..635217c1ad 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,40 @@ export function useRequiredCredentialState(params: { { enabled: open }, ); + const { data: bakedEnvKeys, isFetched: bakedEnvKeysFetched } = + 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( @@ -93,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 }; } 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;