diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d7f924a3f..e40bcd4f4 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -134,8 +134,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. Only identity-less Kimi credentials replace the -active slot; Kiro accounts are keyed by profile ARN. `chatgpt` is always single-slot because Codex +active account without logging the others out. A normal login with an identity-less Kimi credential +replaces the active slot, while an explicit **Add account** preserves that slot and activates a new, +distinct one. 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. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 1735b2c54..98e1965d0 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -129,7 +129,7 @@ opt-in した上流がこのフィールドを拒否しても、opencodex はフ 認証情報に固定アカウント ID やメールがある OAuth プロバイダーはログインを複数保持できます。 Providers ページでアカウントを追加し、別アカウントをログアウトせずにアクティブアカウントだけを切り替えられます。 -アカウント識別情報がない Kimi 認証情報だけがアクティブスロットを差し替え、Kiro アカウントはプロファイル ARN をキーに保存されます。 +アカウント識別情報がない Kimi 認証情報は通常のログインではアクティブスロットを差し替えますが、明示的な **アカウントを追加** では既存スロットを保持し、別の新しいスロットをアクティブにします。Kiro アカウントはプロファイル ARN をキーに保存されます。 `chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 `/api/oauth/accounts` はマスク済みメタデータのみを返します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index a6bdf205a..9926dc46d 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -128,7 +128,7 @@ deny-by-default 상태로 유지됩니다. 자격 증명에 고정된 계정 id나 이메일이 있는 OAuth 프로바이더는 로그인을 여러 개 보관할 수 있습니다. Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃하지 않은 채 활성 계정만 바꿀 수 있습니다. -계정 식별 정보가 없는 Kimi 자격 증명만 활성 슬롯을 교체하며, Kiro 계정은 프로필 ARN을 키로 저장됩니다. +계정 식별 정보가 없는 Kimi 자격 증명은 일반 로그인에서는 활성 슬롯을 교체하지만, 명시적인 **계정 추가**에서는 기존 슬롯을 보존하고 별도의 새 슬롯을 활성화합니다. Kiro 계정은 프로필 ARN을 키로 저장됩니다. `chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, `/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index c49377126..b6d640cc0 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -138,8 +138,9 @@ OAuth можно запустить и из [веб-дашборда](/ru/guides OAuth-провайдеры, чьи учётные данные содержат стабильный id аккаунта или email, могут хранить несколько входов. Страница Providers показывает эти аккаунты в выпадающем списке, позволяет -добавить ещё один и переключает активный аккаунт, не выполняя выход из остальных. Учётные данные -Только учётные данные Kimi без идентификатора заменяют активный слот; аккаунты Kiro сохраняются по ARN профиля. +добавить ещё один и переключает активный аккаунт, не выполняя выход из остальных. При обычном входе +учётные данные Kimi без идентификатора заменяют активный слот; явное действие **Добавить аккаунт** +сохраняет прежний слот и активирует отдельный новый. Аккаунты Kiro сохраняются по ARN профиля. `chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; `/api/oauth/accounts` возвращает только маскированные метаданные. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index a878ee752..c5e036ab3 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -158,8 +158,9 @@ başlatabilirsiniz. Kimlik bilgileri kararlı bir hesap kimliği veya e-posta içeren OAuth sağlayıcıları birden fazla oturum tutabilir. Sağlayıcılar sayfası bu hesapları bir açılır menüde gösterir, başka bir tane eklemenize izin verir ve diğerlerinin -oturumunu kapatmadan etkin hesabı değiştirir. Yalnızca kimliği olmayan Kimi -kimlik bilgileri etkin yuvanın yerini alır; Kiro hesapları profil ARN'sine göre +oturumunu kapatmadan etkin hesabı değiştirir. Normal oturum açmada kimliği olmayan +Kimi kimlik bilgileri etkin yuvanın yerini alır; açık **Hesap ekle** akışı mevcut +yuvayı korur ve ayrı yeni yuvayı etkinleştirir. Kiro hesapları profil ARN'sine göre anahtarlanır. `chatgpt` her zaman tek yuvalıdır çünkü Codex havuz hesaplarının ayrı bir defteri vardır. Belirteçler `~/.opencodex/auth.json` içinde kalır; `/api/oauth/accounts` yalnızca maskelenmiş meta verileri döndürür. @@ -662,4 +663,3 @@ Kimi, Google Antigravity, OpenRouter, DeepSeek, ClinePass, Z.AI, MiniMax, Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt ve a6api destekli herhangi bir özel sağlayıcı. - 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 b25e1284d..2d2afefde 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -117,8 +117,9 @@ provider 仍保持 deny-by-default。 ### 多个 OAuth 账号 OAuth 凭据中带有稳定账号 id 或邮箱的提供商可以保存多个登录。Providers 页面会在下拉列表中显示这些 -账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。只有没有身份信息的 Kimi 凭据会替换 -当前 active slot;Kiro 账户以配置文件 ARN 为键。`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 +账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。普通登录时,没有身份信息的 Kimi 凭据会替换 +当前 active slot;显式 **添加账号** 会保留原有 slot 并激活一个独立的新 slot。Kiro 账户以配置文件 ARN 为键。 +`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 `~/.opencodex/auth.json` 中;`/api/oauth/accounts` 只返回脱敏后的 metadata。 ### Cockpit Tools Antigravity 导入 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index c330b1053..1c536f56b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -124,8 +124,9 @@ session/task key 有助提升 Code Plan cache hit rate;沒有 key 的請求 ### 多個 OAuth 帳號 credential 內含穩定 account id 或 email 的 OAuth provider 可以保存多個登入。Providers 頁面會在下拉 -選單顯示這些帳號、允許新增帳號,並在不登出其他帳號的情況下切換目前帳號。只有沒有 identity 的 Kimi -credential 會取代 active slot;Kiro 帳號以 profile ARN 作為 key。`chatgpt` 始終是 single-slot,因為 +選單顯示這些帳號、允許新增帳號,並在不登出其他帳號的情況下切換目前帳號。一般登入時,沒有 identity 的 +Kimi credential 會取代 active slot;明確的 **新增帳號** 會保留原有 slot 並啟用另一個新 slot。Kiro 帳號以 +profile ARN 作為 key。`chatgpt` 始終是 single-slot,因為 Codex pool 帳號使用獨立 ledger。Token 仍存放在 `~/.opencodex/auth.json`;`/api/oauth/accounts` 只回傳 遮蔽後的 metadata。 diff --git a/src/oauth/index.ts b/src/oauth/index.ts index d6907dc68..afc14a662 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1124,7 +1124,7 @@ export async function runLogin( await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred); } else { await (deps.saveCredential ?? saveCredential)(provider, cred, { - preserveIdentityless: provider === "kiro" && opts?.forceLogin === true, + preserveIdentityless: opts?.forceLogin === true, }); } if (provider !== "chatgpt") { diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 478f9c73f..694338eed 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -10,10 +10,11 @@ * 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) 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. + * - Credentials without identity (no accountId/email) replace the active slot on a normal + * login: their refresh tokens rotate, so a derived id would duplicate the same human on every + * re-login. An explicit add-account login instead preserves the prior slot and appends a + * distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` — + * both append distinct identified accounts under multiauth. */ import { createHash, randomUUID } from "node:crypto"; import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; @@ -300,6 +301,17 @@ function newAccountId(cred: OAuthCredentials): string { return createHash("sha256").update(identity).digest("hex").slice(0, 32); } +/** Allocate a persisted slot id without reusing any existing account's ownership key. */ +function distinctAccountId(cred: OAuthCredentials, accounts: readonly ProviderAccount[]): string { + const base = newAccountId(cred); + const occupied = new Set(accounts.map(account => account.id)); + if (!occupied.has(base)) return base; + for (let suffix = 1; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!occupied.has(candidate)) return candidate; + } +} + function normalizeAccount(value: unknown): ProviderAccount | null { if (!value || typeof value !== "object") return null; const candidate = value as Partial; @@ -473,7 +485,8 @@ export function getCredential(provider: string): OAuthCredentials | null { * Persist a credential as the ACTIVE account. Identity-matching (accountId ?? email) upserts * the same human's slot; a new identity appends a new account. Credentials without identity * (rotating refresh tokens would fabricate duplicates) and single-slot providers replace the - * active slot / whole set instead. + * active slot / whole set instead. An explicit add-account login can preserve the legacy slot; + * an identity-less credential then gets its deterministic refresh-derived account id. */ export async function saveCredential( provider: string, @@ -508,18 +521,24 @@ export async function saveCredential( delete active.needsReauth; return; } - const id = newAccountId(safe); + const id = distinctAccountId(safe, set.accounts); set.accounts.push({ id, credential: safe, addedAt: Date.now() }); set.activeAccountId = id; return; } - // No identity: replace the active slot in place (single-account semantics). + if (opts.preserveIdentityless) { + const id = distinctAccountId(safe, set.accounts); + set.accounts.push({ id, credential: safe, addedAt: Date.now() }); + set.activeAccountId = id; + return; + } + // No identity during a normal login: replace the active slot in place. const active = set.accounts.find(a => a.id === set.activeAccountId); if (active) { active.credential = safe; delete active.needsReauth; } else { - const id = newAccountId(safe); + const id = distinctAccountId(safe, set.accounts); set.accounts.push({ id, credential: safe, addedAt: Date.now() }); set.activeAccountId = id; } diff --git a/structure/00_overview.md b/structure/00_overview.md index bb204211e..9d57b3b65 100644 --- a/structure/00_overview.md +++ b/structure/00_overview.md @@ -73,7 +73,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th | Path | Owner | Notes | | --- | --- | --- | | `~/.opencodex/config.json` | opencodex | Main config written by `ocx init` and the dashboard. Atomic temp-then-rename. | -| `~/.opencodex/auth.json` | opencodex | OAuth tokens; not committed. Multiauth shape: `provider -> { activeAccountId, accounts[] }` (legacy single-credential values normalize on load; a one-time `auth.json.pre-multiauth` backup guards downgrades). ChatGPT scratch OAuth stays separate from the Codex account store; identity-less providers (kimi/kiro/cursor) replace their active slot. | +| `~/.opencodex/auth.json` | opencodex | OAuth tokens; not committed. Multiauth shape: `provider -> { activeAccountId, accounts[] }` (legacy single-credential values normalize on load; a one-time `auth.json.pre-multiauth` backup guards downgrades). ChatGPT scratch OAuth stays separate from the Codex account store. For multi-slot providers, credentials without `accountId`/email replace the active slot on a normal login; an explicit add-account login preserves the prior slot and appends a distinct one. Single-slot providers such as ChatGPT remain replacement-only. | | `~/.opencodex/codex-accounts.json` | opencodex | Hardened main-plus-added credential store used by `openai` in Pool mode. | | `~/.opencodex/catalog-backup.json` | opencodex | One-time pristine Codex catalog backup for restore; per-catalog copies are hashed variants (see [`03_catalog-and-subagents.md`](03_catalog-and-subagents.md)). | | `~/.opencodex/usage.jsonl` | opencodex | Append-only request usage log (0o600); request metadata + token counts only, never prompts or auth. | diff --git a/tests/oauth-reauth-bind.test.ts b/tests/oauth-reauth-bind.test.ts index 8a236a9f0..c3a9b3f13 100644 --- a/tests/oauth-reauth-bind.test.ts +++ b/tests/oauth-reauth-bind.test.ts @@ -136,6 +136,101 @@ describe("OAuth account-scoped reauth", () => { expect(getAccountCredential("kiro", set.activeAccountId)?.access).toBe("identified-access"); }); + test("forced Kimi add-account preserves a legacy identity-less account", async () => { + await saveCredential("kimi", { + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60_000, + }); + const legacySlotId = getAccountSet("kimi")!.activeAccountId; + const original = OAUTH_PROVIDERS.kimi.login; + OAUTH_PROVIDERS.kimi.login = async () => ({ + access: "identified-access", + refresh: "identified-refresh", + expires: Date.now() + 60_000, + accountId: "new-kimi-user", + }); + try { + await runLogin("kimi", {} as OAuthController, { forceLogin: true }); + } finally { + OAUTH_PROVIDERS.kimi.login = original; + } + + const set = getAccountSet("kimi")!; + expect(set.accounts).toHaveLength(2); + expect(set.activeAccountId).not.toBe(legacySlotId); + expect(getAccountCredential("kimi", legacySlotId)).toMatchObject({ + access: "legacy-access", + refresh: "legacy-refresh", + }); + expect(getAccountCredential("kimi", set.activeAccountId)).toMatchObject({ + access: "identified-access", + accountId: "new-kimi-user", + }); + }); + + test("forced Kimi add-account also preserves the legacy slot for opaque tokens", async () => { + await saveCredential("kimi", { + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60_000, + }); + const legacySlotId = getAccountSet("kimi")!.activeAccountId; + const original = OAUTH_PROVIDERS.kimi.login; + OAUTH_PROVIDERS.kimi.login = async () => ({ + access: "opaque-new-access", + refresh: "opaque-new-refresh", + expires: Date.now() + 60_000, + }); + try { + await runLogin("kimi", {} as OAuthController, { forceLogin: true }); + } finally { + OAUTH_PROVIDERS.kimi.login = original; + } + + const set = getAccountSet("kimi")!; + expect(set.accounts).toHaveLength(2); + expect(set.activeAccountId).not.toBe(legacySlotId); + expect(getAccountCredential("kimi", legacySlotId)).toMatchObject({ + access: "legacy-access", + refresh: "legacy-refresh", + }); + expect(getAccountCredential("kimi", set.activeAccountId)).toMatchObject({ + access: "opaque-new-access", + refresh: "opaque-new-refresh", + }); + }); + + test("non-force Kimi login upgrades the legacy identity-less slot in place", async () => { + await saveCredential("kimi", { + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60_000, + }); + const legacySlotId = getAccountSet("kimi")!.activeAccountId; + const original = OAUTH_PROVIDERS.kimi.login; + OAUTH_PROVIDERS.kimi.login = async () => ({ + access: "identified-access", + refresh: "identified-refresh", + expires: Date.now() + 60_000, + accountId: "existing-kimi-user", + }); + try { + await runLogin("kimi", {} as OAuthController); + } finally { + OAUTH_PROVIDERS.kimi.login = original; + } + + const set = getAccountSet("kimi")!; + expect(set.accounts).toHaveLength(1); + expect(set.activeAccountId).toBe(legacySlotId); + expect(getAccountCredential("kimi", legacySlotId)).toMatchObject({ + access: "identified-access", + refresh: "identified-refresh", + accountId: "existing-kimi-user", + }); + }); + test("non-force Kiro login upgrades a legacy identity-less slot in place", async () => { await saveCredential("kiro", { access: "legacy-access", diff --git a/tests/oauth-store-multi.test.ts b/tests/oauth-store-multi.test.ts index 1e6b47e6b..50893daed 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -145,6 +145,66 @@ describe("multi-account auth store", () => { expect(getCredential("cursor")?.access).toBe("rotated"); }); + test("explicit add-account preserves an identity-less slot and activates the new one", async () => { + await saveCredential("cursor", cred({ access: "legacy-access", refresh: "legacy-refresh" })); + const legacySlotId = getAccountSet("cursor")!.activeAccountId; + + await saveCredential("cursor", cred({ + access: "new-access", + refresh: "new-refresh", + }), { preserveIdentityless: true }); + + const set = getAccountSet("cursor")!; + expect(set.accounts).toHaveLength(2); + expect(set.activeAccountId).not.toBe(legacySlotId); + expect(getAccountCredential("cursor", legacySlotId)).toMatchObject({ + access: "legacy-access", + refresh: "legacy-refresh", + }); + expect(getCredential("cursor")).toMatchObject({ + access: "new-access", + refresh: "new-refresh", + }); + }); + + test("explicit add-account does not reuse the legacy slot when opaque credentials share a refresh token", async () => { + await saveCredential("cursor", cred({ access: "legacy-access", refresh: "shared-refresh" })); + const legacySlotId = getAccountSet("cursor")!.activeAccountId; + + await saveCredential("cursor", cred({ + access: "new-access", + refresh: "shared-refresh", + }), { preserveIdentityless: true }); + + const set = getAccountSet("cursor")!; + expect(set.accounts).toHaveLength(2); + expect(new Set(set.accounts.map(account => account.id)).size).toBe(2); + expect(set.activeAccountId).not.toBe(legacySlotId); + expect(getAccountCredential("cursor", legacySlotId)?.access).toBe("legacy-access"); + expect(getCredential("cursor")?.access).toBe("new-access"); + }); + + test("explicit add-account keeps slot ids distinct across refresh and verified-identity seed collisions", async () => { + await saveCredential("cursor", cred({ access: "legacy-access", refresh: "shared-seed" })); + const legacySlotId = getAccountSet("cursor")!.activeAccountId; + + await saveCredential("cursor", cred({ + access: "identified-access", + refresh: "identified-refresh", + accountId: "shared-seed", + }), { preserveIdentityless: true }); + + const set = getAccountSet("cursor")!; + expect(set.accounts).toHaveLength(2); + expect(new Set(set.accounts.map(account => account.id)).size).toBe(2); + expect(set.activeAccountId).not.toBe(legacySlotId); + expect(getAccountCredential("cursor", legacySlotId)?.access).toBe("legacy-access"); + expect(getCredential("cursor")).toMatchObject({ + access: "identified-access", + accountId: "shared-seed", + }); + }); + test("cursor with distinct accountIds appends a second account", async () => { await saveCredential("cursor", cred({ accountId: "google-oauth2|user_a", access: "access-a" })); await saveCredential("cursor", cred({ accountId: "google-oauth2|user_b", access: "access-b" }));