clauderipple 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ // Secret-bearing JSON files that more than one process writes: the router refreshes tokens while a
2
+ // `clauderipple login` in another terminal adds an account. Every mutation holds a cross-process
3
+ // lock and replaces the file atomically, so a reader sees the old file or the new one, never half.
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { lockSync } from "proper-lockfile";
8
+ const LOCK_STALE_MS = 30_000;
9
+ const LOCK_WAIT_MS = 100;
10
+ const LOCK_TIMEOUT_MS = 2_000;
11
+ /** Run `mutate` while holding the lock for `file`. Waits up to two seconds for another writer. */
12
+ export function withFileLock(file, mutate) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
14
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
15
+ let release;
16
+ for (;;) {
17
+ try {
18
+ release = lockSync(file, { realpath: false, stale: LOCK_STALE_MS, update: 10_000 });
19
+ break;
20
+ }
21
+ catch (error) {
22
+ if (error.code !== "ELOCKED" || Date.now() >= deadline)
23
+ throw error;
24
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS);
25
+ }
26
+ }
27
+ try {
28
+ return mutate();
29
+ }
30
+ finally {
31
+ release();
32
+ }
33
+ }
34
+ /**
35
+ * Replace `file` with `value` as JSON (mode 0600): flushed temp file, then rename. Returns whether
36
+ * the directory entry was also made durable — POSIX can fsync a directory, Windows cannot, and a
37
+ * caller retiring an older copy of the same secret needs to know which it got.
38
+ */
39
+ export function writeJsonAtomic(file, value) {
40
+ const dirName = path.dirname(file);
41
+ fs.mkdirSync(dirName, { recursive: true, mode: 0o700 });
42
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
43
+ try {
44
+ const fd = fs.openSync(tmp, "wx", 0o600);
45
+ try {
46
+ fs.writeFileSync(fd, JSON.stringify(value, null, 2) + "\n", "utf8");
47
+ fs.fsyncSync(fd);
48
+ }
49
+ finally {
50
+ fs.closeSync(fd);
51
+ }
52
+ fs.renameSync(tmp, file);
53
+ let directoryDurable = false;
54
+ try {
55
+ const dir = fs.openSync(dirName, "r");
56
+ try {
57
+ fs.fsyncSync(dir);
58
+ }
59
+ finally {
60
+ fs.closeSync(dir);
61
+ }
62
+ directoryDurable = true;
63
+ }
64
+ catch (error) {
65
+ const code = error.code;
66
+ if (process.platform !== "win32" && code !== "EINVAL" && code !== "ENOTSUP" && code !== "EBADF")
67
+ throw error;
68
+ }
69
+ return { directoryDurable };
70
+ }
71
+ finally {
72
+ try {
73
+ fs.unlinkSync(tmp);
74
+ }
75
+ catch (error) {
76
+ if (error.code !== "ENOENT")
77
+ throw error;
78
+ }
79
+ }
80
+ }
@@ -7,37 +7,10 @@
7
7
  import crypto from "node:crypto";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import { lockSync } from "proper-lockfile";
10
+ import { withFileLock, writeJsonAtomic } from "../locked-file.js";
11
11
  import { readClaudeAuthFile, removeClaudeAuthFile } from "./anthropic-token-file.js";
12
- const LOCK_STALE_MS = 30_000;
13
- const LOCK_WAIT_MS = 100;
14
- const LOCK_TIMEOUT_MS = 2_000;
15
12
  function withAccountLock(home, mutate) {
16
- fs.mkdirSync(home, { recursive: true, mode: 0o700 });
17
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
18
- let release;
19
- for (;;) {
20
- try {
21
- release = lockSync(claudeAccountsPath(home), {
22
- realpath: false,
23
- stale: LOCK_STALE_MS,
24
- update: 10_000,
25
- });
26
- break;
27
- }
28
- catch (error) {
29
- if (error.code !== "ELOCKED" || Date.now() >= deadline) {
30
- throw error;
31
- }
32
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS);
33
- }
34
- }
35
- try {
36
- return mutate();
37
- }
38
- finally {
39
- release();
40
- }
13
+ return withFileLock(claudeAccountsPath(home), mutate);
41
14
  }
