Skip to content

[multi-team PR 3B] Key GEAP credential state by team in ApiKeyManager - #15369

Draft
warp-agent-staging[bot] wants to merge 4 commits into
masterfrom
factory/multi-team-pr3b-apikey-geap
Draft

[multi-team PR 3B] Key GEAP credential state by team in ApiKeyManager#15369
warp-agent-staging[bot] wants to merge 4 commits into
masterfrom
factory/multi-team-pr3b-apikey-geap

Conversation

@warp-agent-staging

@warp-agent-staging warp-agent-staging Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Depends on PR 2B slice 1 (#15355, open). That PR independently adds the same ResponseStream.team_uid: Option<ServerId> field, threaded through the same newretryspawn_request path, continuing into spawn_generate/generate_multi_agent_output to attach X-Warp-Team-Uid. This PR stops at spawn_request, consuming the value for GEAP instead. Whichever PR merges second will hit an expected, mechanical conflict on that shared field/threading — keep one copy and both consumers. This PR also replaces #15355's placeholder None at the ResponseStream::new(...) call site in controller.rs with a real, resolved team, which is a value-add for #15355 too (see "Two-for-one" below).

Also independently duplicated in PR 1A (#15350). 1A adds the exact same window_id: WindowId field on BlocklistAIController and the exact same team_uid(ctx) -> Option<ServerId> accessor (UserWorkspaces::team_uid_for_window(self.window_id)), used at the same two RequestParams::new() call sites — to apply apply_team_byo_policy where this PR attaches GEAP credentials. Compared both implementations directly: they delegate to the identical shared UserWorkspaces::team_uid_for_window, so the edge cases agree by construction, not by coincidence — a window with no team returns None in both, and a window whose team was removed from the workspace is reconciled to a fallback (or None) by UserWorkspaces::reconcile_window_team_assignments, which both call sites read through. Whichever of 1A/3B merges second drops its duplicate window_id field/team_uid accessor and keeps the other consumer. This is the fourth independent rebuild of the same request-scope seam across tonight's multi-team PRs (the header constant across 2A/2B/2C, ResponseStream.team_uid across 3B/2B, and now this) — signal that per-window team resolution wants to be a first-class, shared primitive on BlocklistAIController rather than re-derived per consumer.

Design decision: keyed by team, inside ApiKeyManager

GEAP credential state is a single-flighted, proactively-refreshed cache — a ~hourly, 3-leg OIDC→STS→IAM mint, coalesced so concurrent callers don't stampede it. That rules out request-local state (every GEAP request would have to mint synchronously with no coalescing) and view-owned state (two panes on the same team would each run their own mint and their own hourly refresh timer, duplicating work for a value that is legitimately shared). So this PR replaces the single, process-wide geap_credentials_state / geap_refresh_waiters / geap_last_mint_failure fields on ApiKeyManager with a HashMap<team_uid, GeapTeamState>, and adds team_uid to GeapMintBinding so mint identity and map key agree. ApiKeyManager's other state (pasted BYO keys, Grok tokens, AWS credentials) stays global/unkeyed, since those are genuinely user-level, not team-level — the spec's own distinction, not a shortcut.

A real bug this surfaces: current_geap_policy() read UserWorkspaces::current_workspace() — a single ambient value — instead of the operation's actual team's own Team.settings.llm_settings, which already exists server-side per team. Fixed as part of "policy... keyed by team."

Implementation

  • crates/ai (api_keys.rs, geap_credentials.rs): ApiKeyManager now holds geap_team_states: HashMap<String, GeapTeamState>. Every GEAP method takes an explicit team key — credentials, refresh waiters, and mint-failure cooldowns are all keyed by team, and attachment additionally requires the whole (user, team, audience, federation) binding to match. Added GeapCredentialsState::Indeterminate for "more than one team has state and this API can't say which" — kept distinct from Missing ("no credentials at all"), since conflating the two would send someone off to re-provision credentials they already have.
  • app/src/ai/geap_credentials.rs: current_geap_policy_for_team(app, team) replaces the ambient current_geap_policy(app) — no GEAP policy path reads current_workspace() any more. All mint/refresh/completion functions take an explicit team_uid: ServerId, captured once per operation and threaded through every async continuation. The reactive triggers (TeamsChanged/UpdateWorkspaceSettingsSuccess/GeminiEnterpriseCredentialsEnabled) refresh every team currently selected by an open window independently (UserWorkspaces::team_uids_in_use()), so a policy change to team B can never enable, disable, or overwrite team A's state.
  • RequestParams stays free of team scope entirely. RequestParams::new() (agent/api.rs) has its original signature back — no team parameter. RequestParams is cloned for retries, so a capability embedded there could travel to a retry that never established it; that's true regardless of whether the literal TeamContext type is involved. The caller (BlocklistAIController, which already resolves its own window's team) attaches the GEAP credential after RequestParams::new() returns, via a new geap_credentials::attach_geap_credentials_if_available(&mut params, team_uid, app) — the one piece of team-scoped data that legitimately belongs in the payload (the already-minted credential value, not the scope capability itself).
  • Real per-window team scope through the request pipeline. BlocklistAIController carries its terminal surface's WindowId (passed in from TerminalView::new) and resolves the window's current team once per request via team_uid_for_window — never cached. That resolved value flows to attach_geap_credentials_if_available (initial credential) and into ResponseStream.team_uid, which carries it through retries so a later window switch cannot retarget an in-flight request.
  • Two call sites that already had a real window resolved it the same way: AIBlock's GEAP inline-error view (team_for_view) and its "Refresh" action.

Why BlocklistAIController may resolve team scope, and why it re-resolves every request

This needed reconciling with #15355, which deliberately left this exact resolution as a TODO, since "the controller must not store ambient TeamContext." What BlocklistAIController stores is a WindowId, not a TeamContext — every call to team_uid() asks the window which team it's on right now, the same never-cache pattern PR 3E used for its indexing gate. Nothing here is captured once and reused.

That has a real consequence, and it's deliberate rather than a limitation: if a user corrects which team a window is on mid-conversation, the next turn in that same conversation attributes to the newly selected team, not the one the conversation started under. A team-of-record captured once at conversation start would itself go stale the moment the user makes that correction — re-asking the window each turn is the more correct behavior for a per-request billing/credential header, not merely the more convenient one. If usage/billing ever needs a single team-of-record per conversation for invoicing, that reconciliation belongs server-side, not as an early, silently-stale lock on the client.

Two-for-one with #15355

#15355's controller.rs hunk passes None for team_uid at its ResponseStream::new(...) call site, with a TODO saying real capture is deferred. This PR's BlocklistAIController.team_uid() fills that in, so once both land, #15355's X-Warp-Team-Uid header actually starts firing on /ai/multi-agent for windows with a selected team, instead of shipping permanently inert.

What happens for the two team-scope shapes on the request path, now that the header can fire:

  • Window has a selected team → team_uid() returns Some, the header carries the real team, and GEAP attaches/refreshes normally for that team.
  • Window has no team (teamless workspace, or a personal/solo account) → team_uid() returns None, exactly as before: no header, no GEAP binding. This is the common, correct "no team" case, not a degraded one.
  • The residual multi-team-without-a-resolvable-window cases — the settings-page compatibility shims — are edge cases, not the main path: geap_credentials_state()'s Indeterminate and force_refresh_geap_credentials()'s no-op only apply to the two call sites in warp_agent_page.rs I can't touch (Group 1's territory), which never had a window to resolve from in the first place.

