diff --git a/devlog/_plan/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md b/devlog/_plan/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md new file mode 100644 index 0000000000..620389830f --- /dev/null +++ b/devlog/_plan/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md @@ -0,0 +1,167 @@ +# Kiro Builder ID — request-scoped service profile fallback + +Unit: `260827_kiro_builder_id_profile` · work-phase `wp1` · opened 2026-08-27 + +## Symptom + +A Kiro request routed to the Builder ID account fails before any tokens are +generated: + +```text +Provider error 400: kiro_profile_required: Kiro requires a CodeWhisperer +profileArn for this account and model. Re-login or re-import the matching Kiro +account (ocx account login kiro --reauth) so the profile is captured, then retry. +``` + +## Why the current remediation cannot work + +The message tells the operator to re-login so the profile is captured. For an +AWS Builder ID account there is nothing to capture. Builder ID is a personal +identity that is not attached to an AWS account, so AWS never mints an +account-scoped `arn:aws:codewhisperer:::profile/` for +it. Re-running `ocx account login kiro --reauth` produces the same credential +shape it produced before, and the operator loops. + +Confirmed against a live `~/.opencodex/auth.json` holding two Kiro accounts. +Account identifiers and addresses are deliberately omitted; only the +credential *shape* is load-bearing here: + +| account | source | `kiro.profileArn` | `kiro.clientId`/`clientSecret` | +|---|---|---|---| +| A (browser OAuth login) | `oauth` | present | absent | +| B (imported CLI session) | `local-cli` | **absent** | **present** | + +Account B is the failing one, and the presence of `clientId` + +`clientSecret` with no profile ARN is exactly the AWS SSO OIDC / Builder ID +shape. `src/oauth/kiro-credentials.ts:297` already derives +`authType: clientId && clientSecret ? "aws_sso_oidc" : "kiro_desktop"` from +that same pair, so the signal exists — it just never reaches the adapter. + +## Mechanism + +`src/adapters/kiro.ts` `build()`: + +```ts +const resolvedProfileArn = resolveKiroProfileArn(parsed._kiroAuthContext); +const isApiKey = provider.apiKey.trim().startsWith("ksk_"); +const profileArn = isApiKey ? undefined : resolvedProfileArn; +const wireClient: KiroWireClient = isApiKey || !profileArn ? "cli" : "ide"; +... +if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn; +const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode, wireClient); +``` + +`resolveKiroProfileArn` returns `account.profileArn` verbatim when an account +context is present (`src/oauth/kiro.ts:469`). For the Builder ID account that is +`undefined`, so the request goes out on the `cli` wire path with **no** +`profileArn` in the payload and **no** `x-amzn-kiro-profile-arn` header. Gated +models answer with a `ValidationException` naming `profileArn`, and +`src/adapters/kiro-errors.ts:112` maps that to the stable non-retryable +`kiro_profile_required` code. The classifier is doing its job; the request was +simply incomplete. + +## What the reference implementation does + +`minpeter/kiro-lb` hit the same wall and resolved it by observing what the real +Kiro CLI sends. `kiro/config.py`: + +```python +# Builder ID management and generation requests in Kiro CLI 2.19.1 carry this +# service profile even though the local credential has no account-specific ARN. +# Keep it request-scoped: it is not persisted as the account's own profile. +KIRO_BUILDER_ID_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX" +``` + +`kiro/auth.py` exposes it as a derived request-time property, never as the +account's stored identity: + +```python +@property +def request_profile_arn(self) -> Optional[str]: + if self._profile_arn: + return self._profile_arn + if self._auth_type == AuthType.AWS_SSO_OIDC: + return KIRO_BUILDER_ID_PROFILE_ARN + return None +``` + +The account ARN stays authoritative; the service profile is a shared, +non-account-scoped constant that the vendor client itself carries. The account +id `638616132270` is Amazon's, not the user's — nothing account-identifying is +being invented, which is the distinction `#993` cared about when it added +`parseKiroProfileArn` and refused to synthesize ARNs. + +## Design + +Mirror the reference split: **stored identity** vs **request-scoped routing +value**. The fallback must never become the former. + +1. **Carry the auth-type signal to the adapter.** `parsed._kiroAuthContext` is + `Pick` + (`src/types/request.ts:91`). Widen the routing subset with an explicit + `authType?: KiroAuthType` rather than letting the adapter infer Builder ID + from a missing ARN. Inference-by-absence would also catch a + `kiro_desktop` credential whose import merely failed, and that account + should keep failing loudly instead of silently borrowing a service profile. + `src/oauth/index.ts` `accessSnapshot` derives it from the same + `clientId && clientSecret` pair the credential loader already uses, and + propagates it through the three `parsed._kiroAuthContext` assignments in + `src/server/responses/core.ts`. + +2. **Resolve the fallback in one place.** Add + `KIRO_BUILDER_ID_SERVICE_PROFILE_ARN` to `src/adapters/kiro-constants.ts` + and a `resolveKiroRequestProfileArn(account)` helper next to the existing + `resolveKiroProfileArn` in `src/oauth/kiro.ts`. The existing resolver keeps + its current contract — callers that want the account's own ARN keep getting + `undefined` — so region inference and account matching are untouched. + +3. **Use it at request build time only.** `build()` swaps + `resolveKiroProfileArn` for `resolveKiroRequestProfileArn`. Because the + Builder ID account now has a profile, guard the wire-path selection so it + stays `cli`: Builder ID is a CLI-shaped credential and the `ide` envelope is + for enterprise profiles. This is the one place where "has a profileArn" and + "is enterprise" stop being synonyms, and conflating them would silently move + the account onto a different request shape than the vendor client uses. + +4. **Keep API keys unchanged.** `ksk_` still forces `profileArn = undefined`. + +### Non-persistence + +The fallback is computed per request from a constant. It is never written by +`saveAccountCredential`, never enters `KiroOAuthMetadata`, and never reaches +`inferRegionFromProfileArn`, which matters because the constant is +`us-east-1`-scoped and would otherwise pin a Builder ID account's region to +`us-east-1` regardless of its own `ssoRegion`. `resolveKiroApiRegion` reads +`account.profileArn` directly, so leaving that resolver alone is what +preserves correct region behavior. + +One consequence to accept deliberately: +`providerContinuationDestinationIdentity` (`src/server/responses/core.ts:438`) +hashes `kiroContext?.profileArn`. It keeps reading the stored value, so two +Builder ID accounts do not collapse into one continuation scope. + +## Verification + +The regression suite is `tests/kiro-builder-id-profile.test.ts`, kept separate +from `tests/kiro-adapter.test.ts` so the Builder ID contract reads as one story +rather than as scattered cases in the general adapter suite. + +- Builder ID context yields the service ARN in both the payload and the header, + and stays on the `cli` wire path. +- Regression — enterprise account with its own ARN keeps that ARN and the `ide` + path; `ksk_` keeps sending no profile. +- Regression — a `kiro_desktop` account without an ARN still sends none, so the + actionable failure survives for genuinely broken imports. +- Regression — the accountless path, where the auth type comes from the local + CLI import rather than the request context, still sends the fallback inside + the `cli` envelope. Driven red against the earlier context-derived guard + before being accepted. +- Non-persistence asserted against the raw on-disk store, not a parsed view. +- `bun run typecheck` and `bun run privacy:scan`. +- Live: make the Builder ID account (B above) the active Kiro account, restart + the service so the proxy loads this tree, and capture a real completion. + +## Out of scope + +`src/lab/`, routing profiles, other providers, credential rotation, any push or +GitHub mutation. diff --git a/src/adapters/kiro-constants.ts b/src/adapters/kiro-constants.ts index 75183b64db..4b3c709135 100644 --- a/src/adapters/kiro-constants.ts +++ b/src/adapters/kiro-constants.ts @@ -1,4 +1,19 @@ export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer"; + +/** + * Request-scoped CodeWhisperer service profile for AWS Builder ID accounts. + * + * Builder ID is a personal identity with no AWS account behind it, so AWS never mints an + * account-scoped `profile/` ARN for it. The Kiro CLI resolves this the same way: it carries + * this fixed service profile on Builder ID requests. The embedded account id is Amazon's own, not + * the user's, which is why sending it is not the same as synthesizing an account identity. + * + * Request-scoped is load-bearing. This value must never be persisted into `KiroOAuthMetadata`, + * never seed region inference (it is `us-east-1` and would pin every Builder ID account there), + * and never participate in account matching. + */ +export const KIRO_BUILDER_ID_SERVICE_PROFILE_ARN = + "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX"; export const KIRO_CONTINUATION_MESSAGE = "Continue from the prior conversation. Do not quote or mention this instruction."; export const KIRO_COMPLETION_RETRY_MESSAGE = diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 11ed49886e..3b8af6d252 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -1,7 +1,7 @@ import { decodeEventStream } from "../lib/eventstream-decoder"; import { estimateTokens } from "../lib/token-estimate"; import { debugProviderDiagnostic } from "../lib/debug"; -import { resolveKiroApiRegion, resolveKiroProfileArn } from "../oauth/kiro"; +import { resolveKiroApiRegion, resolveKiroRequestProfile } from "../oauth/kiro"; import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models"; import { modelRecordValue } from "../reasoning-effort"; import { parseKiroEvent } from "./kiro-events"; @@ -1723,12 +1723,19 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter throw new Error("kiro token missing — run ocx login kiro"); } const region = resolveKiroApiRegion(parsed._kiroAuthContext); - const resolvedProfileArn = resolveKiroProfileArn(parsed._kiroAuthContext); + // Request-scoped: an AWS Builder ID account has no profile of its own and resolves to Kiro's + // fixed service profile here, without that value ever becoming the account's stored identity. + const requestProfile = resolveKiroRequestProfile(parsed._kiroAuthContext); + const resolvedProfileArn = requestProfile.profileArn; const isApiKey = provider.apiKey.trim().startsWith("ksk_"); const profileArn = isApiKey ? undefined : resolvedProfileArn; - // Builder ID and Kiro API keys have no profile ARN and are accepted only on Kiro's CLI - // request path. Enterprise profiles retain the existing IDE-shaped request. - const wireClient: KiroWireClient = isApiKey || !profileArn ? "cli" : "ide"; + // Builder ID and Kiro API keys are accepted only on Kiro's CLI request path; enterprise + // profiles retain the IDE-shaped request. Builder ID now carries a profile ARN, so a truthy + // `profileArn` no longer implies "enterprise". The wire path reads the resolver's own verdict + // rather than re-deriving it, so the accountless path — where the auth type comes from the + // local import, not the request context — cannot send the fallback inside an IDE-shaped call. + const isBuilderId = requestProfile.builderIdFallback; + const wireClient: KiroWireClient = isApiKey || isBuilderId || !profileArn ? "cli" : "ide"; const fp = fingerprint().slice(0, 64); const headers: Record = wireClient === "cli" ? { authorization: `Bearer ${provider.apiKey}`, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index db8cfb4392..99e596df30 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -58,7 +58,7 @@ export interface OAuthAccessSnapshot { /** Cloud Code Assist project selected during Antigravity login. */ projectId?: string; /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */ - kiro?: Pick; + kiro?: Pick; /** * Allowlisted GitHub Copilot API origin belonging to THIS account. * @@ -359,11 +359,20 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { } function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { + // Derived, not read back: a stored `authType` is trusted when present, but a credential imported + // before the field existed still routes correctly because the client pair implies SSO OIDC. + const kiroAuthType = cred.kiro?.authType + ?? (cred.kiro?.clientId && cred.kiro?.clientSecret ? "aws_sso_oidc" as const : undefined); const storedKiroRouting = { ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}), ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}), ...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}), }; + // `authType` is a property OF the account, not routing the environment can substitute for, so it + // is merged after the environment fallback decision rather than counting as stored routing. + // Folding it into `storedKiroRouting` would make a client-pair-only credential look non-empty + // and silently disable `environmentKiroRoutingMetadata()` for it. + const kiroAuthTypeRouting = kiroAuthType ? { authType: kiroAuthType } : {}; // Validated here, not at the call site: an unvalidated origin from a legacy or crafted // credential must never travel with a bearer, and dropping it makes the transport fall back to // the canonical host rather than to whatever the previous account was using. @@ -381,9 +390,12 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti // may use explicit environment routing, but never borrow the currently signed-in local CLI account. ...(provider === "kiro" ? { - kiro: Object.keys(storedKiroRouting).length > 0 - ? storedKiroRouting - : environmentKiroRoutingMetadata() ?? {}, + kiro: { + ...(Object.keys(storedKiroRouting).length > 0 + ? storedKiroRouting + : environmentKiroRoutingMetadata() ?? {}), + ...kiroAuthTypeRouting, + }, } : {}), }; diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index 716e6cbfa3..f2f25eeb58 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -28,6 +28,7 @@ import { } from "./kiro-credentials"; import { homedir } from "node:os"; import { getAccountSet, saveAccountCredential } from "./store"; +import { KIRO_BUILDER_ID_SERVICE_PROFILE_ARN } from "../adapters/kiro-constants"; const DEFAULT_REGION = "us-east-1"; const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken"; @@ -473,6 +474,50 @@ export function resolveKiroProfileArn(account?: Pick, +): string | undefined { + return resolveKiroRequestProfile(account).profileArn; +} + +/** + * The profileArn to send, together with WHY it was chosen. + * + * The request builder must decide the wire envelope from the same evaluation that produced the + * ARN. Re-deriving "is this Builder ID" from the account context alone would miss the accountless + * path, where the auth type comes from the locally imported credential instead: the fallback would + * be sent while the request was shaped as an enterprise IDE call, which is not a combination the + * vendor client ever produces. + */ +export function resolveKiroRequestProfile( + account?: Pick, +): { profileArn: string | undefined; builderIdFallback: boolean } { + const own = resolveKiroProfileArn(account); + if (own) return { profileArn: own, builderIdFallback: false }; + const authType = account !== undefined + ? account.authType + : readImportedKiroCredential()?.authType; + return authType === "aws_sso_oidc" + ? { profileArn: KIRO_BUILDER_ID_SERVICE_PROFILE_ARN, builderIdFallback: true } + : { profileArn: undefined, builderIdFallback: false }; +} + async function kiroTokenRefreshError(response: Response): Promise { let oauthError: string | undefined; try { diff --git a/src/oauth/types.ts b/src/oauth/types.ts index e712f5b2a5..5c2dac541b 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -1,6 +1,16 @@ /** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */ export type OAuthCredentialSource = "oauth" | "local-cli" | "credential-file" | "environment" | "manual"; +/** + * How the account authenticated. Mirrors `KiroAuthType` in `./kiro-credentials`, restated here so + * the credential-store types do not depend on the SQLite import module. + * + * `aws_sso_oidc` covers AWS Builder ID, which never issues an account-scoped CodeWhisperer + * profile ARN; the adapter needs that distinction to tell a Builder ID account apart from a + * `kiro_desktop` account whose profile import merely failed. + */ +export type KiroCredentialAuthType = "kiro_desktop" | "aws_sso_oidc"; + /** Account-scoped Kiro data required for refresh and request routing. */ export interface KiroOAuthMetadata { profileArn?: string; @@ -8,6 +18,11 @@ export interface KiroOAuthMetadata { apiRegion?: string; clientId?: string; clientSecret?: string; + /** + * Non-secret routing signal. Derived from the presence of a device-registration client pair, so + * it stays accurate even though `clientId`/`clientSecret` never leave the credential store. + */ + authType?: KiroCredentialAuthType; } export type OAuthCredentials = { diff --git a/src/types/request.ts b/src/types/request.ts index 28f37ae666..d78c25b416 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -88,7 +88,7 @@ export interface OcxParsedRequest { */ _cursorIsolateConversation?: boolean; /** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */ - _kiroAuthContext?: Pick; + _kiroAuthContext?: Pick; /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */ _providerContinuation?: OcxProviderContinuationState; /** Persisted continuation considered only after the final physical route is known. */ diff --git a/tests/kiro-builder-id-profile.test.ts b/tests/kiro-builder-id-profile.test.ts new file mode 100644 index 0000000000..4f5a4f342a --- /dev/null +++ b/tests/kiro-builder-id-profile.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createKiroAdapter } from "../src/adapters/kiro"; +import { KIRO_BUILDER_ID_SERVICE_PROFILE_ARN } from "../src/adapters/kiro-constants"; +import { getValidAccessTokenSnapshot } from "../src/oauth"; +import { resolveKiroApiRegion, resolveKiroProfileArn, resolveKiroRequestProfileArn } from "../src/oauth/kiro"; + +/** Mirrors resolveKiroCliNativeSessionEntries so an accountless request reads a real local import. */ +function seedKiroCliBuilderIdSession(): void { + const dir = process.platform === "win32" + ? join(tmp, "AppData", "Local", "Kiro-Cli") + : process.platform === "darwin" + ? join(tmp, "Library", "Application Support", "kiro-cli") + : join(tmp, ".local", "share", "kiro-cli"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, "data.sqlite3")); + db.run("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)"); + db.run("INSERT INTO auth_kv (key, value) VALUES (?, ?)", [ + "kirocli:social:token", + // Builder ID shape: a device-registration client pair and NO profile_arn. + JSON.stringify({ + access_token: "local-access", + refresh_token: "local-refresh", + region: "us-east-1", + client_id: "local-client-id", + client_secret: "local-client-secret", + }), + ]); + db.close(); +} +import { saveCredential } from "../src/oauth/store"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +/** + * Issue #993 follow-up: an AWS Builder ID account authenticates through SSO OIDC and never receives + * an account-scoped CodeWhisperer profile ARN, so every gated-model request failed with + * `kiro_profile_required` and the suggested remediation (re-login to capture the profile) could + * never succeed. The adapter now sends Kiro's fixed service profile for those accounts, exactly as + * the Kiro CLI does, while the account's stored identity stays empty. + */ + +const origHome = process.env.HOME; +const origLocalAppData = process.env.LOCALAPPDATA; +const origUserProfile = process.env.USERPROFILE; +const origRegion = process.env.KIRO_REGION; +const origApiRegion = process.env.KIRO_API_REGION; +const origArn = process.env.KIRO_PROFILE_ARN; +const origOcxHome = process.env.OPENCODEX_HOME; +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "kiro-builder-id-")); + process.env.HOME = tmp; + process.env.LOCALAPPDATA = join(tmp, "AppData", "Local"); + process.env.USERPROFILE = tmp; + process.env.OPENCODEX_HOME = tmp; + process.env.KIRO_REGION = "us-east-1"; + delete process.env.KIRO_API_REGION; + delete process.env.KIRO_PROFILE_ARN; +}); + +afterEach(() => { + if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; + if (origLocalAppData === undefined) delete process.env.LOCALAPPDATA; else process.env.LOCALAPPDATA = origLocalAppData; + if (origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = origUserProfile; + if (origRegion === undefined) delete process.env.KIRO_REGION; else process.env.KIRO_REGION = origRegion; + if (origApiRegion === undefined) delete process.env.KIRO_API_REGION; else process.env.KIRO_API_REGION = origApiRegion; + if (origArn === undefined) delete process.env.KIRO_PROFILE_ARN; else process.env.KIRO_PROFILE_ARN = origArn; + if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = origOcxHome; + rmSync(tmp, { recursive: true, force: true }); +}); + +const provider = { + adapter: "kiro", + baseUrl: "https://runtime.us-east-1.kiro.dev", + authMode: "oauth", + apiKey: "tok-123", +} as unknown as OcxProviderConfig; + +function parsedWith(context: OcxParsedRequest["_kiroAuthContext"]): OcxParsedRequest { + const parsed = { + modelId: "claude-sonnet-4.5", + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "hi" }] }, + } as unknown as OcxParsedRequest; + if (context) parsed._kiroAuthContext = context; + return parsed; +} + +async function buildBody(parsed: OcxParsedRequest): Promise<{ + headers: Record; + payload: { profileArn?: string; conversationState?: Record }; +}> { + const request = await createKiroAdapter(provider).buildRequest(parsed); + return { headers: request.headers, payload: JSON.parse(request.body) }; +} + +describe("kiro Builder ID request-scoped service profile", () => { + test("a Builder ID account sends the service profile in both the payload and the header", async () => { + const { headers, payload } = await buildBody(parsedWith({ apiRegion: "us-east-1", authType: "aws_sso_oidc" })); + + expect(payload.profileArn).toBe(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + expect(headers["x-amzn-kiro-profile-arn"]).toBe(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + }); + + test("a Builder ID account stays on the CLI wire path despite carrying a profile ARN", async () => { + const { headers, payload } = await buildBody(parsedWith({ apiRegion: "us-east-1", authType: "aws_sso_oidc" })); + + // The CLI envelope is identified by its accept type and its agent continuation fields; the IDE + // envelope would instead negotiate the eventstream accept and set x-amzn-kiro-agent-mode. + expect(headers.accept).toBe("*/*"); + expect(headers["x-amzn-kiro-agent-mode"]).toBeUndefined(); + const conversationState = payload.conversationState ?? {}; + expect(conversationState.agentTaskType).toBe("vibe"); + expect(typeof conversationState.agentContinuationId).toBe("string"); + }); + + test("an enterprise account keeps its own profile and the IDE wire path", async () => { + const own = "arn:aws:codewhisperer:eu-central-1:123456789012:profile/account-b"; + const { headers, payload } = await buildBody(parsedWith({ apiRegion: "eu-central-1", profileArn: own })); + + expect(payload.profileArn).toBe(own); + expect(headers["x-amzn-kiro-profile-arn"]).toBe(own); + expect(headers.accept).toBe("application/vnd.amazon.eventstream"); + expect(headers["x-amzn-kiro-agent-mode"]).toBe("vibe"); + }); + + test("an SSO OIDC account that does own a profile ARN uses its own, not the fallback", async () => { + const own = "arn:aws:codewhisperer:us-east-1:123456789012:profile/enterprise-sso"; + const { payload } = await buildBody(parsedWith({ authType: "aws_sso_oidc", profileArn: own })); + + expect(payload.profileArn).toBe(own); + }); + + test("a kiro_desktop account with no profile still sends none, preserving its actionable failure", async () => { + const { headers, payload } = await buildBody(parsedWith({ apiRegion: "us-east-1", authType: "kiro_desktop" })); + + expect(payload.profileArn).toBeUndefined(); + expect(headers["x-amzn-kiro-profile-arn"]).toBeUndefined(); + }); + + test("a Kiro API key never borrows the Builder ID service profile", async () => { + const apiKeyProvider = { ...provider, authMode: "key", apiKey: "ksk_example" } as unknown as OcxProviderConfig; + const parsed = parsedWith({ authType: "aws_sso_oidc" }); + + const request = await createKiroAdapter(apiKeyProvider).buildRequest(parsed); + const payload = JSON.parse(request.body) as { profileArn?: string }; + + expect(payload.profileArn).toBeUndefined(); + expect(request.headers["x-amzn-kiro-profile-arn"]).toBeUndefined(); + }); + + test("the fallback never becomes the account's identity, region, or stored metadata", async () => { + const builderId = { authType: "aws_sso_oidc" as const, ssoRegion: "eu-central-1" }; + + // The identity resolver keeps answering "this account owns no profile". + expect(resolveKiroProfileArn(builderId)).toBeUndefined(); + expect(resolveKiroRequestProfileArn(builderId)).toBe(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + + // Region inference must not be pinned to the fallback's us-east-1. + expect(resolveKiroApiRegion(builderId)).toBe("eu-central-1"); + + await saveCredential("kiro", { + access: "stored-access", + refresh: "stored-refresh", + expires: Date.now() + 3_600_000, + source: "local-cli", + kiro: { ssoRegion: "us-east-1", apiRegion: "us-east-1", clientId: "client-id", clientSecret: "client-secret" }, + }); + + const snapshot = await getValidAccessTokenSnapshot("kiro"); + expect(snapshot.kiro?.authType).toBe("aws_sso_oidc"); + expect(snapshot.kiro?.profileArn).toBeUndefined(); + + // The request built from that snapshot carries the fallback... + const { payload } = await buildBody(parsedWith({ ...snapshot.kiro })); + expect(payload.profileArn).toBe(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + + // ...while the on-disk credential store never learns about it. Asserting on the raw file rather + // than a parsed view is deliberate: a leak through any unexpected key is still a leak. + // OPENCODEX_HOME is the config dir itself, so the store lives at the tmp root. + const authStorePath = join(tmp, "auth.json"); + expect(existsSync(authStorePath)).toBe(true); + const stored = readFileSync(authStorePath, "utf8"); + expect(stored).not.toContain(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + expect(stored).not.toContain("638616132270"); + }); + + test("a legacy credential predating authType still routes as Builder ID via its client pair", async () => { + await saveCredential("kiro", { + access: "stored-access", + refresh: "stored-refresh", + expires: Date.now() + 3_600_000, + source: "local-cli", + kiro: { clientId: "client-id", clientSecret: "client-secret" }, + }); + + const snapshot = await getValidAccessTokenSnapshot("kiro"); + + expect(snapshot.kiro?.authType).toBe("aws_sso_oidc"); + const { payload } = await buildBody(parsedWith({ ...snapshot.kiro })); + expect(payload.profileArn).toBe(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + }); + + test("an accountless Builder ID import sends the fallback inside the CLI envelope, not the IDE one", async () => { + // Regression for the wire-path/resolver split: the auth type here comes from the local import, + // never from _kiroAuthContext, so a guard that re-derived Builder ID from the request context + // would send the fallback ARN while shaping the call as an enterprise IDE request. + seedKiroCliBuilderIdSession(); + + const parsed = parsedWith(undefined); + expect(parsed._kiroAuthContext).toBeUndefined(); + + const { headers, payload } = await buildBody(parsed); + + expect(payload.profileArn).toBe(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + expect(headers["x-amzn-kiro-profile-arn"]).toBe(KIRO_BUILDER_ID_SERVICE_PROFILE_ARN); + expect(headers.accept).toBe("*/*"); + expect(headers["x-amzn-kiro-agent-mode"]).toBeUndefined(); + }); +});