Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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:<region>:<account-id>:profile/<id>` 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<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">`
(`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.
15 changes: 15 additions & 0 deletions src/adapters/kiro-constants.ts
Original file line number Diff line number Diff line change
@@ -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/<id>` 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 =
Expand Down
17 changes: 12 additions & 5 deletions src/adapters/kiro.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, string> = wireClient === "cli" ? {
authorization: `Bearer ${provider.apiKey}`,
Expand Down
20 changes: 16 additions & 4 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
/**
* Allowlisted GitHub Copilot API origin belonging to THIS account.
*
Expand Down Expand Up @@ -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.
Expand All @@ -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,
},
}
: {}),
};
Expand Down
45 changes: 45 additions & 0 deletions src/oauth/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -473,6 +474,50 @@ export function resolveKiroProfileArn(account?: Pick<KiroOAuthMetadata, "profile
return readImportedKiroCredential()?.profileArn;
}

/**
* Resolve the profileArn actually SENT upstream, which is not always the account's own.
*
* An AWS Builder ID account authenticates through SSO OIDC and never receives an account-scoped
* profile ARN, so gated models reject its requests with a `profileArn`-demanding
* `ValidationException`. The Kiro CLI handles this by carrying a fixed service profile on Builder
* ID requests, and this mirrors that.
*
* Deliberately separate from `resolveKiroProfileArn`: that resolver answers "what is this
* account's profile", and callers that ask it — region inference, account matching, continuation
* scoping — must keep receiving `undefined` here. Only request construction uses this function.
*
* The fallback is gated on `authType === "aws_sso_oidc"` rather than on a missing ARN, so a
* `kiro_desktop` account whose profile import failed keeps producing its actionable error instead
* of silently borrowing a service profile that does not describe it.
*/
export function resolveKiroRequestProfileArn(
account?: Pick<KiroOAuthMetadata, "profileArn" | "authType">,
): 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<KiroOAuthMetadata, "profileArn" | "authType">,
): { 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<KiroTokenRefreshError> {
let oauthError: string | undefined;
try {
Expand Down
15 changes: 15 additions & 0 deletions src/oauth/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
/** 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;
ssoRegion?: string;
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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export interface OcxParsedRequest {
*/
_cursorIsolateConversation?: boolean;
/** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */
_kiroAuthContext?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
_kiroAuthContext?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
/** 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. */
Expand Down
Loading
Loading