Server-side header handling today (confirmed, not a hypothesis): warp-server has no reference to X-Warp-Team-Uid or a TEAM_UID_HEADER constant anywhere. generateMultiAgentOutput's handler resolves team from middleware.GetTeam(gc), not from a header, and there is no header-allowlist/signature middleware on that route that would reject an unrecognized header. So today, the header is inert (ignored) on that route, not rejected, until warp-server#15687 adds a reader for it. Note that this branch by itself never emits the header — it's referenced only in this PR body — so nothing changes live from 3B alone; #15355 remains the integration dependency that makes it real.

Fix: window_id going stale on cross-window tab drag

Found in review (credit: PR 1A's investigation): BlocklistAIController.window_id — the field team_uid() resolves from — was set once at construction and never updated. TerminalView, which owns the controller, had no on_window_transferred override, so dragging a terminal into another window left the controller resolving the source window's team indefinitely: GEAP credentials would mint/attach for the wrong team after a drag, and the X-Warp-Team-Uid header (once #15355 lands) would carry the wrong team too. team_uid()'s resolution logic itself was correct; the field it read from was not.

Fixed by adding BlocklistAIController::set_window_id and a TerminalView::on_window_transferred override that propagates the new window id to both self.window_id and the controller. Added a regression test (window_transfer_updates_resolved_team_scope) that performs a real AppContext::transfer_view_to_window between two windows on different teams and asserts the controller's resolved team follows the window; verified it fails without the fix (team stays pinned to the source window) and passes with it.

Deviations from the spec

  • HarnessAvailabilityModel (PR 2C's finding) is not mine. I looked for GEAP/ApiKeyManager coupling and found none — it's a separate model with its own keying problem. Flagging it explicitly so it doesn't fall between 3A/3B/2C; it needs an owner.
  • The Warp Agent settings page (app/src/settings_view/warp_agent_page.rs) is untouched, per the brief — that's Group 1's territory. Its two call sites keep their original signatures as narrow compatibility shims:
    • geap_credentials_state() reports a specific team's state only while at most one team has ever requested a mint; once a second team does, it reports Indeterminate (never Missing, and confirmed to never guess between two real teams' states). Whoever migrates that page to a team-scoped read should switch it to geap_credentials_state_for_team.
    • force_refresh_geap_credentials(manager, ctx) refreshes the account's sole team and is a no-op for a multi-team account, rather than guessing which team's credentials to force-refresh.

Validation

  • ./script/format, cargo check -p warp --lib, cargo check -p ai --lib, git diff --check: clean.
  • cargo clippy -p warp --all-targets --tests -- -D warnings and cargo clippy -p ai --all-targets --tests -- -D warnings: clean.
  • Could not run wasm clippy (cargo clippy -p ai --target wasm32-unknown-unknown) — the sandbox has no permission to install the wasm32-unknown-unknown rustup target. Manually verified the changed wasm-affected symbols are consumed unconditionally, but this is unverified by the actual wasm toolchain.
  • cargo test -p ai --lib geap (33 tests) and cargo test -p warp --lib -- user_workspaces blocklist::controller ai::geap ai::blocklist::block (204 tests): all pass, including four tests proving cross-team isolation at the ApiKeyManager level — credential leakage, mint-failure cooldown scoping, the Indeterminate zero-arg shim, and that begin_expired_geap_refresh starts independent mints for two different teams rather than one coalescing behind the other's waiter list — plus window_transfer_updates_resolved_team_scope, proving the controller's resolved team follows a real cross-window transfer instead of staying pinned to the source window — plus the existing PR 0 foundation tests, unaffected.
  • "Test two windows on different teams concurrently": verified at the ApiKeyManager unit level rather than via a GUI integration test — this PR is scoped to the model/state layer, and the existing GEAP test suite is itself a single-window App::test harness with no multi-window GUI test precedent to extend.

Diff size: within the ~1000-line budget.

CHANGELOG-NONE

@cla-bot

cla-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

@warp-agent-staging

Copy link
Copy Markdown
Contributor Author

This PR was generated with Warp.

Comment @warp-factory on this PR to send it follow-up work.

View run View conversation

@cla-bot

cla-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

@warp-agent-staging
warp-agent-staging Bot force-pushed the factory/multi-team-pr3b-apikey-geap branch from a54d539 to 9b2e5e2 Compare August 20, 2026 18:49
@cla-bot

cla-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

warp-agent-staging Bot added a commit that referenced this pull request Aug 20, 2026
Request-time BYO enforcement previously ran ambiently inside
RequestParams::new(), which cannot resolve a per-window team (it is
cloned and has no window). This mirrors PR 3B's design for GEAP
credentials: BlocklistAIController stores its window_id and exposes
team_uid(ctx), resolved fresh from the window on each call.
RequestParams::new() keeps entitlement-only gating; a new
apply_team_byo_policy(team_uid, app) method is called by the
controller immediately after RequestParams::new() at both call sites
(send_request_input and build_passive_suggestions_request_params),
stripping member-provided credentials the window's current team
policy disallows while preserving AWS Bedrock/GEAP credentials.

UserWorkspaces gains are_member_byo_keys_allowed_for_team/
are_member_byo_endpoints_allowed_for_team (by-uid), with the
context-based agent_settings_* accessors now delegating to them.

This overlaps with PR 3B's (#15369, unmerged) window_id/team_uid
addition to BlocklistAIController for GEAP credentials; that PR is not
cherry-picked here to avoid depending on unmerged PR 2B, but the two
additions are expected to converge once 3B merges.

Adds an acceptance test (apply_team_byo_policy_gates_member_credentials_by_team_policy)
covering two teams with identical workspace entitlement but opposing
team_byo policy.
warp-agent-staging Bot added a commit that referenced this pull request Aug 20, 2026
Request-time BYO enforcement previously ran ambiently inside
RequestParams::new(), which cannot resolve a per-window team (it is
cloned and has no window). This mirrors PR 3B's design for GEAP
credentials: BlocklistAIController stores its window_id and exposes
team_uid(ctx), resolved fresh from the window on each call.
RequestParams::new() keeps entitlement-only gating; a new
apply_team_byo_policy(team_uid, app) method is called by the
controller immediately after RequestParams::new() at both call sites
(send_request_input and build_passive_suggestions_request_params),
stripping member-provided credentials the window's current team
policy disallows while preserving AWS Bedrock/GEAP credentials.

UserWorkspaces gains are_member_byo_keys_allowed_for_team/
are_member_byo_endpoints_allowed_for_team (by-uid), with the
context-based agent_settings_* accessors now delegating to them.

This overlaps with PR 3B's (#15369, unmerged) window_id/team_uid
addition to BlocklistAIController for GEAP credentials; that PR is not
cherry-picked here to avoid depending on unmerged PR 2B, but the two
additions are expected to converge once 3B merges.

Adds an acceptance test (apply_team_byo_policy_gates_member_credentials_by_team_policy)
covering two teams with identical workspace entitlement but opposing
team_byo policy.
Oz and others added 4 commits August 20, 2026 23:49
Replaces ApiKeyManager's single, process-wide GEAP credential state
(policy, mint/refresh coordination, failure cooldown) with state keyed
by team, and threads real per-window team scope through the AI request
pipeline (controller -> RequestParams -> ResponseStream) so a mint,
retry, or completion never re-resolves "current team" from ambient
window state.

See the PR description for the full design rationale and deviations.
…Params

RequestParams::new() reverts to its original signature; the caller
(BlocklistAIController, which already resolves the window's team)
attaches the GEAP credential separately after construction via a new
attach_geap_credentials_if_available helper. ResponseStream.team_uid
now supplies a real value (via BlocklistAIController's window
resolution) instead of #15355's placeholder None, so its
X-Warp-Team-Uid header threading will actually fire once both land.
Proves begin_expired_geap_refresh starts independent mints for two
different teams rather than coalescing one behind the other's waiter
list. Also removes a process/merge-history comment from ResponseStream
that named PR #15355 and described how to resolve a future conflict;
that belongs in the PR description, not production source.
TerminalView owned an ai_controller whose window_id was set once at
construction and never updated. TerminalView had no
on_window_transferred override, so dragging a terminal into another
window left the controller resolving the source window's team
indefinitely -- GEAP credentials would mint/attach for the wrong team,
and the X-Warp-Team-Uid header (once #15355 lands) would carry the
wrong team too.

Adds BlocklistAIController::set_window_id and a TerminalView
on_window_transferred override that keeps both window_id fields
current. Adds a regression test that performs a real
transfer_view_to_window and asserts the controller's resolved team
follows the window.
@warp-agent-staging
warp-agent-staging Bot force-pushed the factory/multi-team-pr3b-apikey-geap branch from 9b2e5e2 to 0984e38 Compare August 20, 2026 23:59
@cla-bot

cla-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

warp-agent-staging Bot added a commit that referenced this pull request Aug 21, 2026
Request-time BYO enforcement previously ran ambiently inside
RequestParams::new(), which cannot resolve a per-window team (it is
cloned and has no window). This mirrors PR 3B's design for GEAP
credentials: BlocklistAIController stores its window_id and exposes
team_uid(ctx), resolved fresh from the window on each call.
RequestParams::new() keeps entitlement-only gating; a new
apply_team_byo_policy(team_uid, app) method is called by the
controller immediately after RequestParams::new() at both call sites
(send_request_input and build_passive_suggestions_request_params),
stripping member-provided credentials the window's current team
policy disallows while preserving AWS Bedrock/GEAP credentials.

UserWorkspaces gains are_member_byo_keys_allowed_for_team/
are_member_byo_endpoints_allowed_for_team (by-uid), with the
context-based agent_settings_* accessors now delegating to them.

This overlaps with PR 3B's (#15369, unmerged) window_id/team_uid
addition to BlocklistAIController for GEAP credentials; that PR is not
cherry-picked here to avoid depending on unmerged PR 2B, but the two
additions are expected to converge once 3B merges.

Adds an acceptance test (apply_team_byo_policy_gates_member_credentials_by_team_policy)
covering two teams with identical workspace entitlement but opposing
team_byo policy.
IsaiahWitzke pushed a commit that referenced this pull request Aug 21, 2026
Request-time BYO enforcement previously ran ambiently inside
RequestParams::new(), which cannot resolve a per-window team (it is
cloned and has no window). This mirrors PR 3B's design for GEAP
credentials: BlocklistAIController stores its window_id and exposes
team_uid(ctx), resolved fresh from the window on each call.
RequestParams::new() keeps entitlement-only gating; a new
apply_team_byo_policy(team_uid, app) method is called by the
controller immediately after RequestParams::new() at both call sites
(send_request_input and build_passive_suggestions_request_params),
stripping member-provided credentials the window's current team
policy disallows while preserving AWS Bedrock/GEAP credentials.

UserWorkspaces gains are_member_byo_keys_allowed_for_team/
are_member_byo_endpoints_allowed_for_team (by-uid), with the
context-based agent_settings_* accessors now delegating to them.

This overlaps with PR 3B's (#15369, unmerged) window_id/team_uid
addition to BlocklistAIController for GEAP credentials; that PR is not
cherry-picked here to avoid depending on unmerged PR 2B, but the two
additions are expected to converge once 3B merges.

Adds an acceptance test (apply_team_byo_policy_gates_member_credentials_by_team_policy)
covering two teams with identical workspace entitlement but opposing
team_byo policy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants