# Auth Secret Creation Flow — Tech Spec ## Problem The cloud mode input V2 composing UI lets users select a non-Oz harness (e.g. Claude Code), but has no way to associate an auth secret with that harness. The server already supports `harnessAuthSecrets` (query) and `createManagedSecret` (mutation), and `AgentConfigSnapshot` already has a `harness_auth_secrets` field — but the client never populates it. We need to add the UI for selecting/creating auth secrets and wire the selection into the spawn config. ## Relevant Code ### Client (warp-internal) - `app/src/ai/harness_availability.rs` — `HarnessAvailabilityModel` singleton. Fetches and caches harness availability from the server. **Will be extended** with per-harness auth secret state. - `app/src/terminal/view/ambient_agent/harness_selector.rs` — `HarnessSelector` view (`ActionButton` + `Menu`). The auth secret selector chip mirrors this pattern. - `app/src/terminal/view/ambient_agent/model.rs` (165-221) — `AmbientAgentViewModel`. Tracks `harness`, `worker_host`, `harness_model_id`. **Will add** `harness_auth_secret_name`. - `app/src/terminal/view/ambient_agent/model.rs` (841-861) — `build_default_spawn_config()`. Builds `AgentConfigSnapshot`. **Will add** `harness_auth_secrets` population. - `app/src/ai/ambient_agents/task.rs` (27-65) — `AgentConfigSnapshot` struct. Already has `harness_auth_secrets: Option` with `claude_auth_secret_name`. - `app/src/terminal/input/agent.rs` (507-525) — `render_cloud_mode_v2_content()`. Builds `top_row + input_container`. **Branch point** for FTUX vs normal rendering. - `app/src/terminal/input/agent.rs` (539-556) — `render_cloud_mode_v2_top_row()`. **Will add** `AuthSecretSelector` chip conditionally. - `app/src/server/server_api/managed_secrets.rs` — `ManagedSecretsClient` trait. Has `create_managed_secret` and `list_secrets`. **Will add** `list_harness_auth_secrets`. ### Server (warp-server) — read-only, no changes needed - `graphql/v2/queries/managed_secrets.graphqls` — `harnessAuthSecrets` query, `ListHarnessAuthSecretsInput`, `HarnessAuthSecretsResult`. - `graphql/v2/mutations/create_managed_secret.graphqls` — `createManagedSecret` mutation. - `model/types/enums/managed_secret_type.go` (46-52) — `authSecretTypesByHarness` map. Claude Code maps to 3 types. - `logic/managed_secrets/secret_type.go` — Secret value structs and their JSON field shapes. ### GraphQL client crate - `crates/warp_graphql` — cynic-based GraphQL query modules. **Will add** `list_harness_auth_secrets` query module. ## Current State - `HarnessAvailabilityModel` fetches harness metadata on auth/network/workspace changes and caches it. It has no awareness of auth secrets. - `AmbientAgentViewModel` tracks harness selection and builds the spawn config, but never sets `harness_auth_secrets`. - The cloud mode V2 composing UI renders a top row (host + harness selectors) and an input container below it. There is no auth secret UI. - The oz web app (`client/packages/agents/src/components/HarnessAuthSecretSelector`) already has a React component and `useHarnessAuthSecrets` hook that query the same server endpoint. ## Proposed Changes ### 1. GraphQL query: `harnessAuthSecrets` **New file**: `crates/warp_graphql/src/queries/list_harness_auth_secrets.rs` Add a cynic query module for the server's `harnessAuthSecrets` query. The input takes an `AgentHarness` enum; the output reuses the existing `ManagedSecret` fragment. ```rust #[derive(cynic::QueryVariables)] pub struct ListHarnessAuthSecretsVariables { pub request_context: RequestContext, pub input: ListHarnessAuthSecretsInput, } #[derive(cynic::InputObject)] pub struct ListHarnessAuthSecretsInput { pub harness: AgentHarness, } #[derive(cynic::QueryFragment)] #[cynic(graphql_type = "RootQuery", variables = "ListHarnessAuthSecretsVariables")] pub struct ListHarnessAuthSecrets { #[arguments(requestContext: $request_context, input: $input)] pub harness_auth_secrets: HarnessAuthSecretsResult, } ``` Extend `ManagedSecretsClient` in `app/src/server/server_api/managed_secrets.rs`: ```rust async fn list_harness_auth_secrets( &self, harness: AgentHarness, ) -> Result>; ``` ### 2. Extend `HarnessAvailabilityModel` with auth secrets **File**: `app/src/ai/harness_availability.rs` Add a lazily-fetched per-harness auth secret map to the existing singleton, keeping all harness-related data in one place. ```rust pub struct HarnessAvailabilityModel { harnesses: Vec, /// Lazily fetched when a non-Oz harness is selected. auth_secrets: HashMap, } pub enum AuthSecretFetchState { NotFetched, Loading, Loaded(Vec), /// Surfaced as "Unable to load secrets" in the dropdown; subsequent /// `ensure_auth_secrets_fetched` retries. Failed(String), } pub struct AuthSecretEntry { pub name: String, } ``` New events added to `HarnessAvailabilityEvent`: ```rust pub enum HarnessAvailabilityEvent { Changed, AuthSecretsLoaded { harness: Harness }, AuthSecretCreated { harness: Harness, name: String }, AuthSecretCreationFailed { error: String }, } ``` Key new methods: - `auth_secrets_for(&self, harness) -> &AuthSecretFetchState` — returns current state for the harness. - `ensure_auth_secrets_fetched(&mut self, harness, ctx)` — if `NotFetched` or `Failed`, fires async `list_harness_auth_secrets` and transitions to `Loading`. On success, stores `Loaded(entries)` and emits `AuthSecretsLoaded`. - `create_auth_secret(&mut self, harness, name, value, ctx)` where `value: ManagedSecretValue` — calls `ManagedSecretManager::create_secret(SecretOwner::CurrentUser, name, value, None)`. On success, appends a new `AuthSecretEntry` to the cached `Loaded` list (or transitions `NotFetched`/`Failed` directly to `Loaded(vec![entry])`) and emits `AuthSecretCreated { harness, name }`. On error, emits `AuthSecretCreationFailed { error }` and leaves the cache untouched. - `invalidate_auth_secrets(&mut self, harness)` — resets to `NotFetched`. Called when the user signs out (subscription on `AuthManager`'s `AuthComplete` already exists; we extend it to clear the per-harness cache). ### 3. Auth secret type metadata **New file**: `app/src/ai/auth_secret_types.rs` Client-side mirror of the server's `authSecretTypesByHarness`. Maps each harness to its secret types and their input field schemas. ```rust pub struct AuthSecretTypeField { pub label: &'static str, pub json_key: &'static str, pub optional: bool, } pub struct AuthSecretTypeInfo { pub display_name: &'static str, pub secret_type: &'static str, pub fields: &'static [AuthSecretTypeField], } pub fn auth_secret_types_for_harness(harness: Harness) -> &'static [AuthSecretTypeInfo] { match harness { Harness::Claude => &[ AuthSecretTypeInfo { display_name: "Anthropic API Key", secret_type: "anthropic_api_key", fields: &[ AuthSecretTypeField { label: "ANTHROPIC_API_KEY", json_key: "api_key", optional: false }, ], }, AuthSecretTypeInfo { display_name: "Bedrock API Key", secret_type: "anthropic_bedrock_api_key", fields: &[ AuthSecretTypeField { label: "AWS_BEARER_TOKEN_BEDROCK", json_key: "aws_bearer_token_bedrock", optional: false }, AuthSecretTypeField { label: "AWS_REGION", json_key: "aws_region", optional: false }, ], }, AuthSecretTypeInfo { display_name: "Bedrock Access Key", secret_type: "anthropic_bedrock_access_key", fields: &[ AuthSecretTypeField { label: "AWS_ACCESS_KEY_ID", json_key: "aws_access_key_id", optional: false }, AuthSecretTypeField { label: "AWS_SECRET_ACCESS_KEY", json_key: "aws_secret_access_key", optional: false }, AuthSecretTypeField { label: "AWS_SESSION_TOKEN", json_key: "aws_session_token", optional: true }, AuthSecretTypeField { label: "AWS_REGION", json_key: "aws_region", optional: false }, ], }, ], _ => &[], } } ``` ### 4. Per-harness FTUX setting **File**: `app/src/ai/settings.rs` (or equivalent) ```rust pub static HARNESS_AUTH_FTUX_COMPLETED: Setting> = Setting::new( "harness_auth_ftux_completed", HashMap::new(), ); pub fn is_harness_auth_ftux_completed(harness: Harness) -> bool { ... } pub fn mark_harness_auth_ftux_completed(harness: Harness) { ... } ``` ### 5. `AmbientAgentViewModel` changes **File**: `app/src/terminal/view/ambient_agent/model.rs` Add field: ```rust harness_auth_secret_name: Option, ``` Update `set_harness()` to clear `harness_auth_secret_name` when harness changes. Update `build_default_spawn_config()` to populate `harness_auth_secrets`: ```rust let harness_auth_secrets = self.harness_auth_secret_name.as_ref().map(|name| { HarnessAuthSecretsConfig { claude_auth_secret_name: Some(name.clone()), } }); ``` Add getter/setter and new event `AuthSecretSelected`. ### 6. Auth Secret Selector — single shared dropdown with sidecar submenu **File**: `app/src/terminal/view/ambient_agent/auth_secret_selector.rs` (already exists, will be extended). The selector is the single source of truth for the dropdown UX. It is rendered both as the top-row chip (returning users) and as the FTUX dropdown (FTUX flow), so the menu items are defined in exactly one place. #### Dropdown structure The `Menu` shows: 1. A non-clickable header (`MenuItem::Header`) labeled "Auth secret". 2. The list of existing secrets for the harness, each as a `MenuItem::Item` with `SelectSecret(name)` as its on-select action. When the fetch state is `Loaded` and empty, this section is omitted (per the design decision that the empty-state dropdown shows only the "New" parent). 3. A `MenuItem::Item` labeled "New" with a `+` icon and a chevron-right indicator on the right side, with `OpenNewTypeSidecar` as its on-select action. Hovering this item opens the sidecar. 4. While `Loading`, replace the secrets list with a single disabled "Loading…" item; while `Failed`/`NotFetched`, show a disabled "Unable to load secrets" item but **still show the "New" item** so the user can always create a secret even when listing fails. #### Sidecar pattern (for "New" expansion) Following the precedent set by `app/src/terminal/profile_model_selector.rs` (`ModelSpecSidecar`) and the new-session menu in `workspace/view.rs` (`update_new_session_sidecar` / `configure_worktree_new_session_sidecar`), the "New" item is *not* a `MenuItem::Submenu` (which is deprecated and unused). Instead: - The selector owns a second `Menu` view (`new_type_sidecar`). - Hovering the "New" item triggers `OpenNewTypeSidecar`, which sets `is_new_type_sidecar_open = true` on the selector and calls `set_safe_zone_target` + `set_submenu_being_shown_for_item_index` on the main menu (mirroring the worktree-config sidecar in `view.rs:8800-8814`). - The render method adds the sidecar as a positioned overlay child next to the main menu, using `OffsetPositioning::offset_from_save_position_element` with the main menu's save-position id (same approach as `profile_model_selector.rs:1972-1984`). When it would overflow the right window edge, it renders to the left. - The sidecar menu items are built from `auth_secret_types_for_harness(harness)`: one `MenuItem::Item` per `AuthSecretTypeInfo`, with `SelectNewType(type_index)` as the on-select action and an icon (`Icon::Key`) on the left. - Selecting a type fires `SelectNewType(type_index)`, which: - Closes the sidecar and the main menu. - Emits `AuthSecretSelectorEvent::NewTypeSelected { harness, type_index }` so the FTUX view can swap into creation mode. #### Updated action and event enums ```rust #[derive(Clone, Debug, PartialEq)] pub enum AuthSecretSelectorAction { ToggleMenu, SelectSecret(String), /// Hovering the "New" parent item opens the sidecar. OpenNewTypeSidecar, /// User picked one of the new-secret types from the sidecar. SelectNewType(usize), } pub enum AuthSecretSelectorEvent { MenuVisibilityChanged { open: bool }, /// Emitted when the user picks "New {type}" from the sidecar. NewTypeSelected { harness: Harness, type_index: usize, }, } ``` #### Selector struct ```rust pub struct AuthSecretSelector { button: ViewHandle, menu: ViewHandle>, new_type_sidecar: ViewHandle>, is_menu_open: bool, is_new_type_sidecar_open: bool, menu_positioning_provider: Arc, ambient_agent_model: ModelHandle, } ``` Subscribes to `HarnessAvailabilityModel` for `AuthSecretsLoaded` / `AuthSecretCreated` (refresh both menus) and to `AmbientAgentViewModel` for `HarnessSelected` (rebuild menus when the harness changes). ### 7. Auth Secret FTUX View **New file**: `app/src/terminal/view/ambient_agent/auth_secret_ftux_view.rs` A `TypedActionView` that owns the FTUX content. The FTUX view *embeds* the existing `AuthSecretSelector` rather than building its own dropdown — this is the single-source-of-truth requirement. ```rust pub struct AuthSecretFtuxView { ambient_agent_model: ModelHandle, /// Shared dropdown view; same one rendered as the top-row chip when FTUX is /// already completed. The FTUX view subscribes to its events. auth_secret_selector: ViewHandle, /// Single-line editors, one per field of the currently-selected new type. /// Empty when no "New {type}" has been selected. field_editors: Vec>, creation_state: Option, /// Whether to show the "Click here to skip" link. Always true for the /// initial FTUX entry; false when the FTUX is re-entered from the chip's /// "New {type}" path (since the user has already completed FTUX once). show_skip_link: bool, } pub struct SecretCreationState { pub harness: Harness, pub secret_type_index: usize, /// Per-field name for the secret being created. Server requires this. pub secret_name: String, pub is_saving: bool, } ``` #### Field input model For each `AuthSecretTypeField` of the selected type, the FTUX view constructs a single-line `EditorView` (using `EditorView::single_line` with `SingleLineEditorOptions`, mirroring `model_selector.rs`'s search input). The editor's buffer text is the field value; the FTUX view reads them on Continue without needing per-keystroke `UpdateField` actions. The view also constructs a name-of-secret editor (the user must name the saved secret); this is a required field that the server uses as the unique key. #### Actions ```rust #[derive(Clone, Debug, PartialEq)] pub enum AuthSecretFtuxAction { Cancel, Continue, Skip, } ``` No per-field `UpdateField` action is needed — the editors hold their own state and `Continue` reads them. #### Behavior - On construction: calls `HarnessAvailabilityModel::ensure_auth_secrets_fetched(harness, ctx)`. - Subscribes to the embedded `auth_secret_selector` for `NewTypeSelected { harness, type_index }`. On that event, populates `creation_state` and rebuilds `field_editors` to match `auth_secret_types_for_harness(harness)[type_index].fields`. - Subscribes to `HarnessAvailabilityModel` for `AuthSecretCreated` (transition out of saving state, set the secret as selected on `AmbientAgentViewModel`, mark FTUX completed) and `AuthSecretCreationFailed` (transition out of saving, surface error toast via `Input`'s existing toast subscription). - Continue button: - If a secret is already selected on the view model (from clicking an existing item in the dropdown), mark FTUX completed and `ctx.notify()`. - If `creation_state` is `Some`, validate that all non-optional fields are filled, build the matching `ManagedSecretValue` (e.g. `anthropic_api_key`, `anthropic_bedrock_api_key`, `anthropic_bedrock_access_key`), set `is_saving = true`, and call `HarnessAvailabilityModel::create_auth_secret(harness, name, value, ctx)`. Disable the button while `is_saving`. - Cancel button: switches harness back to Oz via `AmbientAgentViewModel::set_harness(Harness::Oz, ctx)`. (Same as the existing `CancelAuthSecretFtux` action.) - Skip button: marks FTUX completed via `CloudAgentSettings::mark_harness_auth_ftux_completed(harness, ctx)`. (Same as the existing `SkipAuthSecretFtux` action.) #### Wiring in `Input` The FTUX view is constructed in `Input::new` alongside the existing `AuthSecretSelector` and stored on `AmbientAgentViewState`. The `agent.rs` rendering of `render_auth_secret_ftux_content` is replaced by `ChildView::new(&self.auth_secret_ftux_view)`. The inline `SkipAuthSecretFtux` / `CancelAuthSecretFtux` `InputAction` variants and their handlers in `input.rs` are removed in favor of the FTUX view dispatching `AuthSecretFtuxAction` directly to itself. ### 8. Rendering tree integration **File**: `app/src/terminal/input/agent.rs` The branch point is `render_cloud_mode_v2_content` (line 507-525). Currently: ``` render_cloud_mode_v2_content() └── Align (centered) └── ConstrainedBox (max-width: 720px) └── Flex::column (gap: 10px) ├── render_cloud_mode_v2_top_row() ← ALWAYS rendered └── render_cloud_mode_v2_input_container() ← normal input ``` With FTUX active (non-Oz harness + FTUX not completed): ``` render_cloud_mode_v2_content() └── Align (centered) └── ConstrainedBox (max-width: 720px) └── Flex::column (gap: 10px) ├── render_cloud_mode_v2_top_row() ← SAME └── ChildView::new(&auth_secret_ftux_view) ← replaces input container ``` With FTUX completed (returning user): ``` render_cloud_mode_v2_content() └── Align (centered) └── ConstrainedBox (max-width: 720px) └── Flex::column (gap: 10px) ├── render_cloud_mode_v2_top_row() │ └── HostSelector | HarnessSelector | AuthSecretSelector | ... └── render_cloud_mode_v2_input_container() ← normal input ``` The top row always renders. The second child swaps based on `should_show_auth_secret_ftux()`. ### 9. Toast notification Subscribe to `HarnessAvailabilityEvent::AuthSecretCreated` in `Input` and show an ephemeral `DismissibleToast` via `ToastStack`: "API key saved." with a "Manage secrets" action button. ## End-to-End Flow ```mermaid sequenceDiagram participant User participant HarnessSelector participant AmbientAgentVM as AmbientAgentViewModel participant HarnessAvail as HarnessAvailabilityModel participant FtuxView as AuthSecretFtuxView participant Server as warp-server User->>HarnessSelector: Select "Claude Code" HarnessSelector->>AmbientAgentVM: set_harness(Claude) Note over AmbientAgentVM: Emits HarnessSelected Note over FtuxView: render_cloud_mode_v2_content checks FTUX setting alt FTUX not completed FtuxView->>HarnessAvail: ensure_auth_secrets_fetched(Claude) HarnessAvail->>Server: harnessAuthSecrets(CLAUDE_CODE) Server-->>HarnessAvail: [secret1, secret2, ...] HarnessAvail-->>FtuxView: AuthSecretsLoaded FtuxView->>FtuxView: Populate dropdown alt User selects existing secret User->>FtuxView: Select "secret1" User->>FtuxView: Click Continue FtuxView->>AmbientAgentVM: set_harness_auth_secret_name("secret1") FtuxView->>FtuxView: mark_harness_auth_ftux_completed(Claude) Note over FtuxView: Re-render → normal input with chip else User creates new secret User->>FtuxView: Select "New Anthropic API Key" FtuxView->>FtuxView: Show input field(s) User->>FtuxView: Fill ANTHROPIC_API_KEY, click Continue FtuxView->>HarnessAvail: create_auth_secret(...) HarnessAvail->>Server: createManagedSecret(...) Server-->>HarnessAvail: OK HarnessAvail-->>FtuxView: AuthSecretCreated Note over FtuxView: Toast + transition to normal input end else FTUX completed Note over FtuxView: Render normal input + AuthSecretSelector chip end User->>User: Types prompt, submits AmbientAgentVM->>Server: spawn_agent (config includes harness_auth_secrets) ``` ## Risks and Mitigations - **Shared singleton mutation**: `HarnessAvailabilityModel` is a singleton; adding mutable auth secret state must not interfere with the existing harness availability data. Mitigated by using a separate `HashMap` field that is never touched by the existing `refresh()` path. - **Lazy fetch timing**: The FTUX view calls `ensure_auth_secrets_fetched` on construction. If the fetch is slow, the dropdown shows a loading state. The user cannot hit Continue until secrets are loaded or they enter a raw value. - **Secret encryption**: Creating a secret requires client-side encryption using the user's upload key. The existing `ManagedSecretsClient::create_managed_secret` already handles this — we reuse that path. - **Feature flag coupling**: This feature requires `CloudModeInputV2` and `CloudMode` and `AgentHarness` flags to all be enabled. If any is off, none of the new code runs. - **Stale cache**: After creating a secret, we append to the cached list optimistically. If the server rejects the creation, we show an error toast and don't modify the cache. ## Testing and Validation - **Unit tests**: `auth_secret_types_for_harness` returns correct field schemas for Claude and empty for Oz/Unknown. FTUX setting helpers round-trip correctly. - **Compile check**: `cargo check` with and without the `cloud_mode_input_v2` feature flag. - **Manual verification**: 1. Select Claude Code → FTUX appears, top row persists, dropdown populates from server. 2. Select existing secret → Continue → normal input with chip showing selected secret. 3. Select "New Anthropic API Key" → 1 field appears → fill → Continue → toast + chip updates. 4. Select "New Bedrock Access Key" → 4 fields appear (1 optional) → fill required → Continue. 5. Cancel → returns to Oz. Skip → proceeds with no secret. 6. Restart app → re-select Claude Code → FTUX skipped, chip shows previous secret. 7. Submit prompt → verify `harness_auth_secrets.claude_auth_secret_name` is set in the spawn request. ## Follow-ups - Extend `auth_secret_types_for_harness` when new harnesses (e.g. Gemini, Codex) are added. - Support raw-value passthrough (type the API key directly without creating a named secret). - Add the "Manage secrets" toast button action to open the Warp Drive secrets pane. - Consider pre-selecting the most recently used secret when returning to the FTUX or chip.