-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Import Codex accounts registered in Orca #4394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import type { importOrcaAccounts } from "../codex/orca-import"; | ||
|
|
||
| const USAGE = "Usage: ocx account import-orca --source <orca-data-directory> --registry <orca-data.json> [--apply] [--json]"; | ||
|
|
||
| export interface OrcaImportCommandDeps { | ||
| importAccounts?: typeof importOrcaAccounts; | ||
| } | ||
|
|
||
| /** Local-only: never sends source paths or credentials to a management listener. */ | ||
| export async function cmdOrcaImport(args: string[], deps: OrcaImportCommandDeps = {}): Promise<number> { | ||
| let sourceDir: string | undefined; | ||
| let registryPath: string | undefined; | ||
| let apply = false; | ||
| const wantsJson = args.includes("--json"); | ||
| const seen = new Set<string>(); | ||
| for (let index = 0; index < args.length; index++) { | ||
| const arg = args[index]!; | ||
| if (seen.has(arg) || !["--source", "--registry", "--apply", "--json"].includes(arg)) { | ||
| console.error(USAGE); | ||
| return 1; | ||
| } | ||
| seen.add(arg); | ||
| if (arg === "--source" || arg === "--registry") { | ||
| const value = args[++index]; | ||
| if (!value || value.startsWith("--")) { | ||
| console.error(USAGE); | ||
| return 1; | ||
| } | ||
| if (arg === "--source") sourceDir = value; | ||
| else registryPath = value; | ||
| } else if (arg === "--apply") apply = true; | ||
| } | ||
| if (!sourceDir || !registryPath) { | ||
| console.error(USAGE); | ||
| return 1; | ||
| } | ||
| try { | ||
| const run = deps.importAccounts ?? (await import("../codex/orca-import")).importOrcaAccounts; | ||
| const result = await run({ sourceDir, registryPath, apply }); | ||
| if (wantsJson) console.log(JSON.stringify({ | ||
| mode: result.mode, discovered: result.discovered, eligible: result.eligible, | ||
| imported: result.imported, duplicates: result.duplicates, invalid: result.invalid, | ||
| })); | ||
| else { | ||
| console.log(`Orca: ${result.discovered} discovered, ${result.eligible} eligible, ${result.imported} imported, ${result.duplicates} duplicates, ${result.invalid} invalid.`); | ||
| console.log(apply | ||
| ? "Start the proxy and use Refresh quotas in Codex Auth to validate new accounts. Orca retains refresh ownership." | ||
| : "Preview only. Stop the proxy before repeating with --apply. Orca authentication files stay read-only."); | ||
| } | ||
| return result.invalid > 0 ? 1 : 0; | ||
| } catch { | ||
| // Filesystem/JSON exceptions may contain source paths or token fragments. | ||
| const error = "Orca import failed. Check the source, destination config, and stopped proxy; no successful completion is confirmed."; | ||
| if (wantsJson) console.log(JSON.stringify({ error: "orca_import_failed" })); | ||
| else console.error(error); | ||
| return 1; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { readOrcaAuthSource } from "./orca-auth-source"; | ||
| import { createHash } from "node:crypto"; | ||
| import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
@@ -46,7 +47,9 @@ function isCredential(value: unknown): value is CodexAccountCredentials { | |
| && typeof value.accessToken === "string" | ||
| && typeof value.refreshToken === "string" | ||
| && typeof value.expiresAt === "number" | ||
| && typeof value.chatgptAccountId === "string"; | ||
| && typeof value.chatgptAccountId === "string" | ||
| && (value.sourceAuthPath === undefined || typeof value.sourceAuthPath === "string") | ||
| && (value.sourceSubject === undefined || typeof value.sourceSubject === "string"); | ||
| } | ||
|
|
||
| function isCredentialRecord(value: unknown): value is CodexAccountCredentialRecord { | ||
|
|
@@ -68,6 +71,7 @@ export function refreshGrantFingerprintForToken(refreshToken: string): string { | |
| } | ||
|
|
||
| function recordGrantFingerprint(record: CodexAccountCredentialRecord): string | undefined { | ||
| if (record.credential?.sourceAuthPath) return undefined; | ||
| return record.refreshGrantFingerprint ?? ( | ||
| record.credential ? refreshGrantFingerprintForToken(record.credential.refreshToken) : undefined | ||
| ); | ||
|
|
@@ -327,7 +331,7 @@ export function commitRefreshedCodexCredentialWithAliases( | |
| return withCredentialMutationLockSync(() => { | ||
| const store = loadCodexAccountRecordStore(); | ||
| const current = store[id]; | ||
| if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) { | ||
| if (!current || current.generation !== generation || current.deletedAt != null || !current.credential || current.credential.sourceAuthPath) { | ||
| return { committed: false, propagatedAliases: [] }; | ||
| } | ||
| const priorCredential = current.credential; | ||
|
|
@@ -356,7 +360,7 @@ export function commitRefreshedCodexCredentialWithAliases( | |
| && !!priorCredential.chatgptAccountId | ||
| ) { | ||
| for (const [aliasId, alias] of Object.entries(store)) { | ||
| if (aliasId === id || alias.deletedAt != null || !alias.credential) continue; | ||
| if (aliasId === id || alias.deletedAt != null || !alias.credential || alias.credential.sourceAuthPath) continue; | ||
| if (recordGrantFingerprint(alias) !== priorFingerprint) continue; | ||
| if (alias.credential.accessToken !== priorCredential.accessToken) continue; | ||
| if (alias.credential.expiresAt !== priorCredential.expiresAt) continue; | ||
|
|
@@ -593,7 +597,7 @@ function findFreshCredentialForGrant( | |
| // require both ids to be present and exactly equal rather than inferring identity from the grant. | ||
| if (!expectedChatgptAccountId) return null; | ||
| for (const [candidateId, candidate] of Object.entries(records)) { | ||
| if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue; | ||
| if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential || candidate.credential.sourceAuthPath) continue; | ||
| if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue; | ||
| if (!candidate.credential.chatgptAccountId) continue; | ||
| if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue; | ||
|
|
@@ -760,6 +764,31 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult> | |
| }; | ||
| } | ||
|
|
||
| /** Source credentials never join refresh flights or spend a refresh grant, including after 401. */ | ||
| function resolveOrcaSourceToken(id: string, forced?: ForcedRefreshFence): CodexRefreshResult { | ||
| return withCredentialMutationLockSync(() => { | ||
| const store = loadCodexAccountRecordStore(); | ||
| const record = store[id]; | ||
| const prior = record?.credential; | ||
| if (!record || record.deletedAt != null || !prior?.sourceAuthPath || !prior.sourceSubject) { | ||
| throw new CodexCredentialGenerationConflictError(); | ||
| } | ||
|
Comment on lines
+773
to
+775
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Reject incomplete Orca source links when loading account records If a persisted credential contains Update 🤖 Prompt for AI Agents |
||
| const credential = readOrcaAuthSource(prior.sourceAuthPath); | ||
| if (credential.chatgptAccountId !== prior.chatgptAccountId || credential.sourceSubject !== prior.sourceSubject) { | ||
| throw new Error("Orca credential identity changed; reimport the account explicitly."); | ||
| } | ||
| if (credential.accessToken !== prior.accessToken || credential.expiresAt !== prior.expiresAt) { | ||
| store[id] = { credential, generation: record.generation + 1, replacedAt: Date.now(), ...preservedValidationMetadata(record) }; | ||
| persistCredentialMutation(store); | ||
| } | ||
| if (forced?.rejectedAccessToken === credential.accessToken) { | ||
| throw new Error("Orca bearer was rejected; update the account in Orca and retry."); | ||
| } | ||
| return { accessToken: credential.accessToken, chatgptAccountId: credential.chatgptAccountId, | ||
| generation: store[id]!.generation, provenance: "external-replacement" }; | ||
| }); | ||
| } | ||
|
|
||
| async function resolveCodexToken( | ||
| id: string, | ||
| forced?: ForcedRefreshFence, | ||
|
|
@@ -769,6 +798,7 @@ async function resolveCodexToken( | |
| const record = readCodexAccountRecord(id); | ||
| const cred = record?.deletedAt == null ? record?.credential : undefined; | ||
| if (!record || !cred) throw new Error("Codex account credential is unavailable; reauthenticate the account."); | ||
| if (cred.sourceAuthPath) return resolveOrcaSourceToken(id, forced); | ||
| const refreshGrantFingerprint = recordGrantFingerprint(record); | ||
| if (!refreshGrantFingerprint) throw new Error("Codex account credential is unavailable; reauthenticate the account."); | ||
|
|
||
|
|
@@ -798,6 +828,7 @@ async function resolveCodexToken( | |
| const refreshed = await awaitOwnCancellation(existing.promise, callerSignal); | ||
| const current = readCodexAccountRecord(id); | ||
| const currentCred = current?.deletedAt == null ? current?.credential : undefined; | ||
| if (currentCred?.sourceAuthPath) return resolveOrcaSourceToken(id, forced); | ||
| // The flight owner already committed this credential, and it is the one stored | ||
| // for this account: adopt the stored state instead of CAS-writing the identical | ||
| // bytes, which would bump the generation a second time and invalidate the | ||
|
|
@@ -919,6 +950,7 @@ async function resolveCodexToken( | |
| const lockedRecord = readCodexAccountRecord(id); | ||
| const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined; | ||
| if (!lockedRecord || !lockedCred) throw new CodexCredentialGenerationConflictError(); | ||
| if (lockedCred.sourceAuthPath) return resolveOrcaSourceToken(id, forced); | ||
| const startGeneration = lockedRecord.generation; | ||
| const lockedRefreshGrantFingerprint = recordGrantFingerprint(lockedRecord); | ||
| if (lockedRefreshGrantFingerprint !== refreshGrantFingerprint) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 4814
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 37419
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 50376
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 50376
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 16379
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 24426
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 4107
Avoid the mutation lock for unchanged source credentials.
resolveCodexTokenloads the account store, thenresolveOrcaSourceTokenloads it again underwithCredentialMutationLockSyncand reads both Orca files synchronously.BEGIN IMMEDIATEusesbusy_timeout = 0, so another process holding the configuration transaction raisesCodexCredentialRefreshLockTimeoutError; quota handling reportsquotaProbeSkipped.Read the source without the mutation lock first. When the source credential changed, acquire the lock, reload the account record, reread the source, and persist only after the identity and generation checks pass. Do not add a plain 5-second TTL:
tests/codex-integration/orca-import.test.tsrequires an immediate reread after source rotation, andreadCodexAccountRecordstill reparses the store rather than providing a zero-I/O cache.🤖 Prompt for AI Agents