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
45 changes: 44 additions & 1 deletion src/oauth/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,54 @@ function profileArnFromWhoami(parsed: Record<string, unknown>): string | undefin
: undefined);
}

/**
* `kiro-cli whoami --format json` prints its identity as one JSON object, but once an IAM Identity
* Center profile is selected kiro-cli 2.32 follows that object with a human-readable `Profile:`
* block on stdout. Parsing the whole stream then failed, which silently dropped the user email that
* tells organization users apart (#6641). Only the leading top-level object is identity evidence;
* it must start the output and end its line, so text on later lines is ignored while a second
* value on the same line makes the identity unreadable.
*/
function parseWhoamiObject(stdout: string): Record<string, unknown> | undefined {
const start = stdout.search(/\S/);
if (start < 0 || stdout[start] !== "{") return undefined;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < stdout.length; index++) {
const char = stdout[index];
if (inString) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === "\"") inString = false;
continue;
}
if (char === "\"") inString = true;
else if (char === "{" || char === "[") depth++;
else if (char === "}" || char === "]") {
depth--;
if (depth > 0) continue;
if (!/^[ \t]*(?:\r?\n|$)/.test(stdout.slice(index + 1))) return undefined;
const parsed: unknown = JSON.parse(stdout.slice(start, index + 1));
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: undefined;
}
}
return undefined;
}

