From 798658399a9abeefb4f50781904b967b3c48b360 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:26:36 +0000 Subject: [PATCH 1/3] fix(kiro): support browser-based multi-account login Rebased onto current upstream dev and includes all review fixes through d972876. --- .../src/content/docs/guides/providers.md | 28 +- .../src/content/docs/ja/guides/providers.md | 19 +- .../src/content/docs/ko/guides/providers.md | 19 +- .../src/content/docs/ru/guides/providers.md | 19 +- .../content/docs/zh-cn/guides/providers.md | 19 +- src/adapters/kiro.ts | 4 +- src/oauth/index.ts | 153 +++-- src/oauth/kiro-credentials.ts | 230 ++++++- src/oauth/kiro.ts | 358 +++++++++-- src/oauth/store.ts | 32 +- src/oauth/types.ts | 11 + src/providers/registry.ts | 2 +- src/server/responses/core.ts | 8 + src/types.ts | 4 + tests/kiro-adapter.test.ts | 79 ++- tests/kiro-oauth.test.ts | 592 +++++++++++++++++- tests/kiro-review-regressions.test.ts | 211 +++++++ tests/oauth-reauth-bind.test.ts | 87 ++- tests/oauth-refresh.test.ts | 192 +++++- tests/oauth-store-multi.test.ts | 30 +- tests/server-kiro-oauth-401-replay.test.ts | 101 +++ 21 files changed, 2057 insertions(+), 141 deletions(-) create mode 100644 tests/kiro-review-regressions.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8314f0ddcf..185c3c7dd1 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -85,7 +85,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Import-first login reuses the installed `kiro-cli` session — requires the Kiro CLI installed (`curl -fsSL https://cli.kiro.dev/install | bash`) and signed in via `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (install with `curl -fsSL https://cli.kiro.dev/install | bash`, then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | @@ -96,8 +96,9 @@ You can also start OAuth from the [web dashboard](/guides/web-dashboard/). OAuth providers whose credentials include a stable account id or email can keep more than one login. The Providers page shows those accounts in a dropdown, lets you add another, and switches the -active account without logging the others out. Identity-less Kimi and Kiro credentials replace their -active slot, while `chatgpt` is always single-slot because Codex pool accounts have a separate ledger. +active account without logging the others out. Only identity-less Kimi credentials replace the +active slot; Kiro accounts are keyed by profile ARN. `chatgpt` is always single-slot because Codex +pool accounts have a separate ledger. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. ### OAuth reliability @@ -143,20 +144,33 @@ and WARN rows that include a recovery Action. When an OAuth provider account nee ### Kiro credential import Kiro login expects the Kiro CLI: install it (`curl -fsSL https://cli.kiro.dev/install | bash`) -and sign in with `kiro-cli login` first. Without a kiro-cli session, `ocx login kiro` falls +and sign in with `kiro-cli login` first. Without a `kiro-cli` session, `ocx login kiro` falls back to a pasted access token or the `KIRO_ACCESS_TOKEN` environment variable. -`ocx login kiro` searches the platform Kiro CLI stores and opens SQLite databases read-only. Two -environment variables make selection explicit without copying credentials into opencodex: +The `ocx login kiro` import path searches the platform Kiro CLI stores and opens SQLite databases +read-only. Two environment variables make the source and token row selection explicit: - `KIROCLI_DB_PATH` selects a nonstandard Kiro CLI SQLite database. The path must already exist; - opencodex does not create it or modify the database, WAL, or SHM files. + during this import path, opencodex does not create or modify the database, WAL, or SHM files. - `KIROCLI_TOKEN_KEY` selects the exact `auth_kv` token key when a database contains multiple otherwise ambiguous token rows. A missing selection fails login instead of guessing. +After a successful import, opencodex persists the imported credential to +`~/.opencodex/auth.json`. + Keep these variables and the selected database private. Do not attach database files or raw login diagnostics to bug reports. +**Add account** is a separate write workflow: it snapshots the current session, logs `kiro-cli` out, +and imports the fresh browser login. If the login is cancelled or fails, including while OpenCodex +persists the credential, rollback replaces the Kiro CLI database and removes its current WAL, SHM, +and journal sidecars before publishing the previous session snapshot. + +Because that rollback is only possible from a snapshot, **Add account** refuses to sign `kiro-cli` +out when a session store is present but cannot be captured (unreadable file, mismatched schema, or +an ambiguous token selection). Resolve the local store first, then retry. Signing in from a machine +with no existing `kiro-cli` session is unaffected. + ## 3. API-key catalog opencodex ships 53 built-in presets: 42 key-based, seven OAuth, three local, and the default diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f1133444ce..3806965a28 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -80,7 +80,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | インストール済み `kiro-cli` ログインを優先取得。Kiro CLI のインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)と `kiro-cli login` が必要。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは Kiro CLI をインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)し、`kiro-cli login` でサインインした既存セッションを取り込みます。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | @@ -90,10 +90,23 @@ ocx logout 認証情報に固定アカウント ID やメールがある OAuth プロバイダーはログインを複数保持できます。 Providers ページでアカウントを追加し、別アカウントをログアウトせずにアクティブアカウントだけを切り替えられます。 -アカウント識別情報がない Kimi と Kiro はアクティブスロットを差し替え、`chatgpt` は Codex アカウントプールに別の保存場所が -あり常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 +アカウント識別情報がない Kimi 認証情報だけがアクティブスロットを差し替え、Kiro アカウントはプロファイル ARN をキーに保存されます。 +`chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 `/api/oauth/accounts` はマスク済みメタデータのみを返します。 +### Kiro 認証情報の取り込み + +Kiro のログインには Kiro CLI が必要です。`curl -fsSL https://cli.kiro.dev/install | bash` でインストールし、先に `kiro-cli login` でサインインしてください。`kiro-cli` セッションがない場合、`ocx login kiro` は貼り付けたアクセストークンまたは `KIRO_ACCESS_TOKEN` 環境変数にフォールバックします。 + +通常の `ocx login kiro` 取り込みは CLI の SQLite データベースを読み取り専用で開き、データベース、WAL、SHM を変更しません。 + +- `KIROCLI_DB_PATH` は標準外の Kiro CLI SQLite データベースを選択します。指定するデータベースは既に存在している必要があります。 +- `KIROCLI_TOKEN_KEY` は複数の曖昧なトークン行がある場合に、取り込む正確な `auth_kv` 行のキーを指定します。選択がない場合、推測せずログインに失敗します。 + +取り込んだ認証情報は `~/.opencodex/auth.json` に保存されます。**アカウントを追加**のロールバックは別処理で、以前のスナップショットを復元する際にデータベースを置き換え、現在の WAL、SHM、journal サイドカーを削除します。 + +ロールバックはスナップショットがある場合にのみ可能なため、セッションストアが存在するのに取得できない場合(ファイルが読めない、スキーマの不一致、トークン選択があいまい)、**アカウントを追加**は `kiro-cli` のログアウトを拒否します。まずローカルストアを解決してから再試行してください。既存の `kiro-cli` セッションがまったくない環境には影響しません。 + ## 3. API キーカタログ opencodex v2.7.1 には組み込みプリセットが 50 個含まれています。キー方式 40、OAuth 6、ローカル 3、 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index c2a3f2480d..927a0a9762 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -80,7 +80,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 설치된 `kiro-cli` 로그인을 먼저 가져옵니다. Kiro CLI 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)와 `kiro-cli login`이 필요합니다. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 Kiro CLI를 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)하고 `kiro-cli login`으로 로그인한 기존 세션을 가져옵니다. **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | @@ -90,10 +90,23 @@ ocx logout 자격 증명에 고정된 계정 id나 이메일이 있는 OAuth 프로바이더는 로그인을 여러 개 보관할 수 있습니다. Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃하지 않은 채 활성 계정만 바꿀 수 있습니다. -계정 식별 정보가 없는 Kimi와 Kiro는 활성 슬롯을 교체하며, `chatgpt`는 Codex 계정 풀에 별도 저장소가 -있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, +계정 식별 정보가 없는 Kimi 자격 증명만 활성 슬롯을 교체하며, Kiro 계정은 프로필 ARN을 키로 저장됩니다. +`chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, `/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다. +### Kiro 자격 증명 가져오기 + +Kiro 로그인에는 Kiro CLI가 필요합니다. `curl -fsSL https://cli.kiro.dev/install | bash`로 설치하고 먼저 `kiro-cli login`으로 로그인하세요. `kiro-cli` 세션이 없으면 `ocx login kiro`는 붙여 넣은 액세스 토큰이나 `KIRO_ACCESS_TOKEN` 환경 변수로 폴백합니다. + +일반 `ocx login kiro` 가져오기는 CLI SQLite 데이터베이스를 읽기 전용으로 열며 데이터베이스, WAL, SHM을 수정하지 않습니다. + +- `KIROCLI_DB_PATH`는 비표준 Kiro CLI SQLite 데이터베이스를 선택하며, 지정한 데이터베이스는 이미 존재해야 합니다. +- `KIROCLI_TOKEN_KEY`는 모호한 토큰 행이 여러 개일 때 가져올 정확한 `auth_kv` 행의 키를 선택합니다. 선택값이 없으면 추측하지 않고 로그인이 실패합니다. + +가져온 자격 증명은 `~/.opencodex/auth.json`에 저장됩니다. **계정 추가** 롤백은 별도 절차로, 이전 스냅샷을 복원할 때 데이터베이스를 교체하고 현재 WAL, SHM, journal 사이드카를 제거합니다. + +롤백은 스냅샷이 있을 때만 가능하므로, 세션 저장소가 존재하지만 캡처할 수 없는 경우(파일을 읽을 수 없음, 스키마 불일치, 토큰 선택 모호) **계정 추가**는 `kiro-cli` 로그아웃을 거부합니다. 로컬 저장소를 먼저 정리한 뒤 다시 시도하세요. 기존 `kiro-cli` 세션이 아예 없는 환경에서는 영향이 없습니다. + ## 3. API 키 카탈로그 opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방식 40개, OAuth 6개, 로컬 3개, diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index bd8985942a..ae4729df22 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -88,7 +88,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Вход сначала импортирует и переиспользует сессию установленного `kiro-cli`. Требуется установленный Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) и вход через `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) и входа через `kiro-cli login`. **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | @@ -100,10 +100,23 @@ OAuth можно запустить и из [веб-дашборда](/ru/guides OAuth-провайдеры, чьи учётные данные содержат стабильный id аккаунта или email, могут хранить несколько входов. Страница Providers показывает эти аккаунты в выпадающем списке, позволяет добавить ещё один и переключает активный аккаунт, не выполняя выход из остальных. Учётные данные -Kimi и Kiro без идентификатора заменяют свой активный слот, а `chatgpt` всегда занимает один слот, -поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; +Только учётные данные Kimi без идентификатора заменяют активный слот; аккаунты Kiro сохраняются по ARN профиля. +`chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; `/api/oauth/accounts` возвращает только маскированные метаданные. +### Импорт учётных данных Kiro + +Для входа Kiro требуется Kiro CLI: установите его командой `curl -fsSL https://cli.kiro.dev/install | bash` и сначала выполните `kiro-cli login`. Если сессии `kiro-cli` нет, `ocx login kiro` использует вставленный токен доступа или переменную окружения `KIRO_ACCESS_TOKEN`. + +Обычный импорт `ocx login kiro` открывает базу SQLite CLI только для чтения и не изменяет базу, WAL или SHM. + +- `KIROCLI_DB_PATH` выбирает нестандартную базу SQLite Kiro CLI; указанная база должна уже существовать. +- `KIROCLI_TOKEN_KEY` выбирает точный ключ строки `auth_kv`, если найдено несколько неоднозначных строк с токенами. Без выбора вход завершается ошибкой, а не пытается угадать строку. + +Импортированные учётные данные сохраняются в `~/.opencodex/auth.json`. Откат **Добавить аккаунт** — отдельная операция: при восстановлении предыдущего снимка она заменяет базу и удаляет текущие sidecar-файлы WAL, SHM и journal. + +Поскольку откат возможен только при наличии снимка, **Добавить аккаунт** откажется выходить из `kiro-cli`, если хранилище сессии существует, но его нельзя захватить (файл не читается, несовпадение схемы, неоднозначный выбор токена). Сначала устраните проблему с локальным хранилищем, затем повторите попытку. На машины без существующей сессии `kiro-cli` это не влияет. + ## 3. Каталог API-ключей opencodex поставляется с 53 встроенными пресетами: 42 на основе ключей, семь OAuth, три локальных и diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 93caa2a631..92f90d17b6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -74,7 +74,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 优先复用已安装的 `kiro-cli` 登录。需先安装 Kiro CLI(`curl -fsSL https://cli.kiro.dev/install | bash`)并执行 `kiro-cli login`。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装,然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | @@ -83,10 +83,23 @@ ocx logout ### 多个 OAuth 账号 OAuth 凭据中带有稳定账号 id 或邮箱的提供商可以保存多个登录。Providers 页面会在下拉列表中显示这些 -账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。没有身份信息的 Kimi 和 Kiro 会替换 -当前 active slot;`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 +账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。只有没有身份信息的 Kimi 凭据会替换 +当前 active slot;Kiro 账户以配置文件 ARN 为键。`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 `~/.opencodex/auth.json` 中;`/api/oauth/accounts` 只返回脱敏后的 metadata。 +### Kiro 凭据导入 + +Kiro 登录需要 Kiro CLI:使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装,并先运行 `kiro-cli login`。如果没有 `kiro-cli` 会话,`ocx login kiro` 会回退到粘贴的访问令牌或 `KIRO_ACCESS_TOKEN` 环境变量。 + +普通的 `ocx login kiro` 导入会以只读方式打开 CLI SQLite 数据库,不修改数据库、WAL 或 SHM。 + +- `KIROCLI_DB_PATH` 用于选择非标准位置的 Kiro CLI SQLite 数据库;指定的数据库必须已经存在。 +- `KIROCLI_TOKEN_KEY` 在存在多个含糊的令牌行时选择确切的 `auth_kv` 行键。缺少选择值时,登录会失败而不会猜测。 + +导入的凭据会保存到 `~/.opencodex/auth.json`。**添加账户**的回滚是独立流程:恢复之前的快照时会替换数据库,并删除当前的 WAL、SHM 和 journal 边车文件。 + +由于回滚依赖快照,当会话存储已存在但无法捕获时(文件不可读、架构不匹配、令牌选择有歧义),**添加账户**会拒绝将 `kiro-cli` 登出。请先修复本地存储,然后重试。对于完全没有现有 `kiro-cli` 会话的机器,不受影响。 + ## 3. API 密钥目录 opencodex v2.7.1 内置 50 个预设:40 个密钥预设、6 个 OAuth 预设、3 个本地预设,以及默认的 diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 117123bcf7..8d022c2d74 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -1445,8 +1445,8 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { throw new Error("kiro token missing — run ocx login kiro"); } - const region = resolveKiroApiRegion(); - const profileArn = resolveKiroProfileArn(); + const region = resolveKiroApiRegion(parsed._kiroAuthContext); + const profileArn = resolveKiroProfileArn(parsed._kiroAuthContext); const fp = fingerprint().slice(0, 64); const headers: Record = { authorization: `Bearer ${provider.apiKey}`, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index da6fbc8583..97ded7624a 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,13 +1,14 @@ -import type { OAuthController, OAuthCredentials } from "./types"; +import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { loadConfig, resolveEnvValue, saveConfig } from "../config"; import { maskEmail } from "../lib/privacy"; -import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; +import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai"; import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; -import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro"; +import { KiroTokenRefreshError, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; +import { requireKiroRegion } from "./kiro-credentials"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; @@ -45,6 +46,8 @@ export interface OAuthAccessSnapshot { accountId: string; generation: string; accessToken: string; + /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */ + kiro?: Pick; } const tokenRefreshes = new Map>(); @@ -60,7 +63,11 @@ export interface LoginOpts { forceLogin?: boolean; /** When set, persist into th interface OAuthProviderDef { login(ctrl: OAuthController, opts?: LoginOpts): Promise; - refresh(refreshToken: string, signal?: AbortSignal): Promise; + refresh( + refreshToken: string, + signal?: AbortSignal, + credential?: OAuthCredentials, + ): Promise; /** provider entry written into config.json on first login. */ providerConfig: OcxProviderConfig; defaultModel: string; @@ -108,8 +115,8 @@ export const OAUTH_PROVIDERS: Record = { defaultModel: oauthDefaultModel("kimi"), }, kiro: { - login: (ctrl) => loginKiro(ctrl), - refresh: (rt, signal) => refreshKiroToken(rt, signal), + login: (ctrl, opts) => loginKiro(ctrl, { forceLogin: opts?.forceLogin }), + refresh: (rt, signal, credential) => refreshKiroToken(rt, signal, credential), providerConfig: oauthConfig("kiro"), defaultModel: oauthDefaultModel("kiro"), }, @@ -194,12 +201,42 @@ export class OAuthLoginRequiredError extends Error { } } +function kiroEnvironmentRoutingMetadata(): Pick | undefined { + const profileArn = process.env.KIRO_PROFILE_ARN?.trim() || undefined; + const apiRegion = process.env.KIRO_API_REGION !== undefined + ? requireKiroRegion(process.env.KIRO_API_REGION) + : undefined; + const ssoRegion = process.env.KIRO_REGION !== undefined + ? requireKiroRegion(process.env.KIRO_REGION) + : undefined; + if (!profileArn && !apiRegion && !ssoRegion) return undefined; + return { + ...(profileArn ? { profileArn } : {}), + ...(apiRegion ? { apiRegion } : {}), + ...(ssoRegion ? { ssoRegion } : {}), + }; +} + function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { + const storedKiroRouting = { + ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}), + ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}), + ...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}), + }; return { provider, accountId, generation: credentialGeneration(cred), accessToken: cred.access, + // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials + // may use explicit environment routing, but never borrow the currently signed-in local CLI account. + ...(provider === "kiro" + ? { + kiro: Object.keys(storedKiroRouting).length > 0 + ? storedKiroRouting + : kiroEnvironmentRoutingMetadata() ?? {}, + } + : {}), }; } @@ -268,12 +305,6 @@ export async function getValidAccessTokenForAccount(provider: string, accountId: return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken; } -function readFreshKiroCliCredential(): OAuthCredentials | undefined { - const imported = readKiroCliSqlite(); - if (!imported || imported.expires <= Date.now() + REFRESH_SKEW_MS) return undefined; - return { access: imported.access, refresh: imported.refresh, expires: imported.expires, source: "local-cli" }; -} - /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */ function isTerminalRefreshError(err: unknown): boolean { const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); @@ -287,6 +318,7 @@ function isTerminalRefreshError(err: unknown): boolean { function terminal(error:unknown):boolean{ if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""); if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??""); + if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined; return isTerminalRefreshError(error); } function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;} @@ -298,6 +330,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede ...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}), ...(fresh.email === undefined && previous.email ? { email: previous.email } : {}), ...(fresh.accountId === undefined && previous.accountId ? { accountId: previous.accountId } : {}), + ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}), }; } export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}} @@ -396,7 +429,7 @@ export async function refreshGenericAccountWithLock( } const generation = credentialGeneration(stored); try { - const fresh = merged(await def.refresh(stored.refresh), stored); + const fresh = merged(await def.refresh(stored.refresh, undefined, stored), stored); const outcome = await mergeAccountCredential(provider, accountId, fresh, { expectedGeneration: generation, afterPrePersistRead: deps.afterPrePersistRead, @@ -408,7 +441,7 @@ export async function refreshGenericAccountWithLock( logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId }); return fresh.access; } catch (error) { - if (!isTerminalRefreshError(error)) throw error; + if (!terminal(error)) throw error; await markAccountNeedsReauthIfGeneration(provider, accountId, generation); throw new OAuthLoginRequiredError(provider); } @@ -423,30 +456,9 @@ async function refreshAndPersistAccessToken( def: OAuthProviderDef, cred: OAuthCredentials, ): Promise { - // Local-CLI import fallback only for the ACTIVE account: importing another identity's - // token under a background account id would silently contaminate that account. - const isActive = getAccountSet(provider)?.activeAccountId === accountId; - if (provider === "kiro" && isActive) { - const imported = readFreshKiroCliCredential(); - if (imported) { - await saveCredential(provider, imported); - return imported.access; - } - } if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred); if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred); - try { - return await refreshGenericAccountWithLock(provider, accountId, def, cred); - } catch (err) { - if (provider === "kiro" && isActive) { - const imported = readFreshKiroCliCredential(); - if (imported) { - await saveCredential(provider, imported); - return imported.access; - } - } - throw err; - } + return refreshGenericAccountWithLock(provider, accountId, def, cred); } /** @@ -646,27 +658,66 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { config.providers[provider] = next; } +interface RunLoginDeps { + saveCredential?: typeof saveCredential; + saveAccountCredential?: typeof saveAccountCredential; + loadConfig?: typeof loadConfig; + saveConfig?: typeof saveConfig; + settleKiroLoginTransaction?: typeof settleKiroLoginTransaction; +} + /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */ -export async function runLogin(provider: string, ctrl: OAuthController, opts?: LoginOpts): Promise { +export async function runLogin( + provider: string, + ctrl: OAuthController, + opts?: LoginOpts, + deps: RunLoginDeps = {}, +): Promise { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); + // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object + // for settlement even when source normalization below creates a derived credential object. const rawCred = await def.login(ctrl, opts); const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; - if (opts?.reauthAccountId) { - const existing = getAccountCredential(provider, opts.reauthAccountId); - if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); - const expected = existing.accountId ?? existing.email; - const got = cred.accountId ?? cred.email; - if (!expected) { - throw new Error("Could not verify signed-in account identity for reauth."); + const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; + try { + if (opts?.reauthAccountId) { + const existing = getAccountCredential(provider, opts.reauthAccountId); + if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); + if (!existing.accountId && !existing.email) { + throw new Error("Could not verify signed-in account identity for reauth."); + } + const identityMatches = existing.accountId && cred.accountId + ? existing.accountId === cred.accountId + : existing.email && cred.email + ? existing.email.toLowerCase() === cred.email.toLowerCase() + : false; + if (!identityMatches) { + throw new Error("Signed-in account does not match the selected account. Sign in with the same account."); + } + await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred); + } else { + await (deps.saveCredential ?? saveCredential)(provider, cred, { + preserveIdentityless: provider === "kiro" && opts?.forceLogin === true, + }); } - if (!got || expected !== got) { - throw new Error("Signed-in account does not match the selected account. Sign in with the same account."); + if (provider !== "chatgpt") { + const config = (deps.loadConfig ?? loadConfig)(); + upsertOAuthProvider(config, provider); + (deps.saveConfig ?? saveConfig)(config); } - await saveAccountCredential(provider, opts.reauthAccountId, cred); - } else { - await saveCredential(provider, cred); + } catch (error) { + try { + settleKiroTransaction(rawCred, false); + } catch (restoreError) { + throw new AggregateError( + [error, restoreError], + "Kiro login persistence failed and the previous Kiro CLI session could not be restored.", + ); + } + throw error; } + settleKiroTransaction(rawCred, true); if (provider !== "chatgpt") { try { const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota"); @@ -676,10 +727,6 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L // Quota module may be unavailable in tightly scoped unit tests. } } - if (provider === "chatgpt") return cred; - const config = loadConfig(); - upsertOAuthProvider(config, provider); - saveConfig(config); return cred; } diff --git a/src/oauth/kiro-credentials.ts b/src/oauth/kiro-credentials.ts index 8807856841..71bb594605 100644 --- a/src/oauth/kiro-credentials.ts +++ b/src/oauth/kiro-credentials.ts @@ -1,9 +1,16 @@ -import { existsSync, readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { Database } from "bun:sqlite"; const DEFAULT_EXPIRES_MS = 3600_000; +const KIRO_CLI_RECOVERY_SUFFIX = ".opencodex-recovery"; +const KIRO_CLI_RECOVERY_HEADER_V1 = Buffer.from("opencodex-kiro-session-v1\n", "utf8"); +const KIRO_CLI_RECOVERY_HEADER = Buffer.from("opencodex-kiro-session-v2\n", "utf8"); +const KIRO_CLI_RECOVERY_PROCESS_INSTANCE = randomUUID(); +const KIRO_CLI_RECOVERY_PROCESS_INSTANCE_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; +const SQLITE_DATABASE_HEADER = Buffer.from("SQLite format 3\0", "binary"); const KIRO_REGION_PATTERN = /^[a-z]{2}(?:-[a-z]+)+-\d$/; const CLIENT_ID_HASH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const TOKEN_KEYS = [ @@ -49,6 +56,13 @@ export interface ImportedKiroCredential { clientSecret?: string; } +/** Opaque backup retained on disk while a forced Kiro CLI login is pending persistence. */ +export interface KiroCliSessionSnapshot { + readonly path: string; + readonly database: Buffer; + readonly recoveryPath: string; +} + type JsonObject = Record; function userHome(): string { @@ -210,7 +224,16 @@ function readStateProfile(db: Database): { profileArn?: string; apiRegion?: stri } } -function readSqliteCredentials(diagnostics: KiroImportDiagnostic[]): ImportedKiroCredential | undefined { +interface LocatedKiroCliCredential { + credential: ImportedKiroCredential; + path: string; + database?: Buffer; +} + +function readSqliteCredentials( + diagnostics: KiroImportDiagnostic[], + includeSnapshot = false, +): LocatedKiroCliCredential | undefined { for (const { location, path } of sqliteEntries()) { if (!existsSync(path)) { diagnostics.push({ location, status: "missing" }); @@ -261,7 +284,13 @@ function readSqliteCredentials(diagnostics: KiroImportDiagnostic[]): ImportedKir const merged = { ...registrationData, ...tokenData, ...profile }; const credential = credentialFromJson(merged, "sqlite"); diagnostics.push({ location, status: credential ? "token_found" : "token_missing" }); - if (credential) return credential; + if (credential) { + return { + credential, + path, + ...(includeSnapshot ? { database: db.serialize() } : {}), + }; + } } catch (error) { if (error instanceof Error && error.message.includes("KIROCLI_TOKEN_KEY")) throw error; diagnostics.push({ location, status: "schema_mismatch" }); @@ -277,12 +306,12 @@ export function inspectKiroCredentialSources(): { credential: ImportedKiroCreden const json = readJsonCredentials(diagnostics); if (json) return { credential: json, diagnostics }; const sqlite = readSqliteCredentials(diagnostics); - return { credential: sqlite ?? null, diagnostics }; + return { credential: sqlite?.credential ?? null, diagnostics }; } export function inspectKiroCliSqliteSources(): { credential: ImportedKiroCredential | null; diagnostics: KiroImportDiagnostic[] } { const diagnostics: KiroImportDiagnostic[] = []; - return { credential: readSqliteCredentials(diagnostics) ?? null, diagnostics }; + return { credential: readSqliteCredentials(diagnostics)?.credential ?? null, diagnostics }; } export function readImportedKiroCredential(): ImportedKiroCredential | null { @@ -292,3 +321,194 @@ export function readImportedKiroCredential(): ImportedKiroCredential | null { export function readKiroCliSqliteCredential(): ImportedKiroCredential | null { return inspectKiroCliSqliteSources().credential; } + +/** Capture the complete active CLI database so a failed account switch can restore it exactly. */ +export function snapshotKiroCliSession(): KiroCliSessionSnapshot | null { + return inspectKiroCliSessionSnapshot().snapshot; +} + +/** + * A store that exists but cannot be captured must never be logged out of: the recovery contract + * promises an exact restore, and without a snapshot a later failure would destroy the session for + * good. `missing`/`token_missing` are the only statuses that mean "there is nothing to lose". + */ +const KIRO_UNSNAPSHOTTABLE_SESSION_STATUSES: ReadonlySet = new Set([ + "unreadable", + "schema_mismatch", + "invalid_json", + "token_ambiguous", + "token_key_missing", + "token_found", +]); + +/** + * Capture the active CLI session and report why capture failed. `blocked` is true when a session + * store is present but could not be snapshotted (unreadable / schema-mismatched / ambiguous), so + * callers can abort before mutating it. + */ +export function inspectKiroCliSessionSnapshot(): { + snapshot: KiroCliSessionSnapshot | null; + diagnostics: KiroImportDiagnostic[]; + blocked: boolean; +} { + const diagnostics: KiroImportDiagnostic[] = []; + const located = readSqliteCredentials(diagnostics, true); + if (located?.database) { + return { + snapshot: { + path: located.path, + database: located.database, + recoveryPath: `${located.path}${KIRO_CLI_RECOVERY_SUFFIX}`, + }, + diagnostics, + blocked: false, + }; + } + return { + snapshot: null, + diagnostics, + blocked: diagnostics.some(entry => KIRO_UNSNAPSHOTTABLE_SESSION_STATUSES.has(entry.status)), + }; +} + +/** Persist a complete, private recovery image before kiro-cli is allowed to mutate its store. */ +export function persistKiroCliSessionRecovery(snapshot: KiroCliSessionSnapshot): void { + if (existsSync(snapshot.recoveryPath)) { + throw new Error("Kiro CLI session recovery is already pending."); + } + const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + const staged = `${snapshot.recoveryPath}.${nonce}.tmp`; + try { + writeFileSync(staged, Buffer.concat([ + KIRO_CLI_RECOVERY_HEADER, + Buffer.from(`${process.pid}\n`, "utf8"), + Buffer.from(`${KIRO_CLI_RECOVERY_PROCESS_INSTANCE}\n`, "utf8"), + snapshot.database, + ]), { flag: "wx", mode: 0o600 }); + try { chmodSync(staged, 0o600); } catch { /* platform may ignore chmod */ } + const fd = openSync(staged, "r+"); + try { fsyncSync(fd); } finally { closeSync(fd); } + // A hard link publishes the fsynced inode atomically and, unlike rename, can never replace + // another process's live recovery transaction if both raced past the initial existence check. + linkSync(staged, snapshot.recoveryPath); + } finally { + rmSync(staged, { force: true }); + } +} + +/** Remove recovery data only after the corresponding login transaction has settled. */ +export function discardKiroCliSessionRecovery(snapshot: KiroCliSessionSnapshot): void { + rmSync(snapshot.recoveryPath, { force: true }); +} + +interface ParsedKiroCliSessionRecovery { + ownerPid: number; + ownerProcessInstance?: string; + database: Buffer; +} + +function parseKiroCliSessionRecovery(payload: Buffer): ParsedKiroCliSessionRecovery | null { + const isV2 = payload.subarray(0, KIRO_CLI_RECOVERY_HEADER.length).equals(KIRO_CLI_RECOVERY_HEADER); + const isV1 = payload.subarray(0, KIRO_CLI_RECOVERY_HEADER_V1.length).equals(KIRO_CLI_RECOVERY_HEADER_V1); + if (!isV2 && !isV1) return null; + const headerLength = isV2 ? KIRO_CLI_RECOVERY_HEADER.length : KIRO_CLI_RECOVERY_HEADER_V1.length; + const ownerEnd = payload.indexOf(0x0a, headerLength); + if (ownerEnd <= headerLength) return null; + const ownerPid = Number(payload.subarray(headerLength, ownerEnd).toString("utf8")); + let databaseStart = ownerEnd + 1; + let ownerProcessInstance: string | undefined; + if (isV2) { + const instanceEnd = payload.indexOf(0x0a, databaseStart); + if (instanceEnd <= databaseStart) return null; + ownerProcessInstance = payload.subarray(databaseStart, instanceEnd).toString("utf8"); + if (!KIRO_CLI_RECOVERY_PROCESS_INSTANCE_PATTERN.test(ownerProcessInstance)) return null; + databaseStart = instanceEnd + 1; + } + const database = payload.subarray(databaseStart); + if ( + !Number.isSafeInteger(ownerPid) || ownerPid <= 0 || + !database.subarray(0, SQLITE_DATABASE_HEADER.length).equals(SQLITE_DATABASE_HEADER) + ) { + return null; + } + return { ownerPid, ...(ownerProcessInstance ? { ownerProcessInstance } : {}), database }; +} + +function isKiroRecoveryOwnerAlive(ownerPid: number, ownerProcessInstance?: string): boolean { + // A restarted supervisor may reuse the exact same PID (commonly PID 1). Only this process + // instance's nonce proves that a same-PID recovery transaction is still live. + if (ownerPid === process.pid) return ownerProcessInstance === KIRO_CLI_RECOVERY_PROCESS_INSTANCE; + try { + process.kill(ownerPid, 0); + return true; + } catch (error) { + if (error && typeof error === "object" && "code" in error) return error.code !== "ESRCH"; + return true; + } +} + +/** Restore a previously captured CLI database after every kiro-cli child process has exited. */ +export function restoreKiroCliSession(snapshot: KiroCliSessionSnapshot): void { + const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + const staged = `${snapshot.path}.ocx-restore.${nonce}.tmp`; + const displacedBase = `${snapshot.path}.ocx-restore.${nonce}.new`; + const displaced: Array<{ current: string; backup: string }> = []; + let published = false; + try { + writeFileSync(staged, snapshot.database, { mode: 0o600 }); + try { chmodSync(staged, 0o600); } catch { /* platform may ignore chmod */ } + for (const suffix of ["", "-wal", "-shm", "-journal"]) { + const current = `${snapshot.path}${suffix}`; + if (!existsSync(current)) continue; + const backup = `${displacedBase}${suffix || ".db"}`; + renameSync(current, backup); + displaced.push({ current, backup }); + } + renameSync(staged, snapshot.path); + published = true; + } catch (error) { + const recoveryErrors: unknown[] = []; + for (const { current, backup } of [...displaced].reverse()) { + if (!existsSync(backup) || existsSync(current)) continue; + try { renameSync(backup, current); } catch (recoveryError) { recoveryErrors.push(recoveryError); } + } + if (recoveryErrors.length > 0) throw new AggregateError([error, ...recoveryErrors], "Kiro CLI session restore failed during rollback."); + throw error; + } finally { + rmSync(staged, { force: true }); + if (published) { + for (const { backup } of displaced) { + try { rmSync(backup, { force: true }); } catch { /* restored database is already published */ } + } + } + } +} + +/** Restore a transaction abandoned by a crashed process before starting another forced login. */ +export function restoreStaleKiroCliSessionRecovery(): boolean { + for (const { path } of sqliteEntries()) { + const recoveryPath = `${path}${KIRO_CLI_RECOVERY_SUFFIX}`; + if (!existsSync(recoveryPath)) continue; + const payload = readFileSync(recoveryPath); + const recovery = parseKiroCliSessionRecovery(payload); + if (!recovery) { + throw new Error( + `Kiro CLI session recovery data is invalid: ${recoveryPath}. Remove this file to continue.`, + ); + } + if (isKiroRecoveryOwnerAlive(recovery.ownerPid, recovery.ownerProcessInstance)) { + throw new Error( + `Another Kiro CLI login transaction is still in progress (pid ${recovery.ownerPid}, ${recoveryPath}).`, + ); + } + const snapshot: KiroCliSessionSnapshot = { + path, + database: recovery.database, + recoveryPath, + }; + restoreKiroCliSession(snapshot); + discardKiroCliSessionRecovery(snapshot); + return true; + } + return false; +} diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index 4a9b8b9689..fff7de996a 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -1,29 +1,43 @@ /** * Kiro (AWS CodeWhisperer) OAuth — import-first. * - * Unlike browser/PKCE providers, kiro reuses the locally installed kiro-cli login: - * it reads the kiro-cli SQLite token store, falls back to KIRO_ACCESS_TOKEN env, then to a - * manual access-token paste (CLI only). Refresh hits the Kiro desktop refresh endpoint. + * Normal login imports the locally installed kiro-cli session. Account-add login deliberately + * asks kiro-cli to switch identities in its supported browser flow, then imports that fresh session. * * Ported from jawcode packages/ai/src/providers/kiro.ts (readKiroCliSqlite, refreshKiroDesktopToken). - * profileArn/region are NOT stored in the credential — the kiro ADAPTER resolves them at request - * time (SQLite profile_arn / KIRO_PROFILE_ARN, KIRO_REGION) since getValidAccessToken surfaces - * only the access token. + * profileArn/region/client registration are persisted per OCX account so switching the account pool + * never combines one account's access token with another account's local Kiro profile metadata. */ -import type { OAuthController, OAuthCredentials } from "./types"; +import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { + discardKiroCliSessionRecovery, inferRegionFromProfileArn, inspectKiroCliSqliteSources, + inspectKiroCliSessionSnapshot, normalizeKiroRegion, + persistKiroCliSessionRecovery, readImportedKiroCredential, readKiroCliSqliteCredential, + restoreKiroCliSession, + restoreStaleKiroCliSessionRecovery, requireKiroRegion, + type ImportedKiroCredential, + type KiroCliSessionSnapshot, type KiroImportDiagnostic, } from "./kiro-credentials"; const DEFAULT_REGION = "us-east-1"; const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken"; const OIDC_URL = "https://oidc.{region}.amazonaws.com/token"; +const KIRO_TERMINAL_REFRESH_ERRORS = new Set([ + "invalid_grant", + "refresh_token_reused", + "revoked", + "revoked_token", + "refresh_token_revoked", + "access_denied", + "expired_token", +]); interface ImportedKiroToken { access: string; @@ -31,6 +45,128 @@ interface ImportedKiroToken { expires: number; } +export interface KiroCliCommandResult { + exitCode: number; + stdout: string; +} + +export class KiroTokenRefreshError extends Error { + constructor( + readonly httpStatus: number, + readonly oauthError?: string, + ) { + super(`Kiro token refresh failed: ${httpStatus}${oauthError ? ` (${oauthError})` : ""}`); + this.name = "KiroTokenRefreshError"; + } +} + +export type KiroCliRunner = (args: string[], signal?: AbortSignal) => Promise; + +export interface KiroLoginOptions { + forceLogin?: boolean; + cliRunner?: KiroCliRunner; +} + +const pendingKiroLoginTransactions = new WeakMap(); + +/** Settle the external CLI side of a forced login after OCX credential persistence resolves. */ +export function settleKiroLoginTransaction(credential: OAuthCredentials, persisted: boolean): void { + const snapshot = pendingKiroLoginTransactions.get(credential); + if (!snapshot) return; + if (!persisted) restoreKiroCliSession(snapshot); + discardKiroCliSessionRecovery(snapshot); + pendingKiroLoginTransactions.delete(credential); +} + +function restoreKiroLoginOrThrow(snapshot: KiroCliSessionSnapshot | null, cause: unknown): never { + if (snapshot) { + try { + restoreKiroCliSession(snapshot); + discardKiroCliSessionRecovery(snapshot); + } catch (restoreError) { + throw new AggregateError( + [cause, restoreError], + "Kiro login failed and the previous Kiro CLI session could not be restored.", + ); + } + } + throw cause; +} + +function throwIfKiroLoginCancelled(signal?: AbortSignal): void { + if (signal?.aborted) throw new Error("Kiro login cancelled."); +} + +async function defaultKiroCliRunner(args: string[], signal?: AbortSignal): Promise { + throwIfKiroLoginCancelled(signal); + let child: ReturnType; + try { + child = Bun.spawn(["kiro-cli", ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + } catch { + throw new Error("Kiro CLI is not installed or could not be started."); + } + const abort = () => child.kill(); + signal?.addEventListener("abort", abort, { once: true }); + // AbortSignal does not replay an abort that lands between the pre-check and listener registration. + if (signal?.aborted) abort(); + try { + const [exitCode, stdout] = await Promise.all([ + child.exited, + child.stdout instanceof ReadableStream ? new Response(child.stdout).text() : Promise.resolve(""), + ]); + throwIfKiroLoginCancelled(signal); + return { exitCode, stdout }; + } finally { + signal?.removeEventListener("abort", abort); + } +} + +async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string }> { + try { + const result = await runner(["whoami", "--format", "json"], signal); + if (result.exitCode !== 0) return {}; + const parsed = JSON.parse(result.stdout) as { email?: unknown }; + const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : ""; + return email && email.length <= 320 ? { email } : {}; + } catch { + return {}; + } +} + +function metadataFromImported(imported: ImportedKiroCredential): KiroOAuthMetadata | undefined { + const metadata: KiroOAuthMetadata = { + ...(imported.profileArn ? { profileArn: imported.profileArn } : {}), + ...(imported.ssoRegion ? { ssoRegion: imported.ssoRegion } : {}), + ...(imported.apiRegion ? { apiRegion: imported.apiRegion } : {}), + ...(imported.clientId ? { clientId: imported.clientId } : {}), + ...(imported.clientSecret ? { clientSecret: imported.clientSecret } : {}), + }; + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +async function oauthCredentialFromImported( + imported: ImportedKiroCredential, + runner: KiroCliRunner, + signal?: AbortSignal, +): Promise { + const identity = imported.source === "sqlite" ? await readKiroCliIdentity(runner, signal) : {}; + const metadata = metadataFromImported(imported); + return { + access: imported.access, + refresh: imported.refresh, + expires: imported.expires, + source: imported.source === "json" ? "credential-file" : "local-cli", + ...(imported.profileArn ? { accountId: imported.profileArn } : {}), + ...(identity.email ? { email: identity.email } : {}), + ...(metadata ? { kiro: metadata } : {}), + }; +} + export type KiroCliImportDiagnosticStatus = KiroImportDiagnostic["status"]; export type KiroCliImportDiagnostic = KiroImportDiagnostic; @@ -54,16 +190,55 @@ export function readKiroCliSqlite(): ImportedKiroToken | null { * so the GUI renders the paste-input field, then blocks on onManualCodeInput for the token. * If neither onAuth nor onManualCodeInput is available, throws a clear error. */ -export async function loginKiro(ctrl: OAuthController): Promise { +export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions = {}): Promise { + const runner = options.cliRunner ?? defaultKiroCliRunner; + // A prior process may have exited after switching the external CLI account but before + // settlement. Recover that durable transaction before either importing or switching again. + restoreStaleKiroCliSessionRecovery(); + if (options.forceLogin) { + throwIfKiroLoginCancelled(ctrl.signal); + ctrl.onAuth?.({ + url: "", + instructions: "Kiro CLI is opening a fresh browser login. This also switches the account used by kiro-cli.", + }); + ctrl.onProgress?.("Opening a fresh Kiro CLI browser login."); + // Never log out of a session we could not capture: the recovery contract promises an exact + // restore, so an uncapturable store (unreadable / schema-mismatched / ambiguous token) must + // abort here rather than let a later failure destroy it permanently. + const inspected = inspectKiroCliSessionSnapshot(); + if (inspected.blocked) { + throw new Error( + "Kiro CLI session could not be backed up, so OCX will not sign it out. " + + "Resolve the local kiro-cli credential store first (see `ocx account diagnose kiro`), then retry.", + ); + } + const previousSession = inspected.snapshot; + if (previousSession) persistKiroCliSessionRecovery(previousSession); + try { + const logout = await runner(["logout"], ctrl.signal); + throwIfKiroLoginCancelled(ctrl.signal); + if (logout.exitCode !== 0) throw new Error("Kiro CLI could not prepare a fresh login."); + const login = await runner(["login"], ctrl.signal); + throwIfKiroLoginCancelled(ctrl.signal); + if (login.exitCode !== 0) throw new Error("Kiro CLI login did not complete successfully."); + const fresh = readKiroCliSqliteCredential(); + if (!fresh) throw new Error("Kiro CLI login completed but no credential could be imported."); + const credential = await oauthCredentialFromImported(fresh, runner, ctrl.signal); + throwIfKiroLoginCancelled(ctrl.signal); + if (!credential.accountId && !credential.email) { + throw new Error("Kiro login completed but OCX could not determine a stable account identity."); + } + if (previousSession) pendingKiroLoginTransactions.set(credential, previousSession); + return credential; + } catch (error) { + restoreKiroLoginOrThrow(previousSession, error); + } + } + const imported = readImportedKiroCredential(); if (imported) { ctrl.onProgress?.(imported.source === "json" ? "Imported token from Kiro credentials file." : "Imported token from installed kiro-cli login."); - return { - access: imported.access, - refresh: imported.refresh, - expires: imported.expires, - source: imported.source === "json" ? "credential-file" : "local-cli", - }; + return oauthCredentialFromImported(imported, runner, ctrl.signal); } const envToken = process.env.KIRO_ACCESS_TOKEN; @@ -95,16 +270,25 @@ export async function loginKiro(ctrl: OAuthController): Promise): string { + if (account !== undefined) { + return ( + normalizeKiroRegion(account.apiRegion) || + inferRegionFromProfileArn(account.profileArn) || + normalizeKiroRegion(account.ssoRegion) || + DEFAULT_REGION + ); + } if (process.env.KIRO_API_REGION !== undefined) return requireKiroRegion(process.env.KIRO_API_REGION); + const imported = readImportedKiroCredential(); return ( normalizeKiroRegion(imported?.apiRegion) || inferRegionFromProfileArn(imported?.profileArn) || @@ -116,15 +300,29 @@ export function resolveKiroApiRegion(): string { /** * Resolve the CodeWhisperer profileArn for request-time use by the adapter. - * KIRO_PROFILE_ARN env → kiro-cli SQLite `profile_arn`. Returns undefined if absent - * (the adapter decides whether that is fatal). + * Account metadata is authoritative. Legacy accountless calls use KIRO_PROFILE_ARN → local import. + * Returns undefined if absent (the adapter decides whether that is fatal). */ -export function resolveKiroProfileArn(): string | undefined { +export function resolveKiroProfileArn(account?: Pick): string | undefined { + if (account !== undefined) return account.profileArn; const env = process.env.KIRO_PROFILE_ARN; if (env) return env; return readImportedKiroCredential()?.profileArn; } +async function kiroTokenRefreshError(response: Response): Promise { + let oauthError: string | undefined; + try { + const payload = await response.json() as { error?: unknown }; + if (typeof payload.error === "string" && KIRO_TERMINAL_REFRESH_ERRORS.has(payload.error)) { + oauthError = payload.error; + } + } catch { + // Error bodies are untrusted and intentionally excluded from the surfaced message. + } + return new KiroTokenRefreshError(response.status, oauthError); +} + async function readTokenResponse(res: Response, oldRefresh: string): Promise { const data = (await res.json()) as { accessToken?: string; refreshToken?: string; expiresIn?: number }; if (!data.accessToken) throw new Error("Kiro refresh returned no accessToken"); @@ -135,43 +333,121 @@ async function readTokenResponse(res: Response, oldRefresh: string): Promise { - const region = resolveKiroRegion(); +function kiroRefreshSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(30_000); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +async function refreshKiroDesktopToken(refresh: string, signal?: AbortSignal, metadata?: KiroOAuthMetadata): Promise { + const region = resolveKiroRegion(metadata); const res = await fetch(REFRESH_URL.replace("{region}", region), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken: refresh }), - signal: signal ?? AbortSignal.timeout(30_000), + signal: kiroRefreshSignal(signal), }); - if (!res.ok) throw new Error(`Kiro token refresh failed: ${res.status}`); + if (!res.ok) throw await kiroTokenRefreshError(res); return readTokenResponse(res, refresh); } -async function refreshAwsSsoOidcToken(refresh: string, signal?: AbortSignal): Promise { - const imported = readImportedKiroCredential(); - if (!imported?.clientId || !imported.clientSecret) return refreshKiroDesktopToken(refresh, signal); - const region = resolveKiroRegion(); +async function refreshAwsSsoOidcToken( + refresh: string, + signal?: AbortSignal, + credential?: OAuthCredentials, +): Promise { + let metadata = credential?.kiro; + if (!metadata) { + const local = readImportedKiroCredential(); + // Only a local store holding this exact refresh token describes this account. Anything else + // belongs to whichever account kiro-cli is currently signed into. + if (local?.refresh === refresh) metadata = metadataFromImported(local); + } + // A stored OCX account with no usable `kiro` metadata must still refresh account-scoped: falling + // through to `resolveKiroRegion(undefined)` would read KIRO_REGION or the local CLI import and + // borrow an unrelated account's region after a switch. An empty marker pins the default region. + // Only a truly accountless refresh (no stored credential) keeps the legacy env/local fallback. + if (!metadata && credential) metadata = {}; + const clientId = metadata?.clientId; + const clientSecret = metadata?.clientSecret; + if (!clientId || !clientSecret) return refreshKiroDesktopToken(refresh, signal, metadata); + const region = resolveKiroRegion(metadata); const run = async (refreshToken: string): Promise => fetch(OIDC_URL.replace("{region}", region), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grantType: "refresh_token", - clientId: imported.clientId, - clientSecret: imported.clientSecret, + clientId, + clientSecret, refreshToken, }), - signal: signal ?? AbortSignal.timeout(30_000), + signal: kiroRefreshSignal(signal), }); - let res = await run(refresh); - if (!res.ok && res.status === 400 && imported.source === "sqlite") { - const reloaded = readImportedKiroCredential(); - if (reloaded?.refresh && reloaded.refresh !== refresh) res = await run(reloaded.refresh); - } - if (!res.ok) throw new Error(`Kiro AWS SSO OIDC refresh failed: ${res.status}`); + const res = await run(refresh); + if (!res.ok) throw await kiroTokenRefreshError(res); return readTokenResponse(res, refresh); } -export async function refreshKiroToken(refresh: string, signal?: AbortSignal): Promise { +function matchingRotatedKiroCliCredential( + refresh: string, + credential?: OAuthCredentials, +): ImportedKiroCredential | undefined { + const storedIdentities = [credential?.kiro?.profileArn, credential?.accountId] + .filter((value): value is string => Boolean(value)); + if (storedIdentities.length === 0 || new Set(storedIdentities).size !== 1) return undefined; + try { + const local = readKiroCliSqliteCredential(); + if (!local?.profileArn || storedIdentities.some(identity => identity !== local.profileArn)) return undefined; + if (!local.refresh || local.refresh === refresh) return undefined; + return local; + } catch { + // An unrelated or malformed local store must not block the stored OCX account. + return undefined; + } +} + +function metadataForRotatedKiroCliCredential( + credential: OAuthCredentials, + local: ImportedKiroCredential, +): KiroOAuthMetadata { + const { + clientId: _storedClientId, + clientSecret: _storedClientSecret, + ...storedRouting + } = credential.kiro ?? {}; + const localMetadata = metadataFromImported(local) ?? {}; + const { + clientId: _localClientId, + clientSecret: _localClientSecret, + ...localRouting + } = localMetadata; + return { + ...storedRouting, + ...localRouting, + ...(local.authType === "aws_sso_oidc" && local.clientId && local.clientSecret + ? { clientId: local.clientId, clientSecret: local.clientSecret } + : {}), + }; +} + +export async function refreshKiroToken( + refresh: string, + signal?: AbortSignal, + credential?: OAuthCredentials, +): Promise { if (!refresh) throw new Error("Kiro: no refresh token available (re-run `kiro-cli login`)."); - return refreshAwsSsoOidcToken(refresh, signal); + try { + return await refreshAwsSsoOidcToken(refresh, signal, credential); + } catch (error) { + if (!(error instanceof KiroTokenRefreshError) || error.httpStatus !== 400) throw error; + if (!credential) throw error; + const local = matchingRotatedKiroCliCredential(refresh, credential); + if (!local) throw error; + const retryMetadata = metadataForRotatedKiroCliCredential(credential, local); + const fresh = await refreshAwsSsoOidcToken(local.refresh, signal, { + ...credential, + refresh: local.refresh, + kiro: retryMetadata, + }); + return { ...fresh, kiro: retryMetadata }; + } } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 69110cace8..f60d410eff 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -10,7 +10,7 @@ * Exceptions: * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot * for Codex pool logins, which have their own ledger (codex-accounts.json). - * - Credentials without identity (no accountId/email — e.g. kiro) replace the active slot + * - Credentials without identity (no accountId/email) replace the active slot * instead of appending: their refresh tokens rotate, so a derived id would duplicate the * same human on every re-login. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor * extracts JWT `sub` — both append distinct accounts under multiauth. @@ -206,6 +206,28 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null { const validated = validateCopilotApiBaseUrl(candidate.apiBaseUrl); if (validated) normalized.apiBaseUrl = validated; } + if (candidate.kiro && typeof candidate.kiro === "object") { + const kiro = candidate.kiro; + const clean = (value: unknown, max: number): string | undefined => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed && trimmed.length <= max && !/[\x00-\x1f\x7f]/.test(trimmed) ? trimmed : undefined; + }; + const profileArn = clean(kiro.profileArn, 1024); + const ssoRegion = clean(kiro.ssoRegion, 64); + const apiRegion = clean(kiro.apiRegion, 64); + const clientId = clean(kiro.clientId, 4096); + const clientSecret = clean(kiro.clientSecret, 4096); + if (profileArn || ssoRegion || apiRegion || clientId || clientSecret) { + normalized.kiro = { + ...(profileArn ? { profileArn } : {}), + ...(ssoRegion ? { ssoRegion } : {}), + ...(apiRegion ? { apiRegion } : {}), + ...(clientId ? { clientId } : {}), + ...(clientSecret ? { clientSecret } : {}), + }; + } + } return normalized; } @@ -293,7 +315,11 @@ export function getCredential(provider: string): OAuthCredentials | null { * (rotating refresh tokens would fabricate duplicates) and single-slot providers replace the * active slot / whole set instead. */ -export async function saveCredential(provider: string, cred: OAuthCredentials): Promise { +export async function saveCredential( + provider: string, + cred: OAuthCredentials, + opts: { preserveIdentityless?: boolean } = {}, +): Promise { const safe = normalizeCredential(cred); if (!safe) return; await mutateStore(store => { @@ -317,7 +343,7 @@ export async function saveCredential(provider: string, cred: OAuthCredentials): // active identity-less row in place prevents a stale duplicate that stays selectable // and would re-refresh into a second row with the same identity. const active = set.accounts.find(a => a.id === set.activeAccountId); - if (active && active.credential.accountId === undefined && active.credential.email === undefined) { + if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) { active.credential = safe; delete active.needsReauth; return; diff --git a/src/oauth/types.ts b/src/oauth/types.ts index 1ec061f76c..e39a79c6ac 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -1,6 +1,15 @@ /** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */ export type OAuthCredentialSource = "oauth" | "local-cli" | "credential-file" | "environment" | "manual"; +/** Account-scoped Kiro data required for refresh and request routing. */ +export interface KiroOAuthMetadata { + profileArn?: string; + ssoRegion?: string; + apiRegion?: string; + clientId?: string; + clientSecret?: string; +} + export type OAuthCredentials = { refresh: string; access: string; @@ -15,6 +24,8 @@ export type OAuthCredentials = { * Never reuse for Antigravity projectId; validated on write and again at request time. */ apiBaseUrl?: string; + /** Never returned by management APIs; persisted only inside the protected auth-store boundary. */ + kiro?: KiroOAuthMetadata; }; /** One logged-in account inside a provider's account set (multiauth). */ diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5902c5b29a..5552b845fe 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -506,7 +506,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ baseUrl: "https://runtime.us-east-1.kiro.dev", authKind: "oauth", oauthId: "kiro", - note: "Import-first: reuses your installed Kiro CLI login — requires kiro-cli installed and signed in (`kiro-cli login`). Experimental third-party harness — see Kiro ToS.", + note: "Import-first: reuses your installed and signed-in Kiro CLI session (requires `kiro-cli login`). Add account logs `kiro-cli` out, switches it through a fresh browser login, stores the account by profile ARN, and restores the previous CLI session on cancellation or failure. Experimental third-party harness — see Kiro ToS.", models: KIRO_MODELS, defaultModel: "kiro-auto", // Kiro speaks CodeWhisperer wire, not OpenAI-style GET /models. Keep the static diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fbed025540..2472b66081 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1028,6 +1028,11 @@ export async function handleResponses( const resolved = await getValidAccessTokenSnapshot(route.providerName); if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; route.provider = { ...route.provider, apiKey: resolved.accessToken }; + if (route.providerName === "kiro") { + // `{}` is intentional: this is an account-scoped request with no stored routing metadata. + // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. + parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) }; + } // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the // CCA envelope; the server injects only the bare token, so pull project from the credential. if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) { @@ -1806,6 +1811,9 @@ export async function handleResponses( return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err)); } sentOAuthSnapshot = refreshed; + if (route.providerName === "kiro") { + parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + } const refreshedProvider = resolveProviderTransport( route.providerName, { ...route.provider, apiKey: refreshed.accessToken }, diff --git a/src/types.ts b/src/types.ts index fb0e244d3f..aa55db26a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { KiroOAuthMetadata } from "./oauth/types"; + export interface OcxParsedRequest { modelId: string; previousResponseId?: string; @@ -23,6 +25,8 @@ export interface OcxParsedRequest { * derived from the parent thread id. */ _cursorIsolateConversation?: boolean; + /** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */ + _kiroAuthContext?: Pick; /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */ _providerContinuation?: OcxProviderContinuationState; /** diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index d00ae1bda8..62e5782797 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createKiroAdapter } from "../src/adapters/kiro"; import { KIRO_TOOL_RESULT_CARRIER_MESSAGE } from "../src/adapters/kiro-constants"; import { applyProviderConfigHints, buildCatalogEntries } from "../src/codex/catalog"; +import { getValidAccessTokenSnapshot } from "../src/oauth"; +import { saveCredential } from "../src/oauth/store"; import { normalizeKiroModelId } from "../src/providers/kiro-models"; import { configuredReasoningEfforts, mapReasoningEffort } from "../src/reasoning-effort"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; @@ -16,12 +19,14 @@ const origApiRegion = process.env.KIRO_API_REGION; const origArn = process.env.KIRO_PROFILE_ARN; const origCredsFile = process.env.KIRO_CREDS_FILE; const origCredentialsFile = process.env.KIRO_CREDENTIALS_FILE; +const origOcxHome = process.env.OPENCODEX_HOME; let tmp: string; beforeEach(() => { // isolate: empty HOME so no kiro-cli SQLite is read; deterministic region. tmp = mkdtempSync(join(tmpdir(), "kiro-adapter-")); process.env.HOME = 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; @@ -35,6 +40,7 @@ afterEach(() => { if (origArn === undefined) delete process.env.KIRO_PROFILE_ARN; else process.env.KIRO_PROFILE_ARN = origArn; if (origCredsFile === undefined) delete process.env.KIRO_CREDS_FILE; else process.env.KIRO_CREDS_FILE = origCredsFile; if (origCredentialsFile === undefined) delete process.env.KIRO_CREDENTIALS_FILE; else process.env.KIRO_CREDENTIALS_FILE = origCredentialsFile; + if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = origOcxHome; rmSync(tmp, { recursive: true, force: true }); }); @@ -45,6 +51,18 @@ function parsedWith(messages: unknown[], tools?: unknown[], modelId = "claude-so return { modelId, stream: true, options: {}, context: { messages, tools } } as unknown as OcxParsedRequest; } +function seedKiroCliMetadata(profileArn: string, region: string): void { + const dir = join(tmp, "Library", "Application Support", "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", + JSON.stringify({ access_token: "local-access", refresh_token: "local-refresh", profile_arn: profileArn, region }), + ]); + db.close(); +} + describe("kiro adapter — buildRequest", () => { test("rejects missing and blank Kiro tokens before building a request", async () => { for (const apiKey of [undefined, "", " "]) { @@ -74,6 +92,65 @@ describe("kiro adapter — buildRequest", () => { expect(url).toBe("https://runtime.ap-northeast-2.kiro.dev/"); }); + test("account-scoped OAuth metadata selects the matching Kiro region and profile", async () => { + const parsed = parsedWith([{ role: "user", content: "hi" }]); + parsed._kiroAuthContext = { + apiRegion: "eu-central-1", + profileArn: "arn:aws:codewhisperer:eu-central-1:123456789012:profile/account-b", + }; + + const request = await createKiroAdapter(provider).buildRequest(parsed); + const body = JSON.parse(request.body) as { profileArn?: string }; + + expect(request.url).toBe("https://runtime.eu-central-1.kiro.dev/"); + expect(request.headers["x-amzn-kiro-profile-arn"]).toBe(parsed._kiroAuthContext.profileArn); + expect(body.profileArn).toBe(parsed._kiroAuthContext.profileArn); + }); + + test("an account with no stored Kiro metadata never borrows different local CLI metadata", async () => { + seedKiroCliMetadata( + "arn:aws:codewhisperer:eu-west-1:123456789012:profile/local-other-account", + "eu-west-1", + ); + delete process.env.KIRO_REGION; + await saveCredential("kiro", { + access: "stored-access", + refresh: "stored-refresh", + expires: Date.now() + 3_600_000, + source: "oauth", + }); + + const snapshot = await getValidAccessTokenSnapshot("kiro"); + expect(snapshot.kiro).toEqual({}); + const parsed = parsedWith([{ role: "user", content: "hi" }]); + parsed._kiroAuthContext = { ...snapshot.kiro }; + const request = await createKiroAdapter(provider).buildRequest(parsed); + const body = JSON.parse(request.body) as { profileArn?: string }; + + expect(request.url).toBe("https://runtime.us-east-1.kiro.dev/"); + expect(request.headers["x-amzn-kiro-profile-arn"]).toBeUndefined(); + expect(body.profileArn).toBeUndefined(); + }); + + test("genuinely accountless requests still honor Kiro environment overrides", async () => { + const previousApiRegion = process.env.KIRO_API_REGION; + const previousProfileArn = process.env.KIRO_PROFILE_ARN; + process.env.KIRO_API_REGION = "ap-northeast-1"; + process.env.KIRO_PROFILE_ARN = "arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/env"; + try { + const parsed = parsedWith([{ role: "user", content: "hi" }]); + expect(parsed._kiroAuthContext).toBeUndefined(); + const request = await createKiroAdapter(provider).buildRequest(parsed); + expect(request.url).toBe("https://runtime.ap-northeast-1.kiro.dev/"); + expect(request.headers["x-amzn-kiro-profile-arn"]).toBe(process.env.KIRO_PROFILE_ARN); + } finally { + if (previousApiRegion === undefined) delete process.env.KIRO_API_REGION; + else process.env.KIRO_API_REGION = previousApiRegion; + if (previousProfileArn === undefined) delete process.env.KIRO_PROFILE_ARN; + else process.env.KIRO_PROFILE_ARN = previousProfileArn; + } + }); + test("a genuinely custom Kiro base URL is honored", async () => { const custom = { ...provider, baseUrl: "https://kiro.internal.example/custom/generate" }; const { url } = await createKiroAdapter(custom).buildRequest(parsedWith([{ role: "user", content: "hi" }])); diff --git a/tests/kiro-oauth.test.ts b/tests/kiro-oauth.test.ts index 467da9da96..6b770795dc 100644 --- a/tests/kiro-oauth.test.ts +++ b/tests/kiro-oauth.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { inspectKiroCliSqlite, loginKiro, readKiroCliSqlite, refreshKiroToken, resolveKiroApiRegion, resolveKiroProfileArn, resolveKiroRegion } from "../src/oauth/kiro"; +import { OAUTH_PROVIDERS, runLogin } from "../src/oauth"; +import { inspectKiroCliSqlite, loginKiro, readKiroCliSqlite, refreshKiroToken, resolveKiroApiRegion, resolveKiroProfileArn, resolveKiroRegion, settleKiroLoginTransaction } from "../src/oauth/kiro"; // Windows CI cold runners take 5-7s for the real SQLite create/inspect cycles here // (same flake class as 810fa115); the default 5s harness timeout is too tight. @@ -96,6 +97,31 @@ function seedKiroCliRawValue(value: string) { db.close(); } +function removeKiroCliDb(): void { + const path = kiroCliDbPath(); + for (const suffix of ["", "-wal", "-shm", "-journal"]) rmSync(`${path}${suffix}`, { force: true }); +} + +function kiroCliDbPath(): string { + return join(tmp, "Library", "Application Support", "kiro-cli", "data.sqlite3"); +} + +function kiroCliRecoveryPath(): string { + return `${kiroCliDbPath()}.opencodex-recovery`; +} + +function rewriteKiroCliRecoveryOwner(ownerPid: number): void { + const path = kiroCliRecoveryPath(); + const payload = readFileSync(path); + const firstLineEnd = payload.indexOf(0x0a); + const ownerLineEnd = payload.indexOf(0x0a, firstLineEnd + 1); + writeFileSync(path, Buffer.concat([ + payload.subarray(0, firstLineEnd + 1), + Buffer.from(`${ownerPid}\n`, "utf8"), + payload.subarray(ownerLineEnd + 1), + ]), { mode: 0o600 }); +} + function seedCustomTokenDb(path: string, rows: Array<[string, Record]>): void { mkdirSync(join(path, ".."), { recursive: true }); const db = new Database(path); @@ -157,12 +183,301 @@ describe("kiro oauth — import-first", () => { test("loginKiro returns imported SQLite credentials", async () => { seedKiroCliDb({ access_token: "aoa-xyz", refresh_token: "rt-2" }); - const cred = await loginKiro({}); + const cred = await loginKiro({}, { cliRunner: async () => ({ exitCode: 1, stdout: "" }) }); expect(cred.access).toBe("aoa-xyz"); expect(cred.refresh).toBe("rt-2"); expect(cred.source).toBe("local-cli"); }); + test("force login switches Kiro CLI identity and imports a distinct account", async () => { + const calls: string[][] = []; + const auth: Array<{ url: string; instructions?: string }> = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-second", + refresh_token: "rt-second", + expires_at: "2099-01-01T00:00:00Z", + region: "eu-west-1", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "Second@Example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const cred = await loginKiro({ onAuth: info => auth.push(info) }, { forceLogin: true, cliRunner: runner }); + + expect(calls).toEqual([ + ["logout"], + ["login"], + ["whoami", "--format", "json"], + ]); + expect(auth).toEqual([expect.objectContaining({ url: "", instructions: expect.stringContaining("fresh browser login") })]); + expect(cred).toMatchObject({ + access: "aoa-second", + refresh: "rt-second", + email: "second@example.com", + source: "local-cli", + kiro: { ssoRegion: "eu-west-1" }, + }); + }); + + test("force login imports only the newly authenticated CLI account, not a configured credential file", async () => { + const file = join(tmp, "old-account.json"); + writeFileSync(file, JSON.stringify({ + accessToken: "aoa-old-json", + refreshToken: "rt-old-json", + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/old", + })); + process.env.KIRO_CREDS_FILE = file; + const runner = async (args: string[]) => { + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-new-cli", + refresh_token: "rt-new-cli", + profile_arn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/new", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "new@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const cred = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + + expect(cred.access).toBe("aoa-new-cli"); + expect(cred.refresh).toBe("rt-new-cli"); + expect(cred.accountId).toBe("arn:aws:codewhisperer:eu-west-1:123456789012:profile/new"); + expect(cred.email).toBe("new@example.com"); + expect(cred.source).toBe("local-cli"); + }); + + test("force login durably records the prior session before logout and deletes it after successful settlement", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + let recoveryExistedBeforeLogout = false; + let recoveryModeBeforeLogout: number | undefined; + const runner = async (args: string[]) => { + if (args[0] === "logout") { + recoveryExistedBeforeLogout = existsSync(kiroCliRecoveryPath()); + recoveryModeBeforeLogout = statSync(kiroCliRecoveryPath()).mode & 0o777; + removeKiroCliDb(); + } + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-new", + refresh_token: "rt-new", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "new@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const pending = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + + expect(recoveryExistedBeforeLogout).toBe(true); + if (process.platform !== "win32") expect(recoveryModeBeforeLogout).toBe(0o600); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + settleKiroLoginTransaction(pending, true); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + expect(readKiroCliSqlite()?.access).toBe("aoa-new"); + }); + + test("force login refuses to log out when a present CLI session cannot be snapshotted", async () => { + const dir = join(tmp, "Library", "Application Support", "kiro-cli"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, "data.sqlite3")); + db.run("CREATE TABLE other_table (key TEXT PRIMARY KEY, value TEXT)"); + db.close(); + const calls: string[][] = []; + + await expect(loginKiro({}, { + forceLogin: true, + cliRunner: async (args: string[]) => { + calls.push(args); + return { exitCode: 0, stdout: "" }; + }, + })).rejects.toThrow(/could not be backed up/i); + + expect(calls).toEqual([]); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + expect(existsSync(join(dir, "data.sqlite3"))).toBe(true); + }); + + test("force login still proceeds when no CLI session exists at all", async () => { + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-first", + refresh_token: "rt-first", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/first", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "first@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const cred = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + + expect(calls[0]).toEqual(["logout"]); + expect(cred.access).toBe("aoa-first"); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("next ordinary login restores stale crash recovery before importing SQLite", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const firstRunner = async (args: string[]) => { + if (args[0] === "logout") removeKiroCliDb(); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-abandoned", + refresh_token: "rt-abandoned", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/abandoned", + }); + } + if (args[0] === "whoami") { + for (const suffix of ["-wal", "-shm", "-journal"]) writeFileSync(`${kiroCliDbPath()}${suffix}`, `abandoned${suffix}`); + return { exitCode: 0, stdout: JSON.stringify({ email: "abandoned@example.com" }) }; + } + return { exitCode: 0, stdout: "" }; + }; + + const abandoned = await loginKiro({}, { forceLogin: true, cliRunner: firstRunner }); + expect(abandoned.access).toBe("aoa-abandoned"); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + const liveTransactionFiles = ["", "-wal", "-shm", "-journal", ".opencodex-recovery"] + .map(suffix => readFileSync(`${kiroCliDbPath()}${suffix}`)); + + let liveOwnerRunnerCalled = false; + const liveOwnerFailure = await loginKiro({}, { + cliRunner: async () => { + liveOwnerRunnerCalled = true; + return { exitCode: 0, stdout: "" }; + }, + }).catch((error: unknown) => error); + expect(liveOwnerFailure).toBeInstanceOf(Error); + expect((liveOwnerFailure as Error).message).toContain("still in progress"); + expect((liveOwnerFailure as Error).message).toContain(`pid ${process.pid}`); + expect((liveOwnerFailure as Error).message).toContain(kiroCliRecoveryPath()); + expect(liveOwnerRunnerCalled).toBe(false); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + expect(["", "-wal", "-shm", "-journal", ".opencodex-recovery"] + .map((suffix, index) => readFileSync(`${kiroCliDbPath()}${suffix}`).equals(liveTransactionFiles[index]!))) + .toEqual([true, true, true, true, true]); + + const exitedOwner = Bun.spawn([process.execPath, "-e", ""]); + await exitedOwner.exited; + rewriteKiroCliRecoveryOwner(exitedOwner.pid); + + // This call stands in for a fresh process: it deliberately never settles or otherwise uses + // the in-memory transaction above. Ordinary import must recover the stale transaction first. + const calls: string[][] = []; + const secondRunner = async (args: string[]) => { + calls.push(args); + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "prior@example.com" }) }; + throw new Error(`unexpected Kiro CLI command: ${args[0]}`); + }; + + const restored = await loginKiro({}, { cliRunner: secondRunner }); + + expect(restored).toMatchObject({ access: "aoa-prior", refresh: "rt-prior", email: "prior@example.com" }); + expect(calls).toEqual([["whoami", "--format", "json"]]); + expect(["-wal", "-shm", "-journal"].map(suffix => existsSync(`${kiroCliDbPath()}${suffix}`))).toEqual([false, false, false]); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("invalid recovery data names the file the operator must remove", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + writeFileSync(kiroCliRecoveryPath(), "not a recovery database", { mode: 0o600 }); + let runnerCalled = false; + + const failure = await loginKiro({}, { + cliRunner: async () => { + runnerCalled = true; + return { exitCode: 0, stdout: "" }; + }, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("recovery data is invalid"); + expect((failure as Error).message).toContain(kiroCliRecoveryPath()); + expect((failure as Error).message).toContain("Remove this file to continue"); + expect(runnerCalled).toBe(false); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + }); + + test("force login cancellation during browser login restores the prior Kiro CLI session", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const controller = new AbortController(); + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "logout") { + removeKiroCliDb(); + } + if (args[0] === "login") { + controller.abort(); + } + return { exitCode: 0, stdout: "" }; + }; + + await expect(loginKiro({ signal: controller.signal }, { forceLogin: true, cliRunner: runner })).rejects.toThrow(/cancelled/i); + expect(calls).toEqual([["logout"], ["login"]]); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("force login callback failure restores the prior Kiro CLI session", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "logout") removeKiroCliDb(); + return { exitCode: args[0] === "login" ? 1 : 0, stdout: "" }; + }; + + await expect(loginKiro({}, { forceLogin: true, cliRunner: runner })).rejects.toThrow(/did not complete successfully/i); + + expect(calls).toEqual([["logout"], ["login"]]); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("credential persistence failure restores the prior Kiro CLI session", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const runner = async (args: string[]) => { + if (args[0] === "logout") removeKiroCliDb(); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-new", + refresh_token: "rt-new", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "new@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + const pending = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + expect(readKiroCliSqlite()?.access).toBe("aoa-new"); + + const originalLogin = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => pending; + try { + await expect(runLogin("kiro", {}, { forceLogin: true }, { + saveCredential: async () => { throw new Error("simulated credential persistence failure"); }, + })).rejects.toThrow("simulated credential persistence failure"); + } finally { + OAUTH_PROVIDERS.kiro.login = originalLogin; + } + + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + test("loginKiro imports JSON credentials and resolver metadata", async () => { const file = join(tmp, "kiro-creds.json"); writeFileSync(file, JSON.stringify({ @@ -180,6 +495,12 @@ describe("kiro oauth — import-first", () => { expect(cred.access).toBe("aoa-json"); expect(cred.refresh).toBe("rt-json"); expect(cred.source).toBe("credential-file"); + expect(cred.accountId).toBe("arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/demo"); + expect(cred.kiro).toMatchObject({ + profileArn: "arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/demo", + ssoRegion: "us-west-2", + apiRegion: "eu-central-1", + }); expect(resolveKiroProfileArn()).toBe("arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/demo"); expect(resolveKiroRegion()).toBe("us-west-2"); expect(resolveKiroApiRegion()).toBe("eu-central-1"); @@ -433,6 +754,241 @@ describe("kiro oauth — import-first", () => { }); }); + test("refreshKiroToken uses stored account metadata instead of another local Kiro session", async () => { + const file = join(tmp, "other-local-account.json"); + writeFileSync(file, JSON.stringify({ + accessToken: "aoa-other", + refreshToken: "rt-other", + region: "ap-southeast-1", + clientId: "other-client", + clientSecret: "other-secret", + })); + process.env.KIRO_CREDS_FILE = file; + let captured: { url: string; body: Record } | undefined; + globalThis.fetch = (async (input, init) => { + captured = { url: String(input), body: JSON.parse(String(init?.body)) as Record }; + return new Response(JSON.stringify({ accessToken: "aoa-stored-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + await refreshKiroToken("rt-stored", undefined, { + access: "aoa-stored", + refresh: "rt-stored", + expires: 0, + accountId: "profile-stored", + kiro: { + profileArn: "profile-stored", + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + }); + + expect(captured?.url).toBe("https://oidc.eu-west-1.amazonaws.com/token"); + expect(captured?.body).toMatchObject({ + clientId: "stored-client", + clientSecret: "stored-secret", + refreshToken: "rt-stored", + }); + }); + + test("legacy stored credential without kiro metadata does not borrow the local CLI region", async () => { + // A legacy OCX account predates account-scoped metadata. The local CLI is signed into a + // different account in another region; refresh must not route through that region. + seedKiroCliDb({ + access_token: "aoa-other-cli", + refresh_token: "rt-other-cli", + region: "ap-southeast-1", + profile_arn: "arn:aws:codewhisperer:ap-southeast-1:123456789012:profile/other", + }); + let captured: string | undefined; + globalThis.fetch = (async (input) => { + captured = String(input); + return new Response(JSON.stringify({ accessToken: "aoa-legacy-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + const cred = await refreshKiroToken("rt-legacy", undefined, { + access: "aoa-legacy", + refresh: "rt-legacy", + expires: 0, + }); + + expect(captured).toBe("https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"); + expect(captured).not.toContain("ap-southeast-1"); + expect(cred.access).toBe("aoa-legacy-new"); + }); + + test("legacy stored credential ignores KIRO_REGION set for a different local account", async () => { + process.env.KIRO_REGION = "eu-central-1"; + let captured: string | undefined; + globalThis.fetch = (async (input) => { + captured = String(input); + return new Response(JSON.stringify({ accessToken: "aoa-scoped-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + await refreshKiroToken("rt-legacy", undefined, { + access: "aoa-legacy", + refresh: "rt-legacy", + expires: 0, + }); + + expect(captured).toBe("https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"); + + // A truly accountless refresh keeps the documented env fallback. + await refreshKiroToken("rt-accountless"); + expect(captured).toBe("https://prod.eu-central-1.auth.desktop.kiro.dev/refreshToken"); + }); + + test("stored refresh metadata does not inspect an unrelated ambiguous local CLI store", async () => { + const path = join(tmp, "ambiguous-refresh", "credentials.sqlite3"); + seedCustomTokenDb(path, [ + ["custom:a:token", { access_token: "aoa-a", refresh_token: "rt-a" }], + ["custom:b:token", { access_token: "aoa-b", refresh_token: "rt-b" }], + ]); + process.env.KIROCLI_DB_PATH = path; + globalThis.fetch = (async () => + new Response(JSON.stringify({ accessToken: "aoa-stored-new", expiresIn: 60 }), { status: 200 })) as typeof fetch; + + await expect(refreshKiroToken("rt-stored", undefined, { + access: "aoa-stored", + refresh: "rt-stored", + expires: 0, + kiro: { + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + })).resolves.toMatchObject({ access: "aoa-stored-new", refresh: "rt-stored" }); + }); + + test("refreshKiroToken retries a rotated local refresh only for the same profile", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/same"; + seedKiroCliDb({ + access_token: "aoa-local-new", + refresh_token: "rt-local-new", + profile_arn: profileArn, + region: "eu-west-1", + }); + const refreshRequests: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (input, init) => { + refreshRequests.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record }); + if (refreshRequests.length === 1) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + } + return new Response(JSON.stringify({ accessToken: "aoa-recovered", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + const fresh = await refreshKiroToken("rt-stored-old", undefined, { + access: "aoa-stored-old", + refresh: "rt-stored-old", + expires: 0, + accountId: profileArn, + source: "local-cli", + kiro: { + profileArn, + ssoRegion: "eu-west-1", + clientId: "stale-client", + clientSecret: "stale-secret", + }, + }); + + expect(refreshRequests).toEqual([ + { + url: "https://oidc.eu-west-1.amazonaws.com/token", + body: { + grantType: "refresh_token", + clientId: "stale-client", + clientSecret: "stale-secret", + refreshToken: "rt-stored-old", + }, + }, + { + url: "https://prod.eu-west-1.auth.desktop.kiro.dev/refreshToken", + body: { refreshToken: "rt-local-new" }, + }, + ]); + expect(fresh).toMatchObject({ access: "aoa-recovered", refresh: "rt-local-new", kiro: { profileArn } }); + expect(fresh.kiro?.clientId).toBeUndefined(); + expect(fresh.kiro?.clientSecret).toBeUndefined(); + }); + + test("refreshKiroToken never retries a rotated local refresh from another profile", async () => { + seedKiroCliDb({ + access_token: "aoa-other", + refresh_token: "rt-other-new", + profile_arn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/other", + region: "eu-west-1", + }); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }) as typeof fetch; + + await expect(refreshKiroToken("rt-stored-old", undefined, { + access: "aoa-stored-old", + refresh: "rt-stored-old", + expires: 0, + accountId: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/stored", + source: "local-cli", + kiro: { + profileArn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/stored", + ssoRegion: "eu-west-1", + }, + })).rejects.toBeInstanceOf(Error); + expect(calls).toBe(1); + }); + + test("refreshKiroToken rejects conflicting stored profile identities before local recovery", async () => { + const localProfile = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/local"; + seedKiroCliDb({ + access_token: "aoa-local", + refresh_token: "rt-local-new", + profile_arn: localProfile, + region: "eu-west-1", + }); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }) as typeof fetch; + + await expect(refreshKiroToken("rt-stored-old", undefined, { + access: "aoa-stored-old", + refresh: "rt-stored-old", + expires: 0, + accountId: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/different", + source: "local-cli", + kiro: { profileArn: localProfile, ssoRegion: "eu-west-1" }, + })).rejects.toBeInstanceOf(Error); + expect(calls).toBe(1); + }); + + test("refreshKiroToken composes caller cancellation with its request timeout", async () => { + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout"); + let requestSignal: AbortSignal | undefined; + globalThis.fetch = (async (_input, init) => { + requestSignal = init?.signal ?? undefined; + return new Response(JSON.stringify({ accessToken: "aoa-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + try { + await refreshKiroToken("rt-old", controller.signal, { + access: "aoa-old", + refresh: "rt-old", + expires: 0, + kiro: { ssoRegion: "us-east-1" }, + }); + expect(timeout).toHaveBeenCalledWith(30_000); + expect(requestSignal).not.toBe(controller.signal); + expect(requestSignal?.aborted).toBe(false); + controller.abort(); + expect(requestSignal?.aborted).toBe(true); + } finally { + timeout.mockRestore(); + } + }); + test("refreshKiroToken maps the desktop refresh response to credentials", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ accessToken: "aoa-new", refreshToken: "rt-new", expiresIn: 1000 }), { @@ -458,6 +1014,36 @@ describe("kiro oauth — import-first", () => { }); describe("kiro oauth — adapter-time resolvers (profileArn / region)", () => { + test("account-scoped resolution never borrows another local Kiro identity", () => { + const file = join(tmp, "local-other-account.json"); + writeFileSync(file, JSON.stringify({ + accessToken: "aoa-other", + refreshToken: "rt-other", + profileArn: "arn:aws:codewhisperer:eu-central-1:123456789012:profile/other", + region: "eu-central-1", + })); + process.env.KIRO_CREDS_FILE = file; + + expect(resolveKiroProfileArn({})).toBeUndefined(); + expect(resolveKiroRegion({})).toBe("us-east-1"); + expect(resolveKiroApiRegion({})).toBe("us-east-1"); + }); + + test("account-scoped resolution is not overridden by another account's environment metadata", () => { + process.env.KIRO_PROFILE_ARN = "arn:environment-account"; + process.env.KIRO_REGION = "ap-southeast-1"; + process.env.KIRO_API_REGION = "ap-northeast-1"; + const account = { + profileArn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/account-b", + ssoRegion: "eu-west-1", + apiRegion: "eu-central-1", + }; + + expect(resolveKiroProfileArn(account)).toBe(account.profileArn); + expect(resolveKiroRegion(account)).toBe("eu-west-1"); + expect(resolveKiroApiRegion(account)).toBe("eu-central-1"); + }); + test("resolveKiroProfileArn: KIRO_PROFILE_ARN env wins over SQLite", () => { process.env.KIRO_PROFILE_ARN = "arn:env"; seedKiroCliDb({ access_token: "aoa", profile_arn: "arn:sqlite" }); diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts new file mode 100644 index 0000000000..cab95536dc --- /dev/null +++ b/tests/kiro-review-regressions.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getValidAccessTokenSnapshot, OAUTH_PROVIDERS, runLogin } from "../src/oauth"; +import { + inspectKiroCliSessionSnapshot, + persistKiroCliSessionRecovery, + readKiroCliSqliteCredential, + restoreStaleKiroCliSessionRecovery, +} from "../src/oauth/kiro-credentials"; +import { getAccountCredential, getAccountSet, saveCredential } from "../src/oauth/store"; +import type { OAuthController, OAuthCredentials } from "../src/oauth/types"; +import type { OcxConfig } from "../src/types"; + +const ENV_KEYS = [ + "HOME", + "OPENCODEX_HOME", + "KIRO_ACCESS_TOKEN", + "KIRO_REFRESH_TOKEN", + "KIRO_PROFILE_ARN", + "KIRO_REGION", + "KIRO_API_REGION", + "KIRO_CREDS_FILE", + "KIRO_CREDENTIALS_FILE", + "KIRO_CLI_DB_FILE", + "KIROCLI_DB_PATH", + "KIROCLI_TOKEN_KEY", +] as const; +const originalEnv = new Map(ENV_KEYS.map(key => [key, process.env[key]])); +let tmp: string; + +function config(): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: {}, + }; +} + +function kiroCliDbPath(): string { + return join(tmp, "Library", "Application Support", "kiro-cli", "data.sqlite3"); +} + +function kiroCliRecoveryPath(): string { + return `${kiroCliDbPath()}.opencodex-recovery`; +} + +function removeKiroCliDb(): void { + for (const suffix of ["", "-wal", "-shm", "-journal"]) { + rmSync(`${kiroCliDbPath()}${suffix}`, { force: true }); + } +} + +function seedKiroCliDb(access: string, refresh: string): void { + const path = kiroCliDbPath(); + mkdirSync(join(path, ".."), { recursive: true }); + const db = new Database(path); + db.run("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)"); + db.run("INSERT INTO auth_kv (key, value) VALUES (?, ?)", [ + "kirocli:social:token", + JSON.stringify({ access_token: access, refresh_token: refresh }), + ]); + db.close(); +} + +function rewriteRecoveryProcessInstance(processInstance: string): void { + const path = kiroCliRecoveryPath(); + const payload = readFileSync(path); + const headerEnd = payload.indexOf(0x0a); + const ownerEnd = payload.indexOf(0x0a, headerEnd + 1); + const instanceEnd = payload.indexOf(0x0a, ownerEnd + 1); + if (headerEnd < 0 || ownerEnd < 0 || instanceEnd < 0) throw new Error("unexpected Kiro recovery format"); + expect(Number(payload.subarray(headerEnd + 1, ownerEnd).toString("utf8"))).toBe(process.pid); + writeFileSync(path, Buffer.concat([ + payload.subarray(0, ownerEnd + 1), + Buffer.from(`${processInstance}\n`, "utf8"), + payload.subarray(instanceEnd + 1), + ]), { mode: 0o600 }); +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "kiro-review-regressions-")); + for (const key of ENV_KEYS) delete process.env[key]; + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "opencodex"); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = originalEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("Kiro review regressions", () => { + test("environment login keeps explicit request-routing metadata without borrowing local CLI state", async () => { + process.env.KIRO_ACCESS_TOKEN = "aoa-env"; + process.env.KIRO_REFRESH_TOKEN = "rt-env"; + process.env.KIRO_PROFILE_ARN = "arn:aws:codewhisperer:ap-southeast-2:123456789012:profile/env"; + process.env.KIRO_API_REGION = "eu-west-1"; + process.env.KIRO_REGION = "eu-central-1"; + + const credential = await runLogin("kiro", {} as OAuthController, undefined, { + loadConfig: config, + saveConfig: () => {}, + }); + const snapshot = await getValidAccessTokenSnapshot("kiro"); + + expect(credential).toMatchObject({ access: "aoa-env", refresh: "rt-env", source: "environment" }); + expect(snapshot).toMatchObject({ + accessToken: "aoa-env", + kiro: { + profileArn: "arn:aws:codewhisperer:ap-southeast-2:123456789012:profile/env", + apiRegion: "eu-west-1", + ssoRegion: "eu-central-1", + }, + }); + }); + + test("Kiro CLI recovery rolls back config persistence failures before settling the account switch", async () => { + const rawCredential: OAuthCredentials = { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + source: "local-cli", + }; + const events: string[] = []; + const originalLogin = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => rawCredential; + try { + await expect(runLogin("kiro", {} as OAuthController, { forceLogin: true }, { + saveCredential: async () => { events.push("credential"); }, + loadConfig: () => { + events.push("load-config"); + return config(); + }, + saveConfig: () => { + events.push("save-config"); + throw new Error("config write failed"); + }, + settleKiroLoginTransaction: (credential, persisted) => { + expect(credential).toBe(rawCredential); + events.push(`settle:${persisted}`); + }, + })).rejects.toThrow("config write failed"); + } finally { + OAUTH_PROVIDERS.kiro.login = originalLogin; + } + + expect(events).toEqual(["credential", "load-config", "save-config", "settle:false"]); + }); + + test("Kiro reauth accepts the same email when the refreshed credential gains a profile ARN", async () => { + await saveCredential("kiro", { + access: "old-access", + refresh: "old-refresh", + expires: Date.now() + 60_000, + email: "same@example.test", + source: "local-cli", + }); + const slotId = getAccountSet("kiro")!.activeAccountId; + const profileArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/same"; + const originalLogin = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => ({ + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + email: "SAME@example.test", + accountId: profileArn, + source: "local-cli", + kiro: { profileArn }, + }); + try { + await runLogin("kiro", {} as OAuthController, { reauthAccountId: slotId }, { + loadConfig: config, + saveConfig: () => {}, + }); + } finally { + OAUTH_PROVIDERS.kiro.login = originalLogin; + } + + expect(getAccountSet("kiro")?.accounts).toHaveLength(1); + expect(getAccountCredential("kiro", slotId)).toMatchObject({ + access: "new-access", + email: "SAME@example.test", + accountId: profileArn, + kiro: { profileArn }, + }); + }); + + test("same-PID process restart restores a stale Kiro CLI recovery transaction", () => { + seedKiroCliDb("aoa-prior", "rt-prior"); + const snapshot = inspectKiroCliSessionSnapshot().snapshot; + expect(snapshot).not.toBeNull(); + persistKiroCliSessionRecovery(snapshot!); + + removeKiroCliDb(); + seedKiroCliDb("aoa-abandoned", "rt-abandoned"); + rewriteRecoveryProcessInstance("restarted-process-instance"); + + expect(restoreStaleKiroCliSessionRecovery()).toBe(true); + expect(readKiroCliSqliteCredential()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); +}); diff --git a/tests/oauth-reauth-bind.test.ts b/tests/oauth-reauth-bind.test.ts index 58ea165bde..b88f43dbe6 100644 --- a/tests/oauth-reauth-bind.test.ts +++ b/tests/oauth-reauth-bind.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { OAUTH_PROVIDERS, runLogin } from "../src/oauth"; import { getAccountCredential, getAccountSet, saveCredential } from "../src/oauth/store"; -import type { OAuthController } from "../src/oauth/types"; +import type { OAuthController, OAuthCredentials } from "../src/oauth/types"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; @@ -109,6 +109,91 @@ describe("OAuth account-scoped reauth", () => { expect(getAccountSet("xai")?.accounts).toHaveLength(1); }); + test("forced Kiro add-account preserves a legacy identity-less account", async () => { + await saveCredential("kiro", { + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60_000, + source: "local-cli", + }); + const original = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => ({ + access: "identified-access", + refresh: "identified-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + source: "local-cli", + }); + try { + await runLogin("kiro", {} as OAuthController, { forceLogin: true }); + } finally { + OAUTH_PROVIDERS.kiro.login = original; + } + + const set = getAccountSet("kiro")!; + expect(set.accounts).toHaveLength(2); + expect(set.accounts.some(account => account.credential.access === "legacy-access")).toBe(true); + expect(getAccountCredential("kiro", set.activeAccountId)?.access).toBe("identified-access"); + }); + + test("non-force Kiro login upgrades a legacy identity-less slot in place", async () => { + await saveCredential("kiro", { + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60_000, + source: "local-cli", + }); + const legacySlotId = getAccountSet("kiro")!.activeAccountId; + const original = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => ({ + access: "identified-access", + refresh: "identified-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/existing", + source: "local-cli", + }); + try { + await runLogin("kiro", {} as OAuthController); + } finally { + OAUTH_PROVIDERS.kiro.login = original; + } + + const set = getAccountSet("kiro")!; + expect(set.accounts).toHaveLength(1); + expect(set.activeAccountId).toBe(legacySlotId); + expect(getAccountCredential("kiro", legacySlotId)?.access).toBe("identified-access"); + }); + + test("runLogin settles a source-less Kiro credential with its exact raw object identity", async () => { + const rawCredential: OAuthCredentials = { + access: "source-less-access", + refresh: "source-less-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/source-less", + }; + let savedCredential: OAuthCredentials | undefined; + let settledCredential: OAuthCredentials | undefined; + let settledPersisted: boolean | undefined; + const original = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => rawCredential; + try { + await runLogin("kiro", {} as OAuthController, undefined, { + saveCredential: async (_provider, credential) => { savedCredential = credential; }, + settleKiroLoginTransaction: (credential, persisted) => { + settledCredential = credential; + settledPersisted = persisted; + }, + }); + } finally { + OAUTH_PROVIDERS.kiro.login = original; + } + + expect(savedCredential).not.toBe(rawCredential); + expect(savedCredential?.source).toBe("oauth"); + expect(settledCredential).toBe(rawCredential); + expect(settledPersisted).toBe(true); + }); + test("management login passes reauthAccountId into startLoginFlow", async () => { const source = await Bun.file("src/server/management/oauth-account-routes.ts").text(); expect(source).toContain("reauthAccountId: accountId"); diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 8434016d2b..5e1995112d 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -10,6 +10,9 @@ import { credentialGeneration, getAccountCredential, getAccountSet, getAuthRefre const origHome = process.env.HOME; const origOcxHome = process.env.OPENCODEX_HOME; const origRegion = process.env.KIRO_REGION; +const origCliDbFile = process.env.KIRO_CLI_DB_FILE; +const origCliDbPath = process.env.KIROCLI_DB_PATH; +const origCliTokenKey = process.env.KIROCLI_TOKEN_KEY; const origClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; const origFetch = globalThis.fetch; const origWarn = console.warn; @@ -21,6 +24,9 @@ beforeEach(() => { process.env.HOME = tmp; process.env.OPENCODEX_HOME = join(tmp, "ocx"); process.env.KIRO_REGION = "us-east-1"; + delete process.env.KIRO_CLI_DB_FILE; + delete process.env.KIROCLI_DB_PATH; + delete process.env.KIROCLI_TOKEN_KEY; process.env.CLAUDE_CONFIG_DIR = join(tmp, ".claude"); }); @@ -28,13 +34,22 @@ afterEach(() => { if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = origOcxHome; if (origRegion === undefined) delete process.env.KIRO_REGION; else process.env.KIRO_REGION = origRegion; + if (origCliDbFile === undefined) delete process.env.KIRO_CLI_DB_FILE; else process.env.KIRO_CLI_DB_FILE = origCliDbFile; + if (origCliDbPath === undefined) delete process.env.KIROCLI_DB_PATH; else process.env.KIROCLI_DB_PATH = origCliDbPath; + if (origCliTokenKey === undefined) delete process.env.KIROCLI_TOKEN_KEY; else process.env.KIROCLI_TOKEN_KEY = origCliTokenKey; if (origClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = origClaudeConfigDir; globalThis.fetch = origFetch; console.warn = origWarn; rmSync(tmp, { recursive: true, force: true }); }); -function seedKiroCliDb(token: { access_token: string; refresh_token?: string; expires_at?: string }) { +function seedKiroCliDb(token: { + access_token: string; + refresh_token?: string; + expires_at?: string; + profile_arn?: string; + region?: string; +}) { const dir = join(tmp, "Library", "Application Support", "kiro-cli"); mkdirSync(dir, { recursive: true }); const db = new Database(join(dir, "data.sqlite3")); @@ -125,17 +140,68 @@ describe("oauth refresh hardening", () => { expect(getCredential("kiro")?.refresh).toBe("rt-fresh"); }); - test("fresh Kiro CLI SQLite token is imported before refresh endpoint", async () => { - const mock = mockRefreshFetch([new Response("unexpected", { status: 500 })]); + test("stored Kiro account refresh does not import a different local CLI session", async () => { + const mock = mockRefreshFetch([ + new Response(JSON.stringify({ accessToken: "aoa-refreshed", refreshToken: "rt-refreshed", expiresIn: 3600 }), { status: 200 }), + ]); seedKiroCliDb({ access_token: "aoa-sqlite", refresh_token: "rt-sqlite", expires_at: "2099-01-01T00:00:00Z" }); await saveCredential("kiro", { access: "aoa-old", refresh: "rt-old", expires: Date.now() - 1 }); - await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-sqlite"); - expect(mock.count()).toBe(0); - expect(getCredential("kiro")?.refresh).toBe("rt-sqlite"); - expect(getCredential("kiro")?.source).toBe("local-cli"); + await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-refreshed"); + expect(mock.count()).toBe(1); + expect(getCredential("kiro")?.refresh).toBe("rt-refreshed"); + expect(getCredential("kiro")?.source).not.toBe("local-cli"); + }); + + test("account-scoped Kiro OIDC refresh sends stored client registration and preserves metadata", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/account-scoped"; + const urls: string[] = []; + const bodies: unknown[] = []; + globalThis.fetch = (async (input, init) => { + urls.push(String(input)); + bodies.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ + accessToken: "aoa-oidc-fresh", + refreshToken: "rt-oidc-fresh", + expiresIn: 3600, + }), { status: 200 }); + }) as typeof fetch; + await saveCredential("kiro", { + access: "aoa-oidc-old", + refresh: "rt-oidc-old", + expires: Date.now() - 1, + accountId: profileArn, + kiro: { + profileArn, + apiRegion: "eu-west-1", + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + }); + + await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-oidc-fresh"); + expect(urls).toEqual(["https://oidc.eu-west-1.amazonaws.com/token"]); + expect(bodies).toEqual([{ + grantType: "refresh_token", + clientId: "stored-client", + clientSecret: "stored-secret", + refreshToken: "rt-oidc-old", + }]); + expect(getCredential("kiro")).toMatchObject({ + access: "aoa-oidc-fresh", + refresh: "rt-oidc-fresh", + accountId: profileArn, + kiro: { + profileArn, + apiRegion: "eu-west-1", + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + }); }); - test("failed refresh recovers from a now-fresh Kiro CLI SQLite token", async () => { + test("failed Kiro refresh does not overwrite the stored account from local CLI", async () => { await saveCredential("kiro", { access: "aoa-old", refresh: "rt-old", expires: Date.now() - 1 }); let calls = 0; globalThis.fetch = (async () => { @@ -144,10 +210,114 @@ describe("oauth refresh hardening", () => { throw new Error("network down"); }) as typeof fetch; - await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-recovered"); + await expect(getValidAccessToken("kiro")).rejects.toThrow("network down"); expect(calls).toBe(1); - expect(getCredential("kiro")?.refresh).toBe("rt-recovered"); - expect(getCredential("kiro")?.source).toBe("local-cli"); + expect(getCredential("kiro")?.refresh).toBe("rt-old"); + expect(getCredential("kiro")?.access).toBe("aoa-old"); + }); + + test("late Kiro refresh cannot overwrite a newer reauthentication", async () => { + await saveCredential("kiro", { + access: "old-access", + refresh: "old-refresh", + expires: Date.now() - 1, + accountId: "kiro-race-account", + kiro: { profileArn: "old-profile", apiRegion: "us-east-1" }, + }); + let release!: () => void; + let started!: () => void; + const didStart = new Promise(resolve => { started = resolve; }); + const mayFinish = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async () => { + started(); + await mayFinish; + return new Response(JSON.stringify({ accessToken: "late-access", refreshToken: "late-refresh", expiresIn: 3600 }), { status: 200 }); + }) as typeof fetch; + + const pending = getValidAccessToken("kiro"); + await didStart; + await saveCredential("kiro", { + access: "reauth-access", + refresh: "reauth-refresh", + expires: Date.now() + 3_600_000, + accountId: "kiro-race-account", + kiro: { profileArn: "reauth-profile", apiRegion: "eu-west-1" }, + }); + release(); + + await expect(pending).resolves.toBe("reauth-access"); + expect(getCredential("kiro")).toMatchObject({ + access: "reauth-access", + refresh: "reauth-refresh", + kiro: { profileArn: "reauth-profile", apiRegion: "eu-west-1" }, + }); + }); + + test("terminal Kiro refresh errors mark only the rejected generation for reauthentication", async () => { + await saveCredential("kiro", { + access: "expired-access", + refresh: "revoked-refresh", + expires: Date.now() - 1, + accountId: "kiro-revoked-account", + }); + mockRefreshFetch([ + new Response(JSON.stringify({ error: "invalid_grant", error_description: "do not surface this detail" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + ]); + + const error = await getValidAccessToken("kiro").then( + () => undefined, + reason => reason, + ); + expect(error).toBeInstanceOf(OAuthLoginRequiredError); + expect(String(error)).not.toContain("do not surface this detail"); + expect(getAccountSet("kiro")?.accounts[0]?.needsReauth).toBe(true); + }); + + test("same-profile local rotation persists the recovered desktop generation without stale registration", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/persisted"; + seedKiroCliDb({ + access_token: "aoa-local", + refresh_token: "rt-local-new", + expires_at: "2099-01-01T00:00:00Z", + profile_arn: profileArn, + region: "eu-west-1", + }); + await saveCredential("kiro", { + access: "aoa-stored", + refresh: "rt-stored-old", + expires: Date.now() - 1, + accountId: profileArn, + source: "local-cli", + kiro: { + profileArn, + ssoRegion: "eu-west-1", + clientId: "stale-client", + clientSecret: "stale-secret", + }, + }); + const urls: string[] = []; + globalThis.fetch = (async (input) => { + urls.push(String(input)); + if (urls.length === 1) return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + return new Response(JSON.stringify({ accessToken: "aoa-recovered", expiresIn: 3600 }), { status: 200 }); + }) as typeof fetch; + + const access = await getValidAccessToken("kiro"); + expect(access).toBe("aoa-recovered"); + expect(urls).toEqual([ + "https://oidc.eu-west-1.amazonaws.com/token", + "https://prod.eu-west-1.auth.desktop.kiro.dev/refreshToken", + ]); + expect(getCredential("kiro")).toMatchObject({ + access: "aoa-recovered", + refresh: "rt-local-new", + kiro: { profileArn, ssoRegion: "eu-west-1" }, + }); + expect(getCredential("kiro")?.kiro?.clientId).toBeUndefined(); + expect(getCredential("kiro")?.kiro?.clientSecret).toBeUndefined(); }); test("refresh preserves existing credential source metadata", async () => { diff --git a/tests/oauth-store-multi.test.ts b/tests/oauth-store-multi.test.ts index 066bba0c41..91b0d01b5f 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -14,11 +14,12 @@ import { setAccountAlias, setActiveAccount, } from "../src/oauth/store"; +import type { OAuthCredentials } from "../src/oauth/types"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-store-multi-test"); let previousOpencodexHome: string | undefined; -const cred = (over: Partial<{ access: string; refresh: string; expires: number; email: string; accountId: string; projectId: string }> = {}) => ({ +const cred = (over: Partial = {}): OAuthCredentials => ({ access: "access-1", refresh: "refresh-1", expires: Date.now() + 3600_000, @@ -83,6 +84,33 @@ describe("multi-account auth store", () => { expect(getCredential("anthropic")?.email).toBe("b@example.com"); }); + test("Kiro account metadata stays attached to each distinct identity", async () => { + await saveCredential("kiro", cred({ + accountId: "profile-a", + access: "kiro-a", + kiro: { profileArn: "profile-a", apiRegion: "us-east-1", clientSecret: "secret-a" }, + })); + await saveCredential("kiro", cred({ + accountId: "profile-b", + access: "kiro-b", + kiro: { profileArn: "profile-b", apiRegion: "eu-west-1", clientSecret: "secret-b" }, + })); + + const set = getAccountSet("kiro")!; + expect(set.accounts).toHaveLength(2); + expect(set.accounts.find(account => account.credential.accountId === "profile-a")?.credential.kiro).toMatchObject({ + profileArn: "profile-a", + apiRegion: "us-east-1", + clientSecret: "secret-a", + }); + expect(set.accounts.find(account => account.credential.accountId === "profile-b")?.credential.kiro).toMatchObject({ + profileArn: "profile-b", + apiRegion: "eu-west-1", + clientSecret: "secret-b", + }); + expect(getCredential("kiro")).toMatchObject({ accountId: "profile-b", access: "kiro-b" }); + }); + test("same identity replaces credential without duplicating", async () => { await saveCredential("anthropic", cred({ email: "a@example.com", accountId: "acct-a" })); await saveCredential("anthropic", cred({ email: "a@example.com", accountId: "acct-a", access: "rotated", refresh: "rotated-refresh" })); diff --git a/tests/server-kiro-oauth-401-replay.test.ts b/tests/server-kiro-oauth-401-replay.test.ts index 8c5e1c8000..c063169ebc 100644 --- a/tests/server-kiro-oauth-401-replay.test.ts +++ b/tests/server-kiro-oauth-401-replay.test.ts @@ -141,6 +141,47 @@ function installFetch(chatStatuses: number[]): { chatAuth: string[]; refreshCall } describe("Kiro OAuth upstream 401 replay", () => { + test("selected OAuth account supplies its own Kiro runtime region and profile", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/account-b"; + await saveCredential("kiro", { + access: "account-b-access", + refresh: "account-b-refresh", + expires: Date.now() + 3_600_000, + accountId: profileArn, + source: "local-cli", + kiro: { profileArn, apiRegion: "eu-west-1", ssoRegion: "us-east-1" }, + }); + saveConfig(config()); + let observed: { url: string; profileHeader: string | null; profileBody?: string } | undefined; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://runtime.eu-west-1.kiro.dev/") { + const body = JSON.parse(String(init?.body)) as { profileArn?: string }; + observed = { + url, + profileHeader: new Headers(init?.headers).get("x-amzn-kiro-profile-arn"), + profileBody: body.profileArn, + }; + return new Response(eventStream("account b"), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }); + } + if (/^https:\/\/runtime\.[a-z0-9-]+\.kiro\.dev\//.test(url)) { + throw new Error(`unexpected Kiro runtime host: ${url}`); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await post(server); + expect(response.status).toBe(200); + expect(observed).toEqual({ url: "https://runtime.eu-west-1.kiro.dev/", profileHeader: profileArn, profileBody: profileArn }); + } finally { + server.stop(true); + } + }); + test("401 then 200 performs one refresh and one replay", async () => { await seedOAuth(); saveConfig(config()); @@ -158,6 +199,66 @@ describe("Kiro OAuth upstream 401 replay", () => { } }); + test("401 replay uses metadata from a concurrently updated account generation", async () => { + const oldProfile = "arn:aws:codewhisperer:us-east-1:123456789012:profile/concurrent"; + const newProfile = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/concurrent"; + await saveCredential("kiro", { + access: "rejected-access", + refresh: "initial-refresh", + expires: Date.now() + 3_600_000, + accountId: "kiro-concurrent-account", + source: "oauth", + kiro: { profileArn: oldProfile, apiRegion: "us-east-1", ssoRegion: "us-east-1" }, + }); + saveConfig(config()); + const observed: Array<{ url: string; auth: string; profile: string | null }> = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === CHAT_ENDPOINT) { + observed.push({ + url, + auth: new Headers(init?.headers).get("authorization") ?? "", + profile: new Headers(init?.headers).get("x-amzn-kiro-profile-arn"), + }); + await saveCredential("kiro", { + access: "concurrent-access", + refresh: "concurrent-refresh", + expires: Date.now() + 3_600_000, + accountId: "kiro-concurrent-account", + source: "oauth", + kiro: { profileArn: newProfile, apiRegion: "eu-west-1", ssoRegion: "eu-west-1" }, + }); + return new Response("rejected", { status: 401 }); + } + if (url === "https://runtime.eu-west-1.kiro.dev/") { + observed.push({ + url, + auth: new Headers(init?.headers).get("authorization") ?? "", + profile: new Headers(init?.headers).get("x-amzn-kiro-profile-arn"), + }); + return new Response(eventStream("updated account"), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }); + } + if (/^https:\/\/runtime\.[a-z0-9-]+\.kiro\.dev\//.test(url)) { + throw new Error(`unexpected Kiro runtime host: ${url}`); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await post(server); + expect(response.status).toBe(200); + expect(observed).toEqual([ + { url: CHAT_ENDPOINT, auth: "Bearer rejected-access", profile: oldProfile }, + { url: "https://runtime.eu-west-1.kiro.dev/", auth: "Bearer concurrent-access", profile: newProfile }, + ]); + } finally { + server.stop(true); + } + }); + test("a second 401 is propagated without a second refresh or replay", async () => { await seedOAuth(); saveConfig(config()); From e8ac5a031ab35931b04d467b8c4abba05b2eb422 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:18:39 +0200 Subject: [PATCH 2/3] fix(kiro): finish multi-account review blockers for maintainer merge Bind legacy identity-less rows before Add-account switches the CLI, snapshot only the native kiro-cli store (refuse import selectors / wrong-store fallbacks), roll back auth on config failure, preserve environment refresh routing, and replace the nonexistent diagnose command with actionable recovery guidance. Co-authored-by: coseung2 <120152615+coseung2@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 6 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/oauth/index.ts | 43 +++-- src/oauth/kiro-credentials.ts | 133 ++++++++++--- src/oauth/kiro.ts | 91 ++++++++- src/oauth/store.ts | 23 +++ tests/kiro-review-regressions.test.ts | 180 ++++++++++++++++-- 10 files changed, 418 insertions(+), 66 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 185c3c7dd1..6bcb87bf8b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -168,8 +168,10 @@ and journal sidecars before publishing the previous session snapshot. Because that rollback is only possible from a snapshot, **Add account** refuses to sign `kiro-cli` out when a session store is present but cannot be captured (unreadable file, mismatched schema, or -an ambiguous token selection). Resolve the local store first, then retry. Signing in from a machine -with no existing `kiro-cli` session is unaffected. +an ambiguous token selection), when `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` redirect import reads away +from the live CLI store, or when an existing primary CLI database has no recognized token row. +Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import +selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected. ## 3. API-key catalog diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 3806965a28..72c92baa46 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -105,7 +105,7 @@ Kiro のログインには Kiro CLI が必要です。`curl -fsSL https://cli.ki 取り込んだ認証情報は `~/.opencodex/auth.json` に保存されます。**アカウントを追加**のロールバックは別処理で、以前のスナップショットを復元する際にデータベースを置き換え、現在の WAL、SHM、journal サイドカーを削除します。 -ロールバックはスナップショットがある場合にのみ可能なため、セッションストアが存在するのに取得できない場合(ファイルが読めない、スキーマの不一致、トークン選択があいまい)、**アカウントを追加**は `kiro-cli` のログアウトを拒否します。まずローカルストアを解決してから再試行してください。既存の `kiro-cli` セッションがまったくない環境には影響しません。 +ロールバックはスナップショットがある場合にのみ可能なため、セッションストアが存在するのに取得できない場合(ファイルが読めない、スキーマの不一致、トークン選択があいまい)、`KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` が実際の CLI ストアと異なるインポート先を指す場合、またはプライマリ CLI データベースに認識できるトークン行がない場合、**アカウントを追加**は `kiro-cli` のログアウトを拒否します。通常の `kiro-cli` データパス上の壊れたデータベースを修復または削除し、インポート専用セレクタが設定されていれば解除してから再試行してください。既存の `kiro-cli` セッションがまったくない環境には影響しません。 ## 3. API キーカタログ diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 927a0a9762..1f9cb66b54 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -105,7 +105,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. `curl -fsSL https://cli.kiro.d 가져온 자격 증명은 `~/.opencodex/auth.json`에 저장됩니다. **계정 추가** 롤백은 별도 절차로, 이전 스냅샷을 복원할 때 데이터베이스를 교체하고 현재 WAL, SHM, journal 사이드카를 제거합니다. -롤백은 스냅샷이 있을 때만 가능하므로, 세션 저장소가 존재하지만 캡처할 수 없는 경우(파일을 읽을 수 없음, 스키마 불일치, 토큰 선택 모호) **계정 추가**는 `kiro-cli` 로그아웃을 거부합니다. 로컬 저장소를 먼저 정리한 뒤 다시 시도하세요. 기존 `kiro-cli` 세션이 아예 없는 환경에서는 영향이 없습니다. +롤백은 스냅샷이 있을 때만 가능하므로, 세션 저장소가 존재하지만 캡처할 수 없는 경우(파일을 읽을 수 없음, 스키마 불일치, 토큰 선택 모호), `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE`이 실제 CLI 저장소와 다른 가져오기 경로를 가리키는 경우, 또는 기본 CLI 데이터베이스에 인식 가능한 토큰 행이 없는 경우 **계정 추가**는 `kiro-cli` 로그아웃을 거부합니다. 일반 `kiro-cli` 데이터 경로의 손상된 데이터베이스를 수리하거나 제거하고, 가져오기 전용 선택자가 설정돼 있으면 해제한 뒤 다시 시도하세요. 기존 `kiro-cli` 세션이 아예 없는 환경에서는 영향이 없습니다. ## 3. API 키 카탈로그 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index ae4729df22..6ae1a133f2 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -115,7 +115,7 @@ OAuth-провайдеры, чьи учётные данные содержат Импортированные учётные данные сохраняются в `~/.opencodex/auth.json`. Откат **Добавить аккаунт** — отдельная операция: при восстановлении предыдущего снимка она заменяет базу и удаляет текущие sidecar-файлы WAL, SHM и journal. -Поскольку откат возможен только при наличии снимка, **Добавить аккаунт** откажется выходить из `kiro-cli`, если хранилище сессии существует, но его нельзя захватить (файл не читается, несовпадение схемы, неоднозначный выбор токена). Сначала устраните проблему с локальным хранилищем, затем повторите попытку. На машины без существующей сессии `kiro-cli` это не влияет. +Поскольку откат возможен только при наличии снимка, **Добавить аккаунт** откажется выходить из `kiro-cli`, если хранилище сессии существует, но его нельзя захватить (файл не читается, несовпадение схемы, неоднозначный выбор токена), если `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` направляют импорт не на активное хранилище CLI, или если в основной базе CLI нет распознаваемой строки токена. Исправьте или удалите повреждённую базу по обычному пути данных `kiro-cli`, снимите селекторы только для импорта и повторите попытку. На машины без существующей сессии `kiro-cli` это не влияет. ## 3. Каталог API-ключей diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 92f90d17b6..390b8972bf 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -98,7 +98,7 @@ Kiro 登录需要 Kiro CLI:使用 `curl -fsSL https://cli.kiro.dev/install | b 导入的凭据会保存到 `~/.opencodex/auth.json`。**添加账户**的回滚是独立流程:恢复之前的快照时会替换数据库,并删除当前的 WAL、SHM 和 journal 边车文件。 -由于回滚依赖快照,当会话存储已存在但无法捕获时(文件不可读、架构不匹配、令牌选择有歧义),**添加账户**会拒绝将 `kiro-cli` 登出。请先修复本地存储,然后重试。对于完全没有现有 `kiro-cli` 会话的机器,不受影响。 +由于回滚依赖快照,当会话存储已存在但无法捕获时(文件不可读、架构不匹配、令牌选择有歧义),当 `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` 将导入路径指向与活动 CLI 存储不同的位置时,或当主 CLI 数据库没有可识别的令牌行时,**添加账户**会拒绝将 `kiro-cli` 登出。请修复或删除常规 `kiro-cli` 数据路径下的损坏数据库,并取消仅用于导入的选择器后重试。对于完全没有现有 `kiro-cli` 会话的机器,不受影响。 ## 3. API 密钥目录 diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 97ded7624a..6ef10b83a1 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -3,12 +3,11 @@ import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { loadConfig, resolveEnvValue, saveConfig } from "../config"; import { maskEmail } from "../lib/privacy"; -import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; +import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; +import { getAccountCredential, getAccountSet, replaceProviderAccountSet, saveAccountCredential, saveCredential, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai"; import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; -import { KiroTokenRefreshError, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; -import { requireKiroRegion } from "./kiro-credentials"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; @@ -201,22 +200,6 @@ export class OAuthLoginRequiredError extends Error { } } -function kiroEnvironmentRoutingMetadata(): Pick | undefined { - const profileArn = process.env.KIRO_PROFILE_ARN?.trim() || undefined; - const apiRegion = process.env.KIRO_API_REGION !== undefined - ? requireKiroRegion(process.env.KIRO_API_REGION) - : undefined; - const ssoRegion = process.env.KIRO_REGION !== undefined - ? requireKiroRegion(process.env.KIRO_REGION) - : undefined; - if (!profileArn && !apiRegion && !ssoRegion) return undefined; - return { - ...(profileArn ? { profileArn } : {}), - ...(apiRegion ? { apiRegion } : {}), - ...(ssoRegion ? { ssoRegion } : {}), - }; -} - function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { const storedKiroRouting = { ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}), @@ -234,7 +217,7 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti ? { kiro: Object.keys(storedKiroRouting).length > 0 ? storedKiroRouting - : kiroEnvironmentRoutingMetadata() ?? {}, + : environmentKiroRoutingMetadata() ?? {}, } : {}), }; @@ -664,6 +647,7 @@ interface RunLoginDeps { loadConfig?: typeof loadConfig; saveConfig?: typeof saveConfig; settleKiroLoginTransaction?: typeof settleKiroLoginTransaction; + replaceProviderAccountSet?: typeof replaceProviderAccountSet; } /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */ @@ -677,6 +661,22 @@ export async function runLogin( if (!def) throw new UnsupportedOAuthProviderError(provider); // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object // for settlement even when source normalization below creates a derived credential object. + const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true; + const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined; + const previousKiroAccountsSnapshot = shouldRollbackKiroAccounts + ? (previousKiroAccounts + ? { + activeAccountId: previousKiroAccounts.activeAccountId, + accounts: previousKiroAccounts.accounts.map(account => ({ + id: account.id, + credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) }, + ...(account.alias ? { alias: account.alias } : {}), + ...(account.needsReauth ? { needsReauth: true } : {}), + ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}), + })), + } + : null) + : undefined; const rawCred = await def.login(ctrl, opts); const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; @@ -707,6 +707,9 @@ export async function runLogin( (deps.saveConfig ?? saveConfig)(config); } } catch (error) { + if (previousKiroAccountsSnapshot !== undefined) { + await (deps.replaceProviderAccountSet ?? replaceProviderAccountSet)(provider, previousKiroAccountsSnapshot); + } try { settleKiroTransaction(rawCred, false); } catch (restoreError) { diff --git a/src/oauth/kiro-credentials.ts b/src/oauth/kiro-credentials.ts index 71bb594605..b773ca3000 100644 --- a/src/oauth/kiro-credentials.ts +++ b/src/oauth/kiro-credentials.ts @@ -122,18 +122,30 @@ function jsonCredentialPaths(): string[] { .map(expandPath); } -function sqliteEntries(): Array<{ location: KiroImportDiagnostic["location"]; path: string }> { +function nativeKiroCliSessionEntries(): Array<{ location: "kiro-cli-data" | "kiro-cli-linux-data"; path: string }> { const home = userHome(); - const configured = process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim(); - if (configured) return [{ location: "kiro-cli-db-env", path: expandPath(configured) }]; + // Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks + // (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback. return [ { location: "kiro-cli-data", path: join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3") }, { location: "kiro-cli-linux-data", path: join(home, ".local", "share", "kiro-cli", "data.sqlite3") }, - { location: "amazon-q-data", path: join(home, ".local", "share", "amazon-q", "data.sqlite3") }, - { location: "kiro-sso-cache", path: join(home, ".kiro", "sso", "cache.db") }, ]; } +function sqliteEntries(): Array<{ location: KiroImportDiagnostic["location"]; path: string }> { + const configured = process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim(); + if (configured) return [{ location: "kiro-cli-db-env", path: expandPath(configured) }]; + return [ + ...nativeKiroCliSessionEntries(), + { location: "amazon-q-data", path: join(userHome(), ".local", "share", "amazon-q", "data.sqlite3") }, + { location: "kiro-sso-cache", path: join(userHome(), ".kiro", "sso", "cache.db") }, + ]; +} + +function kiroCliImportSelectorConfigured(): boolean { + return Boolean(process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim()); +} + function selectTokenRow(db: Database): { value: string } | null | "ambiguous" | "selected_missing" { const rows = db.query("SELECT key, value FROM auth_kv WHERE key LIKE ? ORDER BY key ASC").all("%:token") as Array<{ key: string; value: string }>; const selectedKey = process.env.KIROCLI_TOKEN_KEY?.trim(); @@ -345,6 +357,10 @@ const KIRO_UNSNAPSHOTTABLE_SESSION_STATUSES: ReadonlySet = * Capture the active CLI session and report why capture failed. `blocked` is true when a session * store is present but could not be snapshotted (unreadable / schema-mismatched / ambiguous), so * callers can abort before mutating it. + * + * Forced-login rollback must target the exact database `kiro-cli` mutates. Custom import selectors + * and lower-priority Amazon Q / SSO caches are never used here: snapshotting the wrong file would + * leave the real CLI session switched or lost after failure. */ export function inspectKiroCliSessionSnapshot(): { snapshot: KiroCliSessionSnapshot | null; @@ -352,18 +368,71 @@ export function inspectKiroCliSessionSnapshot(): { blocked: boolean; } { const diagnostics: KiroImportDiagnostic[] = []; - const located = readSqliteCredentials(diagnostics, true); - if (located?.database) { - return { - snapshot: { - path: located.path, - database: located.database, - recoveryPath: `${located.path}${KIRO_CLI_RECOVERY_SUFFIX}`, - }, - diagnostics, - blocked: false, - }; + if (kiroCliImportSelectorConfigured()) { + diagnostics.push({ location: "kiro-cli-db-env", status: "token_ambiguous" }); + return { snapshot: null, diagnostics, blocked: true }; } + + for (const { location, path } of nativeKiroCliSessionEntries()) { + if (!existsSync(path)) { + diagnostics.push({ location, status: "missing" }); + continue; + } + + let db: Database | undefined; + try { + db = new Database(path, { readonly: true }); + try { db.exec("PRAGMA busy_timeout = 5000"); } catch { /* read-only best effort */ } + } catch { + diagnostics.push({ location, status: "unreadable" }); + return { snapshot: null, diagnostics, blocked: true }; + } + + try { + const row = selectTokenRow(db); + if (row === "ambiguous") { + diagnostics.push({ location, status: "token_ambiguous" }); + return { snapshot: null, diagnostics, blocked: true }; + } + if (row === "selected_missing") { + diagnostics.push({ location, status: "token_key_missing" }); + return { snapshot: null, diagnostics, blocked: true }; + } + if (!row) { + // An existing CLI database with no recognized token is still what logout mutates. Falling + // through to Amazon Q / another platform path would snapshot the wrong store. + diagnostics.push({ location, status: "token_missing" }); + return { snapshot: null, diagnostics, blocked: true }; + } + try { + JSON.parse(row.value); + } catch { + diagnostics.push({ location, status: "invalid_json" }); + return { snapshot: null, diagnostics, blocked: true }; + } + const database = db.serialize(); + diagnostics.push({ location, status: "token_found" }); + return { + snapshot: { + path, + database, + recoveryPath: `${path}${KIRO_CLI_RECOVERY_SUFFIX}`, + }, + diagnostics, + blocked: false, + }; + } catch (error) { + if (error instanceof Error && error.message.includes("KIROCLI_TOKEN_KEY")) { + diagnostics.push({ location, status: "token_key_missing" }); + return { snapshot: null, diagnostics, blocked: true }; + } + diagnostics.push({ location, status: "schema_mismatch" }); + return { snapshot: null, diagnostics, blocked: true }; + } finally { + db.close(); + } + } + return { snapshot: null, diagnostics, @@ -486,7 +555,7 @@ export function restoreKiroCliSession(snapshot: KiroCliSessionSnapshot): void { /** Restore a transaction abandoned by a crashed process before starting another forced login. */ export function restoreStaleKiroCliSessionRecovery(): boolean { - for (const { path } of sqliteEntries()) { + for (const { path } of nativeKiroCliSessionEntries()) { const recoveryPath = `${path}${KIRO_CLI_RECOVERY_SUFFIX}`; if (!existsSync(recoveryPath)) continue; const payload = readFileSync(recoveryPath); @@ -501,13 +570,29 @@ export function restoreStaleKiroCliSessionRecovery(): boolean { `Another Kiro CLI login transaction is still in progress (pid ${recovery.ownerPid}, ${recoveryPath}).`, ); } - const snapshot: KiroCliSessionSnapshot = { - path, - database: recovery.database, - recoveryPath, - }; - restoreKiroCliSession(snapshot); - discardKiroCliSessionRecovery(snapshot); + // Atomically claim the marker before restoring so a concurrent process cannot restore the + // same stale image (and later delete a newer recovery file published by the winner). + const claimedPath = `${recoveryPath}.claimed.${process.pid}.${Date.now()}`; + try { + renameSync(recoveryPath, claimedPath); + } catch { + continue; + } + try { + const claimed = readFileSync(claimedPath); + if (!claimed.equals(payload)) { + throw new Error( + `Kiro CLI session recovery data changed while claiming ${recoveryPath}. Remove leftover claim files under the kiro-cli data directory and retry.`, + ); + } + restoreKiroCliSession({ + path, + database: recovery.database, + recoveryPath: claimedPath, + }); + } finally { + rmSync(claimedPath, { force: true }); + } return true; } return false; diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index fff7de996a..1e41ea7d1f 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -25,6 +25,7 @@ import { type KiroCliSessionSnapshot, type KiroImportDiagnostic, } from "./kiro-credentials"; +import { getAccountSet, removeAccount, saveAccountCredential } from "./store"; const DEFAULT_REGION = "us-east-1"; const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken"; @@ -149,6 +150,61 @@ function metadataFromImported(imported: ImportedKiroCredential): KiroOAuthMetada return Object.keys(metadata).length > 0 ? metadata : undefined; } +/** Persistable routing subset from explicit KIRO_* environment overrides (never local CLI state). */ +export function environmentKiroRoutingMetadata(): Pick | undefined { + const profileArn = process.env.KIRO_PROFILE_ARN?.trim() || undefined; + const apiRegion = process.env.KIRO_API_REGION !== undefined + ? requireKiroRegion(process.env.KIRO_API_REGION) + : undefined; + const ssoRegion = process.env.KIRO_REGION !== undefined + ? requireKiroRegion(process.env.KIRO_REGION) + : undefined; + if (!profileArn && !apiRegion && !ssoRegion) return undefined; + return { + ...(profileArn ? { profileArn } : {}), + ...(apiRegion ? { apiRegion } : {}), + ...(ssoRegion ? { ssoRegion } : {}), + }; +} + +/** + * Before Add-account switches the external CLI identity, bind any matching legacy identity-less + * OCX row to the current CLI session. Unmatched identity-less rows cannot be refreshed or + * reauthenticated after the switch, so they are removed instead of left selectable-but-broken. + */ +async function bindOrRemoveLegacyIdentitylessKiroAccounts( + runner: KiroCliRunner, + signal?: AbortSignal, +): Promise { + const set = getAccountSet("kiro"); + if (!set) return; + const legacyAccounts = set.accounts.filter( + account => account.credential.accountId === undefined && account.credential.email === undefined, + ); + if (legacyAccounts.length === 0) return; + + let imported: ImportedKiroCredential | null = null; + try { + imported = readKiroCliSqliteCredential(); + } catch { + imported = null; + } + + for (const account of legacyAccounts) { + const refresh = account.credential.refresh; + if (imported && refresh && imported.refresh === refresh) { + const bound = await oauthCredentialFromImported(imported, runner, signal); + if (!bound.accountId && !bound.email) { + await removeAccount("kiro", account.id); + continue; + } + await saveAccountCredential("kiro", account.id, bound); + continue; + } + await removeAccount("kiro", account.id); + } +} + async function oauthCredentialFromImported( imported: ImportedKiroCredential, runner: KiroCliRunner, @@ -209,10 +265,14 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions if (inspected.blocked) { throw new Error( "Kiro CLI session could not be backed up, so OCX will not sign it out. " + - "Resolve the local kiro-cli credential store first (see `ocx account diagnose kiro`), then retry.", + "Repair or remove the unreadable kiro-cli credential database " + + "(usually `~/.local/share/kiro-cli/data.sqlite3` or " + + "`~/Library/Application Support/kiro-cli/data.sqlite3`), " + + "unset KIROCLI_DB_PATH / KIRO_CLI_DB_FILE if set for import-only overrides, then retry.", ); } const previousSession = inspected.snapshot; + await bindOrRemoveLegacyIdentitylessKiroAccounts(runner, ctrl.signal); if (previousSession) persistKiroCliSessionRecovery(previousSession); try { const logout = await runner(["logout"], ctrl.signal); @@ -244,7 +304,14 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions const envToken = process.env.KIRO_ACCESS_TOKEN; if (envToken) { ctrl.onProgress?.("Using KIRO_ACCESS_TOKEN from environment."); - return { access: envToken, refresh: process.env.KIRO_REFRESH_TOKEN ?? "", expires: Date.now() + 3600_000, source: "environment" }; + const routing = environmentKiroRoutingMetadata(); + return { + access: envToken, + refresh: process.env.KIRO_REFRESH_TOKEN ?? "", + expires: Date.now() + 3600_000, + source: "environment", + ...(routing ? { kiro: routing } : {}), + }; } if (ctrl.onManualCodeInput) { @@ -260,7 +327,16 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions }); ctrl.onProgress?.("No kiro-cli token found. Paste a Kiro access token (starts with 'aoa'), or install the Kiro CLI and run `kiro-cli login` first."); const raw = (await ctrl.onManualCodeInput()).trim(); - if (raw) return { access: raw, refresh: "", expires: Date.now() + 3600_000, source: "manual" }; + if (raw) { + const routing = environmentKiroRoutingMetadata(); + return { + access: raw, + refresh: "", + expires: Date.now() + 3600_000, + source: "manual", + ...(routing ? { kiro: routing } : {}), + }; + } } throw new Error( @@ -364,9 +440,14 @@ async function refreshAwsSsoOidcToken( } // A stored OCX account with no usable `kiro` metadata must still refresh account-scoped: falling // through to `resolveKiroRegion(undefined)` would read KIRO_REGION or the local CLI import and - // borrow an unrelated account's region after a switch. An empty marker pins the default region. + // borrow an unrelated account's region after a switch. Environment/manual credentials may still + // honor explicit KIRO_* routing; other stored accounts pin an empty marker (default region). // Only a truly accountless refresh (no stored credential) keeps the legacy env/local fallback. - if (!metadata && credential) metadata = {}; + if (!metadata && credential) { + metadata = credential.source === "environment" || credential.source === "manual" + ? environmentKiroRoutingMetadata() ?? {} + : {}; + } const clientId = metadata?.clientId; const clientSecret = metadata?.clientSecret; if (!clientId || !clientSecret) return refreshKiroDesktopToken(refresh, signal, metadata); diff --git a/src/oauth/store.ts b/src/oauth/store.ts index f60d410eff..ad761d6a00 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -444,6 +444,29 @@ export async function removeAccount(provider: string, accountId: string): Promis }); } +/** Replace or clear a provider account set (used for transactional Kiro add-account rollback). */ +export async function replaceProviderAccountSet( + provider: string, + set: ProviderAccountSet | null, +): Promise { + await mutateStore(store => { + if (!set || set.accounts.length === 0) { + delete store[provider]; + return; + } + store[provider] = { + activeAccountId: set.activeAccountId, + accounts: set.accounts.map(account => ({ + id: account.id, + credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) }, + ...(account.alias ? { alias: account.alias } : {}), + ...(account.needsReauth ? { needsReauth: true } : {}), + ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}), + })), + }; + }); +} + export async function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): Promise { await mutateStore(store => { const account = store[provider]?.accounts.find(a => a.id === accountId); diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts index cab95536dc..a3d83bd763 100644 --- a/tests/kiro-review-regressions.test.ts +++ b/tests/kiro-review-regressions.test.ts @@ -4,13 +4,14 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { getValidAccessTokenSnapshot, OAUTH_PROVIDERS, runLogin } from "../src/oauth"; +import { loginKiro, refreshKiroToken } from "../src/oauth/kiro"; import { inspectKiroCliSessionSnapshot, persistKiroCliSessionRecovery, readKiroCliSqliteCredential, restoreStaleKiroCliSessionRecovery, } from "../src/oauth/kiro-credentials"; -import { getAccountCredential, getAccountSet, saveCredential } from "../src/oauth/store"; +import { getAccountCredential, getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; import type { OAuthController, OAuthCredentials } from "../src/oauth/types"; import type { OcxConfig } from "../src/types"; @@ -48,24 +49,45 @@ function kiroCliRecoveryPath(): string { return `${kiroCliDbPath()}.opencodex-recovery`; } +function amazonQDbPath(): string { + return join(tmp, ".local", "share", "amazon-q", "data.sqlite3"); +} + function removeKiroCliDb(): void { for (const suffix of ["", "-wal", "-shm", "-journal"]) { rmSync(`${kiroCliDbPath()}${suffix}`, { force: true }); } } -function seedKiroCliDb(access: string, refresh: string): void { - const path = kiroCliDbPath(); +function seedSqliteTokenDb( + path: string, + access: string, + refresh: string, + opts: { profileArn?: string; emptyAuthKv?: boolean } = {}, +): void { mkdirSync(join(path, ".."), { recursive: true }); const db = new Database(path); db.run("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)"); - db.run("INSERT INTO auth_kv (key, value) VALUES (?, ?)", [ - "kirocli:social:token", - JSON.stringify({ access_token: access, refresh_token: refresh }), - ]); + if (!opts.emptyAuthKv) { + db.run("INSERT INTO auth_kv (key, value) VALUES (?, ?)", [ + "kirocli:social:token", + JSON.stringify({ access_token: access, refresh_token: refresh }), + ]); + } + if (opts.profileArn) { + db.run("CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT)"); + db.run("INSERT INTO state (key, value) VALUES (?, ?)", [ + "api.codewhisperer.profile", + JSON.stringify({ arn: opts.profileArn }), + ]); + } db.close(); } +function seedKiroCliDb(access: string, refresh: string, opts: { profileArn?: string; emptyAuthKv?: boolean } = {}): void { + seedSqliteTokenDb(kiroCliDbPath(), access, refresh, opts); +} + function rewriteRecoveryProcessInstance(processInstance: string): void { const path = kiroCliRecoveryPath(); const payload = readFileSync(path); @@ -111,7 +133,16 @@ describe("Kiro review regressions", () => { }); const snapshot = await getValidAccessTokenSnapshot("kiro"); - expect(credential).toMatchObject({ access: "aoa-env", refresh: "rt-env", source: "environment" }); + expect(credential).toMatchObject({ + access: "aoa-env", + refresh: "rt-env", + source: "environment", + kiro: { + profileArn: "arn:aws:codewhisperer:ap-southeast-2:123456789012:profile/env", + apiRegion: "eu-west-1", + ssoRegion: "eu-central-1", + }, + }); expect(snapshot).toMatchObject({ accessToken: "aoa-env", kiro: { @@ -122,12 +153,50 @@ describe("Kiro review regressions", () => { }); }); - test("Kiro CLI recovery rolls back config persistence failures before settling the account switch", async () => { + test("environment credentials refresh with KIRO_REGION instead of defaulting to us-east-1", async () => { + process.env.KIRO_REGION = "eu-central-1"; + const seen: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push(url); + return new Response(JSON.stringify({ + accessToken: "aoa-refreshed", + refreshToken: "rt-refreshed", + expiresIn: 3600, + }), { status: 200 }); + }) as typeof fetch; + try { + const fresh = await refreshKiroToken("rt-env", undefined, { + access: "aoa-expired", + refresh: "rt-env", + expires: 0, + source: "environment", + }); + expect(fresh.access).toBe("aoa-refreshed"); + expect(seen.some(url => url.includes("prod.eu-central-1.auth.desktop.kiro.dev"))).toBe(true); + expect(seen.some(url => url.includes("prod.us-east-1.auth.desktop.kiro.dev"))).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("Kiro CLI recovery rolls back auth and config persistence failures", async () => { + await saveCredential("kiro", { + access: "old-access", + refresh: "old-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/old", + email: "old@example.test", + source: "local-cli", + }); + const previousActive = getAccountSet("kiro")!.activeAccountId; const rawCredential: OAuthCredentials = { access: "new-access", refresh: "new-refresh", expires: Date.now() + 60_000, accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + email: "new@example.test", source: "local-cli", }; const events: string[] = []; @@ -135,7 +204,6 @@ describe("Kiro review regressions", () => { OAUTH_PROVIDERS.kiro.login = async () => rawCredential; try { await expect(runLogin("kiro", {} as OAuthController, { forceLogin: true }, { - saveCredential: async () => { events.push("credential"); }, loadConfig: () => { events.push("load-config"); return config(); @@ -153,7 +221,97 @@ describe("Kiro review regressions", () => { OAUTH_PROVIDERS.kiro.login = originalLogin; } - expect(events).toEqual(["credential", "load-config", "save-config", "settle:false"]); + expect(events).toEqual(["load-config", "save-config", "settle:false"]); + expect(getAccountSet("kiro")?.activeAccountId).toBe(previousActive); + expect(getAccountSet("kiro")?.accounts).toHaveLength(1); + expect(getAccountCredential("kiro", previousActive)).toMatchObject({ + access: "old-access", + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/old", + }); + }); + + test("forced login refuses custom import DB selectors that diverge from the CLI store", async () => { + seedKiroCliDb("aoa-primary", "rt-primary", { + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/primary", + }); + const custom = join(tmp, "custom-import.sqlite3"); + seedSqliteTokenDb(custom, "aoa-custom", "rt-custom", { + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/custom", + }); + process.env.KIROCLI_DB_PATH = custom; + const calls: string[][] = []; + await expect(loginKiro({} as OAuthController, { + forceLogin: true, + cliRunner: async args => { + calls.push(args); + return { exitCode: 0, stdout: "" }; + }, + })).rejects.toThrow(/will not sign it out|KIROCLI_DB_PATH|KIRO_CLI_DB_FILE/); + expect(calls).toEqual([]); + expect(readFileSync(kiroCliDbPath()).length).toBeGreaterThan(0); + expect(inspectKiroCliSessionSnapshot()).toMatchObject({ blocked: true, snapshot: null }); + }); + + test("forced login refuses when the primary CLI store exists but only a later fallback is readable", async () => { + mkdirSync(join(kiroCliDbPath(), ".."), { recursive: true }); + writeFileSync(kiroCliDbPath(), "not-a-sqlite-database", { mode: 0o600 }); + seedSqliteTokenDb(amazonQDbPath(), "aoa-fallback", "rt-fallback", { + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/fallback", + }); + const calls: string[][] = []; + await expect(loginKiro({} as OAuthController, { + forceLogin: true, + cliRunner: async args => { + calls.push(args); + return { exitCode: 0, stdout: "" }; + }, + })).rejects.toThrow(/will not sign it out/); + expect(calls).toEqual([]); + expect(existsSync(kiroCliDbPath())).toBe(true); + expect(readFileSync(kiroCliDbPath(), "utf8")).toBe("not-a-sqlite-database"); + }); + + test("Add account binds a legacy identity-less Kiro row before switching and keeps it selectable", async () => { + const legacyArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/legacy"; + const nextArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/next"; + seedKiroCliDb("aoa-legacy", "rt-legacy", { profileArn: legacyArn }); + await saveCredential("kiro", { + access: "aoa-legacy", + refresh: "rt-legacy", + expires: Date.now() + 60_000, + source: "local-cli", + }); + const legacySlot = getAccountSet("kiro")!.activeAccountId; + + const credential = await loginKiro({} as OAuthController, { + forceLogin: true, + cliRunner: async args => { + if (args[0] === "whoami") { + return { exitCode: 0, stdout: JSON.stringify({ email: "legacy@example.test" }) }; + } + if (args[0] === "logout") { + removeKiroCliDb(); + return { exitCode: 0, stdout: "" }; + } + if (args[0] === "login") { + seedKiroCliDb("aoa-next", "rt-next", { profileArn: nextArn }); + return { exitCode: 0, stdout: "" }; + } + return { exitCode: 1, stdout: "" }; + }, + }); + await saveCredential("kiro", credential, { preserveIdentityless: true }); + + const set = getAccountSet("kiro")!; + expect(set.accounts.length).toBeGreaterThanOrEqual(2); + const legacy = getAccountCredential("kiro", legacySlot); + expect(legacy).toMatchObject({ + accountId: legacyArn, + refresh: "rt-legacy", + kiro: { profileArn: legacyArn }, + }); + expect(await setActiveAccount("kiro", legacySlot)).toBe(true); + expect(getAccountSet("kiro")?.activeAccountId).toBe(legacySlot); }); test("Kiro reauth accepts the same email when the refreshed credential gains a profile ARN", async () => { From 20e7d13a8583f9f3fb49c1f3cdfc4c5a89f483e7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:36:20 +0200 Subject: [PATCH 3/3] fix(kiro): address post-takeover Codex follow-ups Preserve unmatched legacy credentials across cancelled Add-account flows, keep claimed recovery data when restore fails, logout empty-prior CLI sessions on persistence failure, and surgically roll back only newly written accounts while always attempting CLI settlement. --- src/oauth/index.ts | 53 ++++++++++++++++++----------- src/oauth/kiro-credentials.ts | 11 ++++-- src/oauth/kiro.ts | 63 ++++++++++++++++++++++++----------- 3 files changed, 86 insertions(+), 41 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 6ef10b83a1..2eb48de773 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -4,7 +4,7 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { loadConfig, resolveEnvValue, saveConfig } from "../config"; import { maskEmail } from "../lib/privacy"; import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; -import { getAccountCredential, getAccountSet, replaceProviderAccountSet, saveAccountCredential, saveCredential, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; +import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai"; import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; @@ -647,7 +647,26 @@ interface RunLoginDeps { loadConfig?: typeof loadConfig; saveConfig?: typeof saveConfig; settleKiroLoginTransaction?: typeof settleKiroLoginTransaction; - replaceProviderAccountSet?: typeof replaceProviderAccountSet; + removeAccount?: typeof removeAccount; + setActiveAccount?: typeof setActiveAccount; +} + +/** Roll back only accounts created by this forced login, preserving concurrent refreshes of others. */ +async function rollbackForcedKiroAccountWrite( + provider: string, + previousActiveId: string | undefined, + previousAccountIds: ReadonlySet, + deps: Pick, +): Promise { + const set = getAccountSet(provider); + if (!set) return; + for (const account of [...set.accounts]) { + if (previousAccountIds.has(account.id)) continue; + await (deps.removeAccount ?? removeAccount)(provider, account.id); + } + if (previousActiveId && getAccountCredential(provider, previousActiveId)) { + await (deps.setActiveAccount ?? setActiveAccount)(provider, previousActiveId); + } } /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */ @@ -663,20 +682,8 @@ export async function runLogin( // for settlement even when source normalization below creates a derived credential object. const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true; const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined; - const previousKiroAccountsSnapshot = shouldRollbackKiroAccounts - ? (previousKiroAccounts - ? { - activeAccountId: previousKiroAccounts.activeAccountId, - accounts: previousKiroAccounts.accounts.map(account => ({ - id: account.id, - credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) }, - ...(account.alias ? { alias: account.alias } : {}), - ...(account.needsReauth ? { needsReauth: true } : {}), - ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}), - })), - } - : null) - : undefined; + const previousKiroActiveId = previousKiroAccounts?.activeAccountId; + const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []); const rawCred = await def.login(ctrl, opts); const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; @@ -707,14 +714,22 @@ export async function runLogin( (deps.saveConfig ?? saveConfig)(config); } } catch (error) { - if (previousKiroAccountsSnapshot !== undefined) { - await (deps.replaceProviderAccountSet ?? replaceProviderAccountSet)(provider, previousKiroAccountsSnapshot); + const errors: unknown[] = [error]; + if (shouldRollbackKiroAccounts) { + try { + await rollbackForcedKiroAccountWrite(provider, previousKiroActiveId, previousKiroAccountIds, deps); + } catch (rollbackError) { + errors.push(rollbackError); + } } try { settleKiroTransaction(rawCred, false); } catch (restoreError) { + errors.push(restoreError); + } + if (errors.length > 1) { throw new AggregateError( - [error, restoreError], + errors, "Kiro login persistence failed and the previous Kiro CLI session could not be restored.", ); } diff --git a/src/oauth/kiro-credentials.ts b/src/oauth/kiro-credentials.ts index b773ca3000..dc54d21cbd 100644 --- a/src/oauth/kiro-credentials.ts +++ b/src/oauth/kiro-credentials.ts @@ -581,6 +581,8 @@ export function restoreStaleKiroCliSessionRecovery(): boolean { try { const claimed = readFileSync(claimedPath); if (!claimed.equals(payload)) { + // Put the unexpected claim back so an operator / later process can inspect it. + try { renameSync(claimedPath, recoveryPath); } catch { /* keep claimed path for manual recovery */ } throw new Error( `Kiro CLI session recovery data changed while claiming ${recoveryPath}. Remove leftover claim files under the kiro-cli data directory and retry.`, ); @@ -590,10 +592,15 @@ export function restoreStaleKiroCliSessionRecovery(): boolean { database: recovery.database, recoveryPath: claimedPath, }); - } finally { rmSync(claimedPath, { force: true }); + return true; + } catch (error) { + // Preserve the only copy of the prior database when restore fails (disk full, permissions). + if (existsSync(claimedPath) && !existsSync(recoveryPath)) { + try { renameSync(claimedPath, recoveryPath); } catch { /* claimed file remains for manual recovery */ } + } + throw error; } - return true; } return false; } diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index 1e41ea7d1f..fd47be8cfe 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -25,7 +25,7 @@ import { type KiroCliSessionSnapshot, type KiroImportDiagnostic, } from "./kiro-credentials"; -import { getAccountSet, removeAccount, saveAccountCredential } from "./store"; +import { getAccountSet, saveAccountCredential } from "./store"; const DEFAULT_REGION = "us-east-1"; const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken"; @@ -69,17 +69,41 @@ export interface KiroLoginOptions { } const pendingKiroLoginTransactions = new WeakMap(); +/** Forced logins that started with no native CLI DB must logout on persistence failure. */ +const pendingKiroEmptyPriorSessions = new WeakSet(); + +function logoutKiroCliBestEffort(): void { + try { + Bun.spawnSync(["kiro-cli", "logout"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + windowsHide: true, + }); + } catch { + // Best-effort rollback when the prior CLI state was empty. + } +} /** Settle the external CLI side of a forced login after OCX credential persistence resolves. */ export function settleKiroLoginTransaction(credential: OAuthCredentials, persisted: boolean): void { const snapshot = pendingKiroLoginTransactions.get(credential); - if (!snapshot) return; - if (!persisted) restoreKiroCliSession(snapshot); - discardKiroCliSessionRecovery(snapshot); + const emptyPrior = pendingKiroEmptyPriorSessions.has(credential); pendingKiroLoginTransactions.delete(credential); + pendingKiroEmptyPriorSessions.delete(credential); + if (!persisted) { + if (snapshot) { + restoreKiroCliSession(snapshot); + discardKiroCliSessionRecovery(snapshot); + return; + } + if (emptyPrior) logoutKiroCliBestEffort(); + return; + } + if (snapshot) discardKiroCliSessionRecovery(snapshot); } -function restoreKiroLoginOrThrow(snapshot: KiroCliSessionSnapshot | null, cause: unknown): never { +function restoreKiroLoginOrThrow(snapshot: KiroCliSessionSnapshot | null, emptyPrior: boolean, cause: unknown): never { if (snapshot) { try { restoreKiroCliSession(snapshot); @@ -90,6 +114,8 @@ function restoreKiroLoginOrThrow(snapshot: KiroCliSessionSnapshot | null, cause: "Kiro login failed and the previous Kiro CLI session could not be restored.", ); } + } else if (emptyPrior) { + logoutKiroCliBestEffort(); } throw cause; } @@ -169,10 +195,11 @@ export function environmentKiroRoutingMetadata(): Pick { @@ -189,19 +216,14 @@ async function bindOrRemoveLegacyIdentitylessKiroAccounts( } catch { imported = null; } + if (!imported?.refresh) return; for (const account of legacyAccounts) { const refresh = account.credential.refresh; - if (imported && refresh && imported.refresh === refresh) { - const bound = await oauthCredentialFromImported(imported, runner, signal); - if (!bound.accountId && !bound.email) { - await removeAccount("kiro", account.id); - continue; - } - await saveAccountCredential("kiro", account.id, bound); - continue; - } - await removeAccount("kiro", account.id); + if (!refresh || imported.refresh !== refresh) continue; + const bound = await oauthCredentialFromImported(imported, runner, signal); + if (!bound.accountId && !bound.email) continue; + await saveAccountCredential("kiro", account.id, bound); } } @@ -272,7 +294,7 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions ); } const previousSession = inspected.snapshot; - await bindOrRemoveLegacyIdentitylessKiroAccounts(runner, ctrl.signal); + await bindMatchingLegacyIdentitylessKiroAccounts(runner, ctrl.signal); if (previousSession) persistKiroCliSessionRecovery(previousSession); try { const logout = await runner(["logout"], ctrl.signal); @@ -289,9 +311,10 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions throw new Error("Kiro login completed but OCX could not determine a stable account identity."); } if (previousSession) pendingKiroLoginTransactions.set(credential, previousSession); + else pendingKiroEmptyPriorSessions.add(credential); return credential; } catch (error) { - restoreKiroLoginOrThrow(previousSession, error); + restoreKiroLoginOrThrow(previousSession, !previousSession, error); } }