42
15
  export function claudeAccountsPath(home) {
43
16
  return path.join(home, "claude-accounts.json");
@@ -116,51 +89,13 @@ export function legacyMigrationDurable(directoryDurable, platform = process.plat
116
89
  return directoryDurable || platform === "win32";
117
90
  }
118
91
  function writeClaudeAccountsFile(home, accounts) {
119
- fs.mkdirSync(home, { recursive: true, mode: 0o700 });
120
- const file = claudeAccountsPath(home);
121
- const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
122
- try {
123
- const fd = fs.openSync(tmp, "wx", 0o600);
124
- try {
125
- fs.writeFileSync(fd, JSON.stringify({ version: 1, accounts }, null, 2) + "\n", "utf8");
126
- fs.fsyncSync(fd);
127
- }
128
- finally {
129
- fs.closeSync(fd);
130
- }
131
- fs.renameSync(tmp, file);
132
- // Persist the directory entry where the filesystem supports it. POSIX keeps the legacy refresh
133
- // token unless that succeeds; Windows has no directory fsync, so the flushed file + rename is its
134
- // durability boundary and must complete migration rather than resurrect a removed legacy account.
135
- let directoryDurable = false;
136
- try {
137
- const dir = fs.openSync(home, "r");
138
- try {
139
- fs.fsyncSync(dir);
140
- }
141
- finally {
142
- fs.closeSync(dir);
143
- }
144
- directoryDurable = true;
145
- }
146
- catch (error) {
147
- const code = error.code;
148
- if (process.platform !== "win32" && code !== "EINVAL" && code !== "ENOTSUP" && code !== "EBADF")
149
- throw error;
150
- }
151
- const legacy = readClaudeAuthFile(home);
152
- if (legacyMigrationDurable(directoryDurable) && legacy?.source === "oauth")
153
- removeClaudeAuthFile(home);
154
- }
155
- finally {
156
- try {
157
- fs.unlinkSync(tmp);
158
- }
159
- catch (error) {
160
- if (error.code !== "ENOENT")
161
- throw error;
162
- }
163
- }
92
+ const { directoryDurable } = writeJsonAtomic(claudeAccountsPath(home), { version: 1, accounts });
93
+ // POSIX keeps the legacy refresh token unless the directory entry is durable; Windows has no
94
+ // directory fsync, so the flushed file + rename is its durability boundary and must complete
95
+ // migration rather than resurrect a removed legacy account.
96
+ const legacy = readClaudeAuthFile(home);
97
+ if (legacyMigrationDurable(directoryDurable) && legacy?.source === "oauth")
98
+ removeClaudeAuthFile(home);
164
99
  }
165
100
  function subjectHash(accountId) {
166
101
  return accountId ? crypto.createHash("sha256").update(accountId).digest("hex") : undefined;
@@ -0,0 +1,438 @@
1
+ // ChatGPT subscription accounts: several can be signed in, and a turn moves to the next one when
2
+ // the account it is on runs out.
3
+ //
4
+ // Two kinds of account:
5
+ // own signed in through `clauderipple login`, stored in <home>/chatgpt-accounts.json (0600).
6
+ // We refresh these. The old single-login file <home>/chatgpt-auth.json is imported on the
7
+ // first write, the same way the Claude store retires its single-account file.
8
+ // codex the Codex CLI's own login in ~/.codex/auth.json. Read-only: its refresh token rotates,
9
+ // and refreshing someone else's grant would sign Codex out. Takes part last.
10
+ //
11
+ // Identity is the ChatGPT workspace (`chatgpt_account_id`) plus the email: one person can be in
12
+ // several workspaces with separate limits, and several people can share one workspace. Signing in
13
+ // again as the same pair replaces that account instead of adding a duplicate.
14
+ import crypto from "node:crypto";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { redactErrorText } from "../../redact.js";
18
+ import { withFileLock, writeJsonAtomic } from "../../locked-file.js";
19
+ import { legacyMigrationDurable } from "../anthropic-accounts.js";
20
+ import { OAUTH, identityFromTokens, ownAuthPath, readBorrowed, readOwn } from "./auth.js";
21
+ export function chatgptAccountsPath(home) {
22
+ return path.join(home, "chatgpt-accounts.json");
23
+ }
24
+ function validAccount(value) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value))
26
+ return false;
27
+ const a = value;
28
+ return typeof a.id === "string" && a.id.length > 0
29
+ && typeof a.accessToken === "string" && a.accessToken.length > 0
30
+ && (a.refreshToken === undefined || typeof a.refreshToken === "string")
31
+ && (a.idToken === undefined || typeof a.idToken === "string")
32
+ && typeof a.accountId === "string" && a.accountId.length > 0
33
+ && typeof a.expiresAt === "number" && Number.isFinite(a.expiresAt)
34
+ && typeof a.label === "string" && a.label.length > 0
35
+ && typeof a.createdAt === "string" && typeof a.updatedAt === "string"
36
+ && (a.email === undefined || typeof a.email === "string")
37
+ && (a.planType === undefined || typeof a.planType === "string")
38
+ && (a.needsReauth === undefined || typeof a.needsReauth === "boolean")
39
+ && (a.paused === undefined || typeof a.paused === "boolean");
40
+ }
41
+ /** The file's accounts; [] when absent, null when unreadable (so a mutation refuses to overwrite it). */
42
+ function parseAccountsFile(home) {
43
+ try {
44
+ const parsed = JSON.parse(fs.readFileSync(chatgptAccountsPath(home), "utf8"));
45
+ if (parsed.version !== 1 || !Array.isArray(parsed.accounts) || !parsed.accounts.every(validAccount))
46
+ return null;
47
+ const seen = new Set();
48
+ return parsed.accounts.filter((account) => !seen.has(account.id) && seen.add(account.id)).map((account) => ({ ...account }));
49
+ }
50
+ catch (error) {
51
+ return error.code === "ENOENT" ? [] : null;
52
+ }
53
+ }
54
+ /** The single login file from before accounts, seen as one account until the first write imports it. */
55
+ function legacyAccount(home) {
56
+ const own = readOwn(home);
57
+ if (!own)
58
+ return null;
59
+ const at = (() => { try {
60
+ return fs.statSync(ownAuthPath(home)).mtime.toISOString();
61
+ }
62
+ catch {
63
+ return new Date(0).toISOString();
64
+ } })();
65
+ const identity = identityFromTokens(own);
66
+ return {
67
+ id: "legacy",
68
+ accessToken: own.accessToken,
69
+ ...(own.refreshToken ? { refreshToken: own.refreshToken } : {}),
70
+ ...(own.idToken ? { idToken: own.idToken } : {}),
71
+ accountId: own.accountId,
72
+ expiresAt: own.expiresAt,
73
+ ...(identity.email ? { email: identity.email } : {}),
74
+ ...(identity.planType ? { planType: identity.planType } : {}),
75
+ label: identity.email ?? "ChatGPT account",
76
+ createdAt: at,
77
+ updatedAt: at,
78
+ };
79
+ }
80
+ function withLegacy(home, accounts) {
81
+ const legacy = legacyAccount(home);
82
+ if (!legacy)
83
+ return accounts;
84
+ // A migration that wrote the pool but crashed before retiring the old file must not duplicate it.
85
+ if (accounts.some((account) => account.id === "legacy" || sameIdentity(account, legacy)))
86
+ return accounts;
87
+ return [...accounts, legacy];
88
+ }
89
+ /** Every stored account. Once the pool file exists it is the only source: a stale legacy file cannot resurrect a removed login. */
90
+ export function readChatGptAccounts(home) {
91
+ const accounts = parseAccountsFile(home);
92
+ if (accounts === null)
93
+ return [];
94
+ return fs.existsSync(chatgptAccountsPath(home)) ? accounts : withLegacy(home, accounts);
95
+ }
96
+ function readForMutation(home) {
97
+ const accounts = parseAccountsFile(home);
98
+ if (accounts === null)
99
+ throw new Error("ChatGPT account store is unreadable; refusing to overwrite it");
100
+ return fs.existsSync(chatgptAccountsPath(home)) ? accounts : withLegacy(home, accounts);
101
+ }
102
+ function write(home, accounts) {
103
+ const { directoryDurable } = writeJsonAtomic(chatgptAccountsPath(home), { version: 1, accounts });
104
+ if (legacyMigrationDurable(directoryDurable) && fs.existsSync(ownAuthPath(home)))
105
+ fs.rmSync(ownAuthPath(home), { force: true });
106
+ }
107
+ function mutate(home, change) {
108
+ return withFileLock(chatgptAccountsPath(home), () => {
109
+ const { accounts, result } = change(readForMutation(home));
110
+ if (accounts)
111
+ write(home, accounts);
112
+ return result;
113
+ });
114
+ }
115
+ /** Same person in the same workspace. A missing email on either side counts as a match. */
116
+ function sameIdentity(a, b) {
117
+ if (a.accountId !== b.accountId)
118
+ return false;
119
+ return !a.email || !b.email || a.email.toLowerCase() === b.email.toLowerCase();
120
+ }
121
+ /** Add a signed-in account, or replace the same one after a re-login. Returns the stored account's summary. */
122
+ export function saveChatGptAccount(home, grant, now = new Date().toISOString()) {
123
+ const identity = identityFromTokens(grant);
124
+ return mutate(home, (accounts) => {
125
+ const index = accounts.findIndex((account) => sameIdentity(account, { accountId: grant.accountId, ...(identity.email ? { email: identity.email } : {}) }));
126
+ const previous = index >= 0 ? accounts[index] : undefined;
127
+ const account = {
128
+ id: previous?.id ?? crypto.randomUUID(),
129
+ accessToken: grant.accessToken,
130
+ ...(grant.refreshToken ? { refreshToken: grant.refreshToken } : {}),
131
+ ...(grant.idToken ? { idToken: grant.idToken } : {}),
132
+ accountId: grant.accountId,
133
+ expiresAt: grant.expiresAt,
134
+ ...(identity.email ?? previous?.email ? { email: identity.email ?? previous.email } : {}),
135
+ ...(identity.planType ?? previous?.planType ? { planType: identity.planType ?? previous.planType } : {}),
136
+ label: previous?.label ?? identity.email ?? `ChatGPT account ${accounts.length + 1}`,
137
+ createdAt: previous?.createdAt ?? now,
138
+ updatedAt: now,
139
+ ...(previous?.paused ? { paused: true } : {}),
140
+ };
141
+ const next = [...accounts];
142
+ if (index >= 0)
143
+ next[index] = account;
144
+ else
145
+ next.push(account);
146
+ return { accounts: next, result: { ...summarize(account), added: index < 0 } };
147
+ });
148
+ }
149
+ /** Compare-and-swap a refreshed grant: an older concurrent refresh must not overwrite a newer one. */
150
+ export function replaceChatGptTokens(home, id, expectedRefreshToken, grant, now = new Date().toISOString()) {
151
+ const identity = identityFromTokens(grant);
152
+ return mutate(home, (accounts) => {
153
+ const index = accounts.findIndex((account) => account.id === id && account.refreshToken === expectedRefreshToken);
154
+ if (index < 0)
155
+ return { result: false };
156
+ const previous = accounts[index];
157
+ const next = [...accounts];
158
+ next[index] = {
159
+ ...previous,
160
+ accessToken: grant.accessToken,
161
+ ...(grant.refreshToken ? { refreshToken: grant.refreshToken } : {}),
162
+ ...(grant.idToken ? { idToken: grant.idToken } : {}),
163
+ expiresAt: grant.expiresAt,
164
+ ...(identity.planType ? { planType: identity.planType } : {}),
165
+ updatedAt: now,
166
+ needsReauth: false,
167
+ };
168
+ return { accounts: next, result: true };
169
+ });
170
+ }
171
+ /** Mark only the token generation that failed; a sign-in that landed meanwhile wins. */
172
+ export function markChatGptNeedsReauth(home, id, expectedAccessToken) {
173
+ return mutate(home, (accounts) => {
174
+ const index = accounts.findIndex((account) => account.id === id && account.accessToken === expectedAccessToken);
175
+ if (index < 0)
176
+ return { result: false };
177
+ const next = [...accounts];
178
+ next[index] = { ...next[index], needsReauth: true, updatedAt: new Date().toISOString() };
179
+ return { accounts: next, result: true };
180
+ });
181
+ }
182
+ export function updateChatGptAccount(home, id, change) {
183
+ const label = change.label?.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 80);
184
+ if (change.label !== undefined && !label)
185
+ return false;
186
+ return mutate(home, (accounts) => {
187
+ const index = accounts.findIndex((account) => account.id === id);
188
+ if (index < 0)
189
+ return { result: false };
190
+ const next = [...accounts];
191
+ const { paused: _paused, ...rest } = next[index];
192
+ next[index] = { ...rest, ...(label ? { label } : {}), ...((change.paused ?? next[index].paused) ? { paused: true } : {}), updatedAt: new Date().toISOString() };
193
+ return { accounts: next, result: true };
194
+ });
195
+ }
196
+ export function removeChatGptAccount(home, id) {
197
+ return mutate(home, (accounts) => {
198
+ const next = accounts.filter((account) => account.id !== id);
199
+ return next.length === accounts.length ? { result: false } : { accounts: next, result: true };
200
+ });
201
+ }
202
+ /** `clauderipple logout`: every account we signed in, and the pre-accounts file. The Codex CLI's login is not ours. */
203
+ export function removeAllChatGptAccounts(home) {
204
+ return withFileLock(chatgptAccountsPath(home), () => {
205
+ const count = readChatGptAccounts(home).length;
206
+ fs.rmSync(chatgptAccountsPath(home), { force: true });
207
+ fs.rmSync(ownAuthPath(home), { force: true });
208
+ return count;
209
+ });
210
+ }
211
+ export function summarize(account) {
212
+ return {
213
+ id: account.id,
214
+ label: account.label,
215
+ ...(account.email ? { email: account.email } : {}),
216
+ ...(account.planType ? { planType: account.planType } : {}),
217
+ expiresAt: account.expiresAt,
218
+ needsReauth: account.needsReauth === true,
219
+ paused: account.paused === true,
220
+ source: "own",
221
+ };
222
+ }
223
+ // ---------------------------------------------------------------------------------------------
224
+ // Runtime
225
+ /** Refresh this long before expiry. A turn never waits for it while the old token still works. */
226
+ const REFRESH_LEAD_MS = 5 * 60_000;
227
+ /** The owner id the Codex CLI's login goes by; never collides with a UUID or "legacy". */
228
+ export const CODEX_LOGIN_ID = "codex";
229
+ /** Refresh-token rejections that will not heal: the account needs a new sign-in. */
230
+ const TERMINAL_REFRESH_CODES = new Set(["invalid_grant", "refresh_token_invalidated", "refresh_token_expired", "refresh_token_reused"]);
231
+ export class ChatGptRefreshError extends Error {
232
+ terminal;
233
+ constructor(message, terminal) {
234
+ super(message);
235
+ this.terminal = terminal;
236
+ }
237
+ }
238
+ /**
239
+ * Whether a refused refresh means "sign in again". Decided on the structured error code only: a
240
+ * 5xx whose body happens to mention "invalid" must not retire a working account. Prose is read
241
+ * only when the answer carries no code at all, and only for a 400 or 401.
242
+ */
243
+ export function refreshRejectionIsTerminal(status, body) {
244
+ let code;
245
+ try {
246
+ const parsed = JSON.parse(body);
247
+ const e = parsed.error;
248
+ code = typeof e === "string" ? e : e && typeof e === "object" && typeof e.code === "string" ? e.code : typeof parsed.code === "string" ? parsed.code : undefined;
249
+ }
250
+ catch {
251
+ /* not JSON */
252
+ }
253
+ if (code)
254
+ return TERMINAL_REFRESH_CODES.has(code);
255
+ return (status === 400 || status === 401) && /\b(invalid_grant|revoked|invalidated|expired|reused)\b/i.test(body);
256
+ }
257
+ /** Exchange a refresh token for a new grant. Throws ChatGptRefreshError. */
258
+ export async function refreshChatGptGrant(refreshToken, previous, fetchImpl = fetch, now = Date.now) {
259
+ let res;
260
+ try {
261
+ res = await fetchImpl(OAUTH.tokenUrl, {
262
+ method: "POST",
263
+ headers: { "content-type": "application/x-www-form-urlencoded" },
264
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: OAUTH.clientId }),
265
+ signal: AbortSignal.timeout(30_000),
266
+ });
267
+ }
268
+ catch (error) {
269
+ throw new ChatGptRefreshError(`token refresh unreachable: ${error.message}`, false);
270
+ }
271
+ const text = await res.text().catch(() => "");
272
+ if (!res.ok)
273
+ throw new ChatGptRefreshError(`token refresh failed: HTTP ${res.status} ${redactErrorText(text, [refreshToken], 200)}`, refreshRejectionIsTerminal(res.status, text));
274
+ let j;
275
+ try {
276
+ j = JSON.parse(text);
277
+ }
278
+ catch {
279
+ throw new ChatGptRefreshError("token refresh answered with something other than JSON", false);
280
+ }
281
+ if (!j.access_token)
282
+ throw new ChatGptRefreshError("token refresh answered without an access token", false);
283
+ const identity = identityFromTokens({ accessToken: j.access_token, ...(j.id_token ? { idToken: j.id_token } : {}) });
284
+ return {
285
+ accessToken: j.access_token,
286
+ refreshToken: j.refresh_token ?? refreshToken,
287
+ ...(j.id_token ? { idToken: j.id_token } : {}),
288
+ accountId: identity.accountId ?? previous.accountId,
289
+ expiresAt: j.expires_in ? now() + j.expires_in * 1000 : identity.expiresAt ?? now() + 3600_000,
290
+ };
291
+ }
292
+ /** One refresh per token generation across every adapter in this process. */
293
+ const refreshing = new Map();
294
+ function credentialOf(ownerId, label, accessToken, accountId) {
295
+ // The runtime id is the account itself, not its token: a usage limit belongs to the account, and
296
+ // a refresh must not bring a spent account back early. Whether an account needs a new sign-in is
297
+ // kept in the store (needsReauth), never as a pool quarantine that a new token would have to undo.
298
+ return {
299
+ id: ownerId,
300
+ ownerId,
301
+ label,
302
+ accountId,
303
+ accessToken,
304
+ headers: { authorization: `Bearer ${accessToken}`, "chatgpt-account-id": accountId },
305
+ };
306
+ }
307
+ /**
308
+ * The accounts one chatgpt provider may use, in order: ours as listed, then the Codex CLI's login.
309
+ * Refreshes what is due without holding a turn up, and can force a refresh when the backend
310
+ * refuses a token that has not expired yet.
311
+ */
312
+ export class ChatGptAccountPool {
313
+ home;
314
+ mode;
315
+ log;
316
+ fetchImpl;
317
+ now;
318
+ constructor(options) {
319
+ this.home = options.home;
320
+ this.mode = options.mode;
321
+ this.log = options.log;
322
+ this.fetchImpl = options.fetch;
323
+ this.now = options.now ?? Date.now;
324
+ }
325
+ /** Stored accounts this provider may use (paused and needs-sign-in included, for display). */
326
+ ownAccounts() {
327
+ return this.mode === "borrow-codex" ? [] : readChatGptAccounts(this.home);
328
+ }
329
+ /** The Codex CLI's login, when this provider may use it and it is not one of ours already. */
330
+ codexLogin() {
331
+ if (this.mode === "own")
332
+ return null;
333
+ const borrowed = readBorrowed();
334
+ if (!borrowed)
335
+ return null;
336
+ const identity = identityFromTokens(borrowed);
337
+ const key = { accountId: borrowed.accountId, ...(identity.email ? { email: identity.email } : {}) };
338
+ if (this.ownAccounts().some((account) => sameIdentity(account, key)))
339
+ return null;
340
+ return borrowed;
341
+ }
342
+ /** Accounts that could be sent right now, without network I/O. */
343
+ peekCredentials() {
344
+ const out = [];
345
+ for (const account of this.ownAccounts()) {
346
+ if (account.paused || account.needsReauth || this.now() >= account.expiresAt)
347
+ continue;
348
+ out.push(credentialOf(account.id, account.label, account.accessToken, account.accountId));
349
+ }
350
+ const codex = this.codexLogin();
351
+ if (codex && this.now() < codex.expiresAt)
352
+ out.push(credentialOf(CODEX_LOGIN_ID, identityFromTokens(codex).email ?? "Codex CLI login", codex.accessToken, codex.accountId));
353
+ return out;
354
+ }
355
+ /** Accounts for one turn. Waits for a refresh only when nothing could be sent otherwise. */
356
+ async credentials() {
357
+ const ready = this.peekCredentials();
358
+ const due = this.refreshDue();
359
+ if (ready.length > 0) {
360
+ void due.catch((error) => this.log.warn(`chatgpt account refresh task failed: ${error.message}`));
361
+ return ready;
362
+ }
363
+ await due;
364
+ return this.peekCredentials();
365
+ }
366
+ /** Refresh every stored account inside its lead window. */
367
+ async refreshDue() {
368
+ const due = this.ownAccounts().filter((account) => !account.needsReauth && account.refreshToken && this.now() >= account.expiresAt - REFRESH_LEAD_MS);
369
+ await Promise.all(due.map((account) => this.refresh(account)));
370
+ }
371
+ /**
372
+ * The backend refused this account's token before it expired (revoked elsewhere, or a clock
373
+ * that disagrees). One refresh; true when a new token is in place. The Codex CLI's login is not
374
+ * ours to refresh.
375
+ */
376
+ async forceRefresh(ownerId) {
377
+ const account = this.ownAccounts().find((candidate) => candidate.id === ownerId);
378
+ if (!account?.refreshToken || account.needsReauth)
379
+ return false;
380
+ const before = account.accessToken;
381
+ await this.refresh(account);
382
+ const after = this.ownAccounts().find((candidate) => candidate.id === ownerId);
383
+ return !!after && !after.needsReauth && after.accessToken !== before;
384
+ }
385
+ /** A token the backend refused after a refresh: that account needs a new sign-in. */
386
+ reject(credential) {
387
+ if (credential.ownerId === CODEX_LOGIN_ID)
388
+ return;
389
+ if (markChatGptNeedsReauth(this.home, credential.ownerId, credential.accessToken)) {
390
+ this.log.warn(`chatgpt account ${credential.ownerId.slice(0, 8)}: token refused; sign-in required`);
391
+ }
392
+ }
393
+ refresh(account) {
394
+ const refreshToken = account.refreshToken;
395
+ const key = `${this.home}\0${account.id}\0${crypto.createHash("sha256").update(refreshToken).digest("hex")}`;
396
+ const running = refreshing.get(key);
397
+ if (running)
398
+ return running;
399
+ const task = refreshChatGptGrant(refreshToken, account, this.fetchImpl, this.now).then((grant) => {
400
+ if (replaceChatGptTokens(this.home, account.id, refreshToken, grant)) {
401
+ this.log.info(`chatgpt account ${account.id.slice(0, 8)}: token refreshed, valid until ${new Date(grant.expiresAt).toISOString()}`);
402
+ }
403
+ }, (error) => {
404
+ if (error instanceof ChatGptRefreshError && error.terminal) {
405
+ markChatGptNeedsReauth(this.home, account.id, account.accessToken);
406
+ this.log.warn(`chatgpt account ${account.id.slice(0, 8)}: refresh rejected; sign-in required`);
407
+ }
408
+ else {
409
+ this.log.warn(`chatgpt account ${account.id.slice(0, 8)}: refresh failed: ${redactErrorText(error.message, [account.accessToken, refreshToken], 300)}`);
410
+ }
411
+ }).finally(() => refreshing.delete(key));
412
+ refreshing.set(key, task);
413
+ return task;
414
+ }
415
+ /** Every account for the dashboard: ours, then the Codex CLI's login. Metadata only. */
416
+ summaries() {
417
+ const out = this.ownAccounts().map(summarize);
418
+ const codex = this.codexLogin();
419
+ if (codex) {
420
+ const identity = identityFromTokens(codex);
421
+ out.push({
422
+ id: CODEX_LOGIN_ID,
423
+ label: identity.email ?? "Codex CLI login",
424
+ ...(identity.email ? { email: identity.email } : {}),
425
+ ...(identity.planType ? { planType: identity.planType } : {}),
426
+ expiresAt: codex.expiresAt,
427
+ needsReauth: this.now() >= codex.expiresAt,
428
+ paused: false,
429
+ source: "codex",
430
+ });
431
+ }
432
+ return out;
433
+ }
434
+ /** Whether anything is signed in at all, expired or not — the "log in first" check. */
435
+ signedIn() {
436
+ return this.ownAccounts().length > 0 || this.codexLogin() !== null || (this.mode !== "own" && readBorrowed() !== null);
437
+ }
438
+ }