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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ja/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ opt-in した上流がこのフィールドを拒否しても、opencodex はフ

認証情報に固定アカウント ID やメールがある OAuth プロバイダーはログインを複数保持できます。
Providers ページでアカウントを追加し、別アカウントをログアウトせずにアクティブアカウントだけを切り替えられます。
アカウント識別情報がない Kimi 認証情報だけがアクティブスロットを差し替え、Kiro アカウントはプロファイル ARN をキーに保存されます。
アカウント識別情報がない Kimi 認証情報は通常のログインではアクティブスロットを差し替えますが、明示的な **アカウントを追加** では既存スロットを保持し、別の新しいスロットをアクティブにします。Kiro アカウントはプロファイル ARN をキーに保存されます。
`chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、
`/api/oauth/accounts` はマスク済みメタデータのみを返します。

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ko/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ deny-by-default 상태로 유지됩니다.

자격 증명에 고정된 계정 id나 이메일이 있는 OAuth 프로바이더는 로그인을 여러 개 보관할 수 있습니다.
Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃하지 않은 채 활성 계정만 바꿀 수 있습니다.
계정 식별 정보가 없는 Kimi 자격 증명만 활성 슬롯을 교체하며, Kiro 계정은 프로필 ARN을 키로 저장됩니다.
계정 식별 정보가 없는 Kimi 자격 증명은 일반 로그인에서는 활성 슬롯을 교체하지만, 명시적인 **계정 추가**에서는 기존 슬롯을 보존하고 별도의 새 슬롯을 활성화합니다. Kiro 계정은 프로필 ARN을 키로 저장됩니다.
`chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고,
`/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다.

Expand Down
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/ru/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` возвращает только маскированные метаданные.

Expand Down
6 changes: 3 additions & 3 deletions docs-site/src/content/docs/tr/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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ı.


5 changes: 3 additions & 2 deletions docs-site/src/content/docs/zh-cn/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 导入
Expand Down
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/zh-tw/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

Expand Down
2 changes: 1 addition & 1 deletion src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
35 changes: 27 additions & 8 deletions src/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ProviderAccount>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Comment thread
luvs01 marked this conversation as resolved.
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;
}
Expand Down
2 changes: 1 addition & 1 deletion structure/00_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
95 changes: 95 additions & 0 deletions tests/oauth-reauth-bind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading