[multi-team PR 3B] Key GEAP credential state by team in ApiKeyManager - #15369
[multi-team PR 3B] Key GEAP credential state by team in ApiKeyManager#15369warp-agent-staging[bot] wants to merge 4 commits into
Conversation
|
Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
|
|
This PR was generated with Warp. Comment |
|
Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
|
a54d539 to
9b2e5e2
Compare
|
Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
|
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.
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.
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.
9b2e5e2 to
0984e38
Compare
|
Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Oz.
|
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.
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.


Depends on PR 2B slice 1 (#15355, open). That PR independently adds the same
ResponseStream.team_uid: Option<ServerId>field, threaded through the samenew→retry→spawn_requestpath, continuing intospawn_generate/generate_multi_agent_outputto attachX-Warp-Team-Uid. This PR stops atspawn_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 placeholderNoneat theResponseStream::new(...)call site incontroller.rswith 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: WindowIdfield onBlocklistAIControllerand the exact sameteam_uid(ctx) -> Option<ServerId>accessor (UserWorkspaces::team_uid_for_window(self.window_id)), used at the same twoRequestParams::new()call sites — to applyapply_team_byo_policywhere this PR attaches GEAP credentials. Compared both implementations directly: they delegate to the identical sharedUserWorkspaces::team_uid_for_window, so the edge cases agree by construction, not by coincidence — a window with no team returnsNonein both, and a window whose team was removed from the workspace is reconciled to a fallback (orNone) byUserWorkspaces::reconcile_window_team_assignments, which both call sites read through. Whichever of 1A/3B merges second drops its duplicatewindow_idfield/team_uidaccessor 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_uidacross 3B/2B, and now this) — signal that per-window team resolution wants to be a first-class, shared primitive onBlocklistAIControllerrather than re-derived per consumer.Design decision: keyed by team, inside
ApiKeyManagerGEAP 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_failurefields onApiKeyManagerwith aHashMap<team_uid, GeapTeamState>, and addsteam_uidtoGeapMintBindingso 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()readUserWorkspaces::current_workspace()— a single ambient value — instead of the operation's actual team's ownTeam.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):ApiKeyManagernow holdsgeap_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. AddedGeapCredentialsState::Indeterminatefor "more than one team has state and this API can't say which" — kept distinct fromMissing("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 ambientcurrent_geap_policy(app)— no GEAP policy path readscurrent_workspace()any more. All mint/refresh/completion functions take an explicitteam_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.RequestParamsstays free of team scope entirely.RequestParams::new()(agent/api.rs) has its original signature back — no team parameter.RequestParamsis cloned for retries, so a capability embedded there could travel to a retry that never established it; that's true regardless of whether the literalTeamContexttype is involved. The caller (BlocklistAIController, which already resolves its own window's team) attaches the GEAP credential afterRequestParams::new()returns, via a newgeap_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).BlocklistAIControllercarries its terminal surface'sWindowId(passed in fromTerminalView::new) and resolves the window's current team once per request viateam_uid_for_window— never cached. That resolved value flows toattach_geap_credentials_if_available(initial credential) and intoResponseStream.team_uid, which carries it through retries so a later window switch cannot retarget an in-flight request.AIBlock's GEAP inline-error view (team_for_view) and its "Refresh" action.Why
BlocklistAIControllermay resolve team scope, and why it re-resolves every requestThis needed reconciling with #15355, which deliberately left this exact resolution as a TODO, since "the controller must not store ambient
TeamContext." WhatBlocklistAIControllerstores is aWindowId, not aTeamContext— every call toteam_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.rshunk passesNoneforteam_uidat itsResponseStream::new(...)call site, with a TODO saying real capture is deferred. This PR'sBlocklistAIController.team_uid()fills that in, so once both land, #15355'sX-Warp-Team-Uidheader actually starts firing on/ai/multi-agentfor 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:
team_uid()returnsSome, the header carries the real team, and GEAP attaches/refreshes normally for that team.team_uid()returnsNone, exactly as before: no header, no GEAP binding. This is the common, correct "no team" case, not a degraded one.geap_credentials_state()'sIndeterminateandforce_refresh_geap_credentials()'s no-op only apply to the two call sites inwarp_agent_page.rsI 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-serverhas no reference toX-Warp-Team-Uidor aTEAM_UID_HEADERconstant anywhere.generateMultiAgentOutput's handler resolvesteamfrommiddleware.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, untilwarp-server#15687adds 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_idgoing stale on cross-window tab dragFound in review (credit: PR 1A's investigation):
BlocklistAIController.window_id— the fieldteam_uid()resolves from — was set once at construction and never updated.TerminalView, which owns the controller, had noon_window_transferredoverride, 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 theX-Warp-Team-Uidheader (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_idand aTerminalView::on_window_transferredoverride that propagates the new window id to bothself.window_idand the controller. Added a regression test (window_transfer_updates_resolved_team_scope) that performs a realAppContext::transfer_view_to_windowbetween 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/ApiKeyManagercoupling 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.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 reportsIndeterminate(neverMissing, and confirmed to never guess between two real teams' states). Whoever migrates that page to a team-scoped read should switch it togeap_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 warningsandcargo clippy -p ai --all-targets --tests -- -D warnings: clean.cargo clippy -p ai --target wasm32-unknown-unknown) — the sandbox has no permission to install thewasm32-unknown-unknownrustup 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) andcargo 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 theApiKeyManagerlevel — credential leakage, mint-failure cooldown scoping, theIndeterminatezero-arg shim, and thatbegin_expired_geap_refreshstarts independent mints for two different teams rather than one coalescing behind the other's waiter list — pluswindow_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.ApiKeyManagerunit 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-windowApp::testharness with no multi-window GUI test precedent to extend.Diff size: within the ~1000-line budget.
CHANGELOG-NONE