/**
* Same-session identity reported by `kiro-cli whoami --format json`: the lower-cased user email
* and a structurally valid profile ARN. Any failure (non-zero exit, unreadable output, a thrown
* runner) yields an empty identity, so a login falls back to the SQLite import instead of failing.
*/
async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string; profileArn?: string }> {
try {
const result = await runner(["whoami", "--format", "json"], signal);
if (result.exitCode !== 0) return {};
const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
const parsed = parseWhoamiObject(result.stdout);
if (!parsed) return {};
const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : "";
const profileArn = profileArnFromWhoami(parsed);
return {
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ rewrite rules and the routed-id settlement.
| `src/providers/model-rename-fields.ts`, `src/providers/model-rename-migration.ts` | Classifies every provider config field for a declared model rename. Exact-model records, lists and nested request-pacing keys follow the replacement; an already saved replacement entry wins. Provider-wide settings, including response-tier authority and project-context consent, and credential fields are not model identities. |
| `src/providers/resolved-model-policy.ts`, `src/providers/resolved-model-policy-merge.ts` | Static provider/model policy resolution for the final upstream wire model, plus its pure clone/merge/URL/family helpers. The resolver detaches and freezes registry defaults, operator overrides, exact explicit input-modality declarations, provider-scoped hard wire pins (including Command Code's `claude-` prefix), aliases, and explicit false/empty values with field-level provenance. Provider derivation, routing, catalog hints, gather admission, and adapter selection consume its detached frozen result. Callers supply transport match, the exact capability row, and a credential-free effective auth decision; credential bytes, usability evidence, account/quota/health state, and observed limits remain outside the result. |

| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. Because an organization profile ARN is shared by its users, `src/oauth/kiro-account-identity.ts` keys Kiro rows by ARN plus the same-session `whoami` email (#6641): a login without a user keeps the legacy ARN identity but never replaces an identified user of the same profile unless the refresh token proves the same CLI session; reauth of a row with a user and rotated-CLI recovery require that same user (a row without a user recovers only while no other row shares its profile, and explicit reauth may record its user unless that user owns another row of the profile), an unmatched login claims an identity-less active slot only as the same session while the profile has other rows, and Add account records the current CLI user on ARN-only rows through a fenced same-session bind. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second per-fetch deadline covering headers and body. Error bodies are capped at 16 KiB bytes and success JSON at 64 KiB; each settled call detaches its caller listener and clears its timer. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals require an explicit allowlisted terminal OAuth code and mark only the matching generation `needsReauth`; free-text descriptions never establish terminality; transient failures remain retryable. HTTP-derived errors expose only status and allowlisted code. Cancellation and deadline errors use fixed AbortError/TimeoutError messages; transport and parsing failures also use fixed messages, without raw reasons or causes, and never mark reauthentication. The registry preserves its own stale-flight error. `tests/oauth/chatgpt-token-expiry.test.ts`, `tests/oauth/chatgpt-token-boundaries.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. |
| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. That identity is read from the leading JSON object of `kiro-cli whoami --format json`, which must end its line: kiro-cli 2.32 prints a human-readable `Profile:` block after it once an IAM Identity Center profile is selected, and that later text is ignored. Because an organization profile ARN is shared by its users, `src/oauth/kiro-account-identity.ts` keys Kiro rows by ARN plus the same-session `whoami` email (#6641): a login without a user keeps the legacy ARN identity but never replaces an identified user of the same profile unless the refresh token proves the same CLI session; reauth of a row with a user and rotated-CLI recovery require that same user (a row without a user recovers only while no other row shares its profile, and explicit reauth may record its user unless that user owns another row of the profile), an unmatched login claims an identity-less active slot only as the same session while the profile has other rows, and Add account records the current CLI user on ARN-only rows through a fenced same-session bind. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second per-fetch deadline covering headers and body. Error bodies are capped at 16 KiB bytes and success JSON at 64 KiB; each settled call detaches its caller listener and clears its timer. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals require an explicit allowlisted terminal OAuth code and mark only the matching generation `needsReauth`; free-text descriptions never establish terminality; transient failures remain retryable. HTTP-derived errors expose only status and allowlisted code. Cancellation and deadline errors use fixed AbortError/TimeoutError messages; transport and parsing failures also use fixed messages, without raw reasons or causes, and never mark reauthentication. The registry preserves its own stale-flight error. `tests/oauth/chatgpt-token-expiry.test.ts`, `tests/oauth/chatgpt-token-boundaries.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. |
| `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. |
| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. |
| `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. Compaction parsing normalizes known incomplete aliases under the [incomplete reason contract](transports/responses-wire-shapes.md#adapter-incomplete-reasons). The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). |
Expand Down
30 changes: 30 additions & 0 deletions tests/providers/kiro/kiro-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,36 @@ describe("kiro oauth — import-first", () => {
expect(nested.accountId).toBe(arn);
});

test("the Profile block kiro-cli 2.32 prints after the whoami JSON does not hide the user", async () => {
const arn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/ORGPROFILE";
seedKiroCliDb({ access_token: "aoa-org", refresh_token: "rt-org" }, { stateArn: arn });
const identity = JSON.stringify({
accountType: "IamIdentityCenter", email: "Org.User@Example.com", region: "us-east-1", startUrl: "https://org.awsapps.com/start",
});
const runner = async (args: string[]) => {
if (args[0] === "whoami") return { exitCode: 0, stdout: `${identity}\n\nProfile:\nKiroProfile-us-east-1\n${arn}\n\n` };
throw new Error(`unexpected Kiro CLI command: ${args[0]}`);
};

const cred = await loginKiro({}, { cliRunner: runner });

expect(cred).toMatchObject({ accountId: arn, email: "org.user@example.com" });
});

test("a second whoami value on the same line leaves the identity unread", async () => {
seedKiroCliDb({ access_token: "aoa-two", refresh_token: "rt-two" });
const runner = async (args: string[]) => {
if (args[0] === "whoami") {
return { exitCode: 0, stdout: `${JSON.stringify({ email: "first@example.com" })}${JSON.stringify({ email: "second@example.com" })}\n` };
}
throw new Error(`unexpected Kiro CLI command: ${args[0]}`);
};

const cred = await loginKiro({}, { cliRunner: runner });

expect(cred.email).toBeUndefined();
});

test("a malformed whoami ARN is ignored without breaking the login (#993)", async () => {
seedKiroCliDb({ access_token: "aoa-bad", refresh_token: "rt-bad" });
const badRunner = async (args: string[]) => {
Expand Down
30 changes: 29 additions & 1 deletion tests/providers/kiro/kiro-org-user-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,15 @@ function seedSession(access: string, refresh: string, profileArn: string | null
function fakeCli(
whoamiByRefresh: Record<string, WhoamiFixture | "fail">,
nextLogin: Array<{ access: string; refresh: string; profileArn?: string }> = [],
whoamiTrailer = "",
): { runner: KiroCliRunner; calls: string[][] } {
const calls: string[][] = [];
const runner: KiroCliRunner = async args => {
calls.push(args);
if (args[0] === "whoami") {
const fixture = whoamiByRefresh[readKiroCliSqliteCredential()?.refresh ?? ""];
if (!fixture || fixture === "fail") return { exitCode: 1, stdout: "" };
return { exitCode: 0, stdout: JSON.stringify(WHOAMI[fixture]) };
return { exitCode: 0, stdout: JSON.stringify(WHOAMI[fixture]) + whoamiTrailer };
}
if (args[0] === "logout") {
removeKiroCliDb();
Expand Down Expand Up @@ -155,6 +156,33 @@ describe("Kiro organization users (#6641)", () => {
expect(getAccountSet("kiro")?.activeAccountId).toBe(bob.id);
});

// kiro-cli 2.32 prints the selected profile as text after the whoami JSON. Reading the whole
// stream as JSON lost both users' emails, so the second user's import overwrote the first.
const PROFILE_TRAILER = `\n\nProfile:\nKiroProfile-us-east-1\n${ORG_ARN}\n\n`;

test("an import after a CLI user switch keeps both users when whoami appends its Profile block", async () => {
seedSession("aoa-alice", "rt-alice");
const cli = fakeCli({ "rt-alice": "alice", "rt-bob": "bob" }, [], PROFILE_TRAILER);
await dashboardLogin(cli.runner);
seedSession("aoa-bob", "rt-bob");
await dashboardLogin(cli.runner);

const accounts = rows();
expect(accounts.map(account => account.credential.email).sort()).toEqual(["alice@org.test", "bob@org.test"]);
expect(accounts.find(account => account.credential.email === "alice@org.test")!.credential)
.toMatchObject({ access: "aoa-alice", refresh: "rt-alice", accountId: ORG_ARN });
});

test("Add account keeps both users when whoami appends its Profile block", async () => {
seedSession("aoa-alice", "rt-alice");
const cli = fakeCli({ "rt-alice": "alice", "rt-bob": "bob" }, [{ access: "aoa-bob", refresh: "rt-bob" }], PROFILE_TRAILER);
await dashboardLogin(cli.runner);
await dashboardLogin(cli.runner, true);

expect(rows().map(account => account.credential.email).sort()).toEqual(["alice@org.test", "bob@org.test"]);
expect(rows().map(account => account.credential.accountId)).toEqual([ORG_ARN, ORG_ARN]);
});

test("re-importing the same user updates that user's slot", async () => {
seedSession("aoa-alice", "rt-alice");
const cli = fakeCli(
Expand Down
Loading