Files
galaxy/specs/cloud-mode-auth-secrets/TECH.md
T

23 KiB

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.rsHarnessAvailabilityModel 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.rsHarnessSelector view (ActionButton + Menu<A>). 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<HarnessAuthSecretsConfig> 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.rsManagedSecretsClient 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.graphqlsharnessAuthSecrets query, ListHarnessAuthSecretsInput, HarnessAuthSecretsResult.
  • graphql/v2/mutations/create_managed_secret.graphqlscreateManagedSecret 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.

#[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:

async fn list_harness_auth_secrets(
    &self,
    harness: AgentHarness,
) -> Result<Vec<ManagedSecret>>;

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.

pub struct HarnessAvailabilityModel {
    harnesses: Vec<HarnessAvailability>,
    /// Lazily fetched when a non-Oz harness is selected.
    auth_secrets: HashMap<Harness, AuthSecretFetchState>,
}

pub enum AuthSecretFetchState {
    NotFetched,
    Loading,
    Loaded(Vec<AuthSecretEntry>),
    /// 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:

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.

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)

pub static HARNESS_AUTH_FTUX_COMPLETED: Setting<HashMap<String, bool>> = 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:

harness_auth_secret_name: Option<String>,

Update set_harness() to clear harness_auth_secret_name when harness changes.

Update build_default_spawn_config() to populate harness_auth_secrets:

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<AuthSecretSelectorAction> 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<AuthSecretSelectorAction> 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

#[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

pub struct AuthSecretSelector {
    button: ViewHandle<ActionButton>,
    menu: ViewHandle<Menu<AuthSecretSelectorAction>>,
    new_type_sidecar: ViewHandle<Menu<AuthSecretSelectorAction>>,
    is_menu_open: bool,
    is_new_type_sidecar_open: bool,
    menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
    ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
}

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.

pub struct AuthSecretFtuxView {
    ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
    /// 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<AuthSecretSelector>,
    /// Single-line editors, one per field of the currently-selected new type.
    /// Empty when no "New {type}" has been selected.
    field_editors: Vec<ViewHandle<EditorView>>,
    creation_state: Option<SecretCreationState>,
    /// 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

#[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

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.