From 0209234e4bfd300fdc7793ce17bf41941f78406c Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 27 Aug 2026 13:31:17 +0900 Subject: [PATCH 1/3] fix(kiro): send Kiro's service profile for AWS Builder ID accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An AWS Builder ID account authenticates through SSO OIDC and never receives an account-scoped CodeWhisperer profile ARN, because Builder ID is a personal identity with no AWS account behind it. `build()` resolved the account's own profile, got `undefined`, and sent a request with neither a `profileArn` in the payload nor an `x-amzn-kiro-profile-arn` header. Gated models answered with a `profileArn`-demanding ValidationException, which surfaced as `kiro_profile_required` telling the operator to re-login so the profile is captured — advice that can never succeed, because there is nothing to capture. The Kiro CLI resolves this by carrying a fixed service profile on Builder ID requests. Mirror that: `resolveKiroRequestProfileArn` answers "what do we send", falling back to `KIRO_BUILDER_ID_SERVICE_PROFILE_ARN` only for `authType === "aws_sso_oidc"`. `resolveKiroProfileArn` keeps answering "what does this account own", so region inference, account matching, and continuation scoping still see `undefined` and the fallback never reaches auth.json. The embedded AWS account id is Amazon's own, not the user's, so no account identity is being synthesized. Gating on `authType` rather than on a missing ARN keeps a `kiro_desktop` account whose profile import failed producing its actionable error instead of silently borrowing a profile that does not describe it. `authType` is derived from the device-registration client pair, so credentials written before the field existed route correctly without a migration. Because Builder ID now carries an ARN, a truthy `profileArn` no longer implies "enterprise", so the wire-path selection keys off the auth type; otherwise a Builder ID account would flip to the IDE envelope the vendor client never uses for it. Verification: tests/kiro-builder-id-profile.test.ts (8 new), plus kiro-adapter/kiro-oauth/kiro-stream/kiro-review-regressions/core-lab-boundary/ oauth-reauth-bind green at 247 pass, and `bun run typecheck` clean. --- .../010_builder_id_request_scoped_profile.md | 158 +++++++++++++++ src/adapters/kiro-constants.ts | 15 ++ src/adapters/kiro.ts | 16 +- src/oauth/index.ts | 20 +- src/oauth/kiro.ts | 28 +++ src/oauth/types.ts | 15 ++ src/types/request.ts | 2 +- tests/kiro-builder-id-profile.test.ts | 183 ++++++++++++++++++ 8 files changed, 427 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md create mode 100644 tests/kiro-builder-id-profile.test.ts 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..f091418000 --- /dev/null +++ b/devlog/_plan/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md @@ -0,0 +1,158 @@ +# 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 + +- `tests/kiro-adapter.test.ts` — 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. +- `bun run typecheck`. +- 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..c410909e14 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, resolveKiroRequestProfileArn } 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,18 @@ 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 resolvedProfileArn = resolveKiroRequestProfileArn(parsed._kiroAuthContext); 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" and the wire path keys off the account's auth + // type instead. Selecting "ide" here would send a Builder ID account a request shape the + // vendor client never uses for it. + const isBuilderId = parsed._kiroAuthContext?.authType === "aws_sso_oidc"; + 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..d3f3349747 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,33 @@ export function resolveKiroProfileArn(account?: Pick, +): string | undefined { + const own = resolveKiroProfileArn(account); + if (own) return own; + const authType = account !== undefined + ? account.authType + : readImportedKiroCredential()?.authType; + return authType === "aws_sso_oidc" ? KIRO_BUILDER_ID_SERVICE_PROFILE_ARN : undefined; +} + 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..b60cdb3428 --- /dev/null +++ b/tests/kiro-builder-id-profile.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, 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"; +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); + }); +}); From 1241021d8b76658edd6e3076c5983e0f1617398b Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 27 Aug 2026 13:37:04 +0900 Subject: [PATCH 2/3] fix(kiro): key the Builder ID wire path off the resolver's own verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the guard and the resolver could disagree. The adapter re-derived "is this Builder ID" from `parsed._kiroAuthContext?.authType`, but `resolveKiroRequestProfileArn` also resolves the auth type from the locally imported credential when no account context is present. On that accountless path the fallback ARN was sent while `isBuilderId` stayed false, so the request was shaped as an enterprise IDE call carrying a Builder ID service profile — a combination the vendor client never produces. Rather than duplicating the derivation, `resolveKiroRequestProfile` now returns the ARN together with whether it came from the Builder ID fallback, and the adapter reads that verdict. One evaluation decides both, so the two cannot drift apart again. `resolveKiroRequestProfileArn` stays as the value-only wrapper. The new regression was driven red against the previous guard before being accepted: with the context-derived check restored it fails, with the resolver verdict it passes. Verification: tests/kiro-builder-id-profile.test.ts 9 pass; kiro-adapter, kiro-oauth, kiro-review-regressions, core-lab-boundary 136 pass; typecheck clean; live completion re-captured on the Builder ID account after restart. --- src/adapters/kiro.ts | 13 ++++---- src/oauth/kiro.ts | 21 +++++++++++-- tests/kiro-builder-id-profile.test.ts | 44 ++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index c410909e14..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, resolveKiroRequestProfileArn } 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"; @@ -1725,15 +1725,16 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter const region = resolveKiroApiRegion(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 resolvedProfileArn = resolveKiroRequestProfileArn(parsed._kiroAuthContext); + 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 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" and the wire path keys off the account's auth - // type instead. Selecting "ide" here would send a Builder ID account a request shape the - // vendor client never uses for it. - const isBuilderId = parsed._kiroAuthContext?.authType === "aws_sso_oidc"; + // `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" ? { diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index d3f3349747..f2f25eeb58 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -493,12 +493,29 @@ 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 own; + if (own) return { profileArn: own, builderIdFallback: false }; const authType = account !== undefined ? account.authType : readImportedKiroCredential()?.authType; - return authType === "aws_sso_oidc" ? KIRO_BUILDER_ID_SERVICE_PROFILE_ARN : undefined; + return authType === "aws_sso_oidc" + ? { profileArn: KIRO_BUILDER_ID_SERVICE_PROFILE_ARN, builderIdFallback: true } + : { profileArn: undefined, builderIdFallback: false }; } async function kiroTokenRefreshError(response: Response): Promise { diff --git a/tests/kiro-builder-id-profile.test.ts b/tests/kiro-builder-id-profile.test.ts index b60cdb3428..4f5a4f342a 100644 --- a/tests/kiro-builder-id-profile.test.ts +++ b/tests/kiro-builder-id-profile.test.ts @@ -1,11 +1,36 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +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"; @@ -180,4 +205,21 @@ describe("kiro Builder ID request-scoped service profile", () => { 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(); + }); }); From 0f10e577c9f91c54bc023dba4f142c23ddeb0a83 Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 27 Aug 2026 14:26:18 +0900 Subject: [PATCH 3/3] docs(devlog): point the plan's verification section at the real test file The plan was written before the regression suite got its own file, so it still named tests/kiro-adapter.test.ts. The tests live in tests/kiro-builder-id-profile.test.ts. Also records the two cases the plan did not anticipate: the accountless path and the raw-store non-persistence check. --- .../010_builder_id_request_scoped_profile.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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 index f091418000..620389830f 100644 --- 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 @@ -142,13 +142,22 @@ Builder ID accounts do not collapse into one continuation scope. ## Verification -- `tests/kiro-adapter.test.ts` — Builder ID context yields the service ARN in - both the payload and the header, and stays on the `cli` wire path. +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. -- `bun run typecheck`. +- 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.