@namzu/sdk 58.1.1 → 59.0.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/account/binding.d.ts +20 -0
  3. package/dist/account/binding.d.ts.map +1 -0
  4. package/dist/account/binding.js +27 -0
  5. package/dist/account/binding.js.map +1 -0
  6. package/dist/account/index.d.ts +9 -0
  7. package/dist/account/index.d.ts.map +1 -0
  8. package/dist/account/index.js +5 -0
  9. package/dist/account/index.js.map +1 -0
  10. package/dist/account/lock.d.ts +33 -0
  11. package/dist/account/lock.d.ts.map +1 -0
  12. package/dist/account/lock.js +170 -0
  13. package/dist/account/lock.js.map +1 -0
  14. package/dist/account/registry.d.ts +21 -0
  15. package/dist/account/registry.d.ts.map +1 -0
  16. package/dist/account/registry.js +340 -0
  17. package/dist/account/registry.js.map +1 -0
  18. package/dist/account/types.d.ts +124 -0
  19. package/dist/account/types.d.ts.map +1 -0
  20. package/dist/account/types.js +35 -0
  21. package/dist/account/types.js.map +1 -0
  22. package/dist/pals/communication/ingress-schema.d.ts +38 -38
  23. package/dist/peers/record.d.ts +98 -0
  24. package/dist/peers/record.d.ts.map +1 -1
  25. package/dist/peers/record.js +17 -0
  26. package/dist/peers/record.js.map +1 -1
  27. package/dist/peers/registry.d.ts.map +1 -1
  28. package/dist/peers/registry.js +2 -2
  29. package/dist/peers/registry.js.map +1 -1
  30. package/dist/provider/errors.d.ts.map +1 -1
  31. package/dist/provider/errors.js +25 -2
  32. package/dist/provider/errors.js.map +1 -1
  33. package/dist/provider/token-budget.d.ts.map +1 -1
  34. package/dist/provider/token-budget.js +9 -1
  35. package/dist/provider/token-budget.js.map +1 -1
  36. package/dist/public-runtime.d.ts +1 -0
  37. package/dist/public-runtime.d.ts.map +1 -1
  38. package/dist/public-runtime.js +1 -0
  39. package/dist/public-runtime.js.map +1 -1
  40. package/dist/public-types.d.ts +2 -0
  41. package/dist/public-types.d.ts.map +1 -1
  42. package/dist/runtime/query/executor.d.ts +7 -2
  43. package/dist/runtime/query/executor.d.ts.map +1 -1
  44. package/dist/runtime/query/executor.js +10 -2
  45. package/dist/runtime/query/executor.js.map +1 -1
  46. package/dist/types/errors/catalog.d.ts.map +1 -1
  47. package/dist/types/errors/catalog.js +6 -0
  48. package/dist/types/errors/catalog.js.map +1 -1
  49. package/dist/types/provider/errors.d.ts +7 -0
  50. package/dist/types/provider/errors.d.ts.map +1 -1
  51. package/dist/types/provider/errors.js +27 -3
  52. package/dist/types/provider/errors.js.map +1 -1
  53. package/dist/types/session/records.d.ts +215 -1
  54. package/dist/types/session/records.d.ts.map +1 -1
  55. package/dist/types/session/records.js +13 -0
  56. package/dist/types/session/records.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/account/binding.ts +43 -0
  59. package/src/account/index.ts +32 -0
  60. package/src/account/lock.ts +184 -0
  61. package/src/account/registry.ts +413 -0
  62. package/src/account/types.ts +137 -0
  63. package/src/peers/record.ts +18 -0
  64. package/src/peers/registry.ts +2 -2
  65. package/src/provider/errors.ts +27 -1
  66. package/src/provider/token-budget.ts +9 -3
  67. package/src/public-runtime.ts +17 -0
  68. package/src/public-types.ts +15 -0
  69. package/src/runtime/query/executor.ts +10 -2
  70. package/src/types/errors/catalog.ts +6 -0
  71. package/src/types/provider/errors.ts +40 -3
  72. package/src/types/session/records.ts +16 -0
@@ -0,0 +1,32 @@
1
+ export {
2
+ ACCOUNTS_FILE_NAME,
3
+ ACCOUNTS_FILE_VERSION,
4
+ accountId,
5
+ createAccountRegistry,
6
+ parseAccountId,
7
+ } from './registry.js'
8
+ export type { AccountRegistryOptions } from './registry.js'
9
+ export {
10
+ codexRefreshLockPath,
11
+ withAccountLock,
12
+ withAccountLockSync,
13
+ withAccountRefreshLock,
14
+ } from './lock.js'
15
+ export type { AccountLockOptions, AccountLockSyncOptions } from './lock.js'
16
+ export { accountBoundPayload, resolveBoundAccount } from './binding.js'
17
+ export type { AccountBoundReason, BoundAccount } from './binding.js'
18
+ export {
19
+ AccountLockError,
20
+ AccountRegistryError,
21
+ CredentialWithdrawnError,
22
+ DEFAULT_ACCOUNT_LABEL,
23
+ } from './types.js'
24
+ export type {
25
+ Account,
26
+ AccountCredentialReaders,
27
+ AccountId,
28
+ AccountRegistry,
29
+ AccountSource,
30
+ NewAccount,
31
+ ResolvedAccountCredential,
32
+ } from './types.js'
@@ -0,0 +1,184 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { AccountLockError } from './types.js'
5
+
6
+ /**
7
+ * The lock file beside a Codex account's `auth.json`. One lock per account
8
+ * directory, so a stuck account never blocks another.
9
+ */
10
+ export function codexRefreshLockPath(dir: string): string {
11
+ return join(dir, 'auth.json.namzu.lock')
12
+ }
13
+
14
+ export interface AccountLockOptions {
15
+ /** How many times to try before refusing. Default 1: a held lock is refused at once. */
16
+ readonly attempts?: number
17
+ /** Waits between attempts; injected so a test never depends on the clock. */
18
+ readonly wait?: (attempt: number) => Promise<void>
19
+ }
20
+
21
+ function acquire(lockPath: string): string | undefined {
22
+ const token = `${process.pid}:${Date.now()}:${randomBytes(12).toString('hex')}`
23
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 })
24
+ for (let pass = 0; pass < 2; pass++) {
25
+ let fd: number
26
+ try {
27
+ fd = openSync(lockPath, 'wx', 0o600)
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
30
+ if (pass === 0 && reclaimIfStale(lockPath)) continue
31
+ return undefined
32
+ }
33
+ try {
34
+ writeSync(fd, token)
35
+ } finally {
36
+ closeSync(fd)
37
+ }
38
+ return token
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ function pidOf(token: string): number | undefined {
44
+ const pid = Number.parseInt(token.split(':', 1)[0] ?? '', 10)
45
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined
46
+ }
47
+
48
+ function processIsDead(pid: number): boolean {
49
+ try {
50
+ process.kill(pid, 0)
51
+ return false
52
+ } catch (error) {
53
+ // EPERM means the process exists but belongs to someone else: alive.
54
+ return (error as NodeJS.ErrnoException).code === 'ESRCH'
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Remove a lock whose recorded owner process no longer exists. Reclaimers are
60
+ * serialised by a guard file, and the lock is re-read under the guard, so a
61
+ * lock that was replaced by a live owner in the meantime is never removed.
62
+ */
63
+ function reclaimIfStale(lockPath: string): boolean {
64
+ let observed: string
65
+ try {
66
+ observed = readFileSync(lockPath, 'utf8')
67
+ } catch {
68
+ return true // already gone; the caller may simply try again
69
+ }
70
+ const pid = pidOf(observed)
71
+ if (pid === undefined || !processIsDead(pid)) return false
72
+ const guard = `${lockPath}.reclaim`
73
+ let fd: number
74
+ try {
75
+ fd = openSync(guard, 'wx', 0o600)
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
78
+ try {
79
+ const holder = pidOf(readFileSync(guard, 'utf8'))
80
+ if (holder !== undefined && processIsDead(holder)) rmSync(guard, { force: true })
81
+ } catch {
82
+ // The guard vanished or is unreadable; the next attempt decides.
83
+ }
84
+ return false
85
+ }
86
+ try {
87
+ writeSync(fd, `${process.pid}`)
88
+ closeSync(fd)
89
+ let again: string | undefined
90
+ try {
91
+ again = readFileSync(lockPath, 'utf8')
92
+ } catch {
93
+ return true
94
+ }
95
+ if (again !== observed) return false
96
+ rmSync(lockPath, { force: true })
97
+ return true
98
+ } finally {
99
+ rmSync(guard, { force: true })
100
+ }
101
+ }
102
+
103
+ function release(lockPath: string, token: string): void {
104
+ // Never remove a successor's lock if something else replaced the file.
105
+ try {
106
+ if (readFileSync(lockPath, 'utf8') === token) rmSync(lockPath, { force: true })
107
+ } catch {
108
+ // The work already has its result; a missing lock is not its failure.
109
+ }
110
+ }
111
+
112
+ function refusal(lockPath: string): AccountLockError {
113
+ return new AccountLockError(
114
+ lockPath,
115
+ `another owner holds the account lock ${lockPath}. The lock is reclaimed on its own when the process that recorded it has exited; if that process is still running, wait for it to finish.`,
116
+ )
117
+ }
118
+
119
+ /**
120
+ * Run `operation` while holding the lock file at `lockPath`. The lock is held
121
+ * across local read/write work, never across a user prompt. A lock whose
122
+ * recorded process has exited is reclaimed; a lock held by a live process is a
123
+ * refusal (or a wait, when attempts and a wait function are given).
124
+ */
125
+ export async function withAccountLock<T>(
126
+ lockPath: string,
127
+ operation: () => Promise<T> | T,
128
+ options: AccountLockOptions = {},
129
+ ): Promise<T> {
130
+ const attempts = Math.max(1, options.attempts ?? 1)
131
+ let token: string | undefined
132
+ for (let attempt = 0; attempt < attempts; attempt++) {
133
+ token = acquire(lockPath)
134
+ if (token !== undefined) break
135
+ if (attempt + 1 < attempts && options.wait) await options.wait(attempt)
136
+ }
137
+ if (token === undefined) throw refusal(lockPath)
138
+ try {
139
+ return await operation()
140
+ } finally {
141
+ release(lockPath, token)
142
+ }
143
+ }
144
+
145
+ export interface AccountLockSyncOptions {
146
+ /** How many times to try before refusing. Default 1. */
147
+ readonly attempts?: number
148
+ /** Blocks between attempts; injected so a test never depends on the clock. */
149
+ readonly wait?: (attempt: number) => void
150
+ }
151
+
152
+ /** Synchronous form for short store mutations. */
153
+ export function withAccountLockSync<T>(
154
+ lockPath: string,
155
+ operation: () => T,
156
+ options: AccountLockSyncOptions = {},
157
+ ): T {
158
+ const attempts = Math.max(1, options.attempts ?? 1)
159
+ let token: string | undefined
160
+ for (let attempt = 0; attempt < attempts; attempt++) {
161
+ token = acquire(lockPath)
162
+ if (token !== undefined) break
163
+ if (attempt + 1 < attempts && options.wait) options.wait(attempt)
164
+ }
165
+ if (token === undefined) throw refusal(lockPath)
166
+ try {
167
+ return operation()
168
+ } finally {
169
+ release(lockPath, token)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Serialise a Codex account's read-refresh-write. A reader that implements
175
+ * `AccountCredentialReaders.codexHome` takes this around the whole cycle and
176
+ * compares the file's tokens with what it read before writing back.
177
+ */
178
+ export function withAccountRefreshLock<T>(
179
+ dir: string,
180
+ operation: () => Promise<T> | T,
181
+ options: AccountLockOptions = {},
182
+ ): Promise<T> {
183
+ return withAccountLock(codexRefreshLockPath(dir), operation, options)
184
+ }
@@ -0,0 +1,413 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ unlinkSync,
13
+ writeSync,
14
+ } from 'node:fs'
15
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
16
+ import { z } from 'zod'
17
+ import { resolveNamzuHome } from '../session/home.js'
18
+ import { withAccountLockSync } from './lock.js'
19
+ import {
20
+ type Account,
21
+ type AccountCredentialReaders,
22
+ type AccountId,
23
+ type AccountRegistry,
24
+ AccountRegistryError,
25
+ type AccountSource,
26
+ CredentialWithdrawnError,
27
+ DEFAULT_ACCOUNT_LABEL,
28
+ type NewAccount,
29
+ type ResolvedAccountCredential,
30
+ } from './types.js'
31
+
32
+ export const ACCOUNTS_FILE_NAME = 'accounts.json'
33
+ export const ACCOUNTS_FILE_VERSION = 1
34
+ const LABEL = /^[a-z0-9-]{1,32}$/
35
+ const CODEX_PROVIDER = 'codex'
36
+ const PROVIDER = /^[a-z0-9][a-z0-9._-]{0,63}$/
37
+
38
+ // Unknown keys are kept so a store written by a newer build survives a rewrite by this one.
39
+ const SourceSchema = z.discriminatedUnion('kind', [
40
+ z.object({ kind: z.literal('codex-home'), dir: z.string().min(1) }).passthrough(),
41
+ z.object({ kind: z.literal('api-key'), keyRef: z.string().min(1) }).passthrough(),
42
+ ])
43
+ const AccountSchema = z
44
+ .object({
45
+ id: z.string().min(1),
46
+ provider: z.string().min(1),
47
+ label: z.string().min(1),
48
+ source: SourceSchema,
49
+ createdAt: z.string(),
50
+ display: z.string().optional(),
51
+ baseUrl: z.string().optional(),
52
+ defaultModel: z.string().optional(),
53
+ })
54
+ .passthrough()
55
+ const FileSchema = z
56
+ .object({
57
+ version: z.literal(ACCOUNTS_FILE_VERSION),
58
+ accounts: z.array(AccountSchema),
59
+ keys: z.record(z.string()),
60
+ })
61
+ .passthrough()
62
+ type StoreFile = z.infer<typeof FileSchema>
63
+
64
+ export interface AccountRegistryOptions {
65
+ /** The Namzu home. Defaults to `resolveNamzuHome()`. */
66
+ readonly home?: string
67
+ /** Providers that always have a synthesised `default` account, even with no stored one. */
68
+ readonly defaultProviders?: readonly string[]
69
+ readonly readers?: AccountCredentialReaders
70
+ readonly now?: () => Date
71
+ /** Blocks between attempts to take the store lock. Defaults to a short real sleep. */
72
+ readonly lockWait?: (attempt: number) => void
73
+ }
74
+
75
+ function sleepBlocking(): void {
76
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
77
+ }
78
+
79
+ export function accountId(provider: string, label: string): AccountId {
80
+ return `${provider}:${label}`
81
+ }
82
+
83
+ /** Split an id into provider and label, or `undefined` if it is not shaped `<provider>:<label>`. */
84
+ export function parseAccountId(id: string): { provider: string; label: string } | undefined {
85
+ const at = id.indexOf(':')
86
+ if (at <= 0 || at === id.length - 1) return undefined
87
+ return { provider: id.slice(0, at), label: id.slice(at + 1) }
88
+ }
89
+
90
+ function writeAtomic(path: string, text: string): void {
91
+ const temporary = join(dirname(path), `.${randomBytes(8).toString('hex')}.accounts.tmp`)
92
+ const fd = openSync(temporary, 'wx', 0o600)
93
+ try {
94
+ writeSync(fd, text)
95
+ fsyncSync(fd)
96
+ } finally {
97
+ closeSync(fd)
98
+ }
99
+ try {
100
+ renameSync(temporary, path)
101
+ } catch (error) {
102
+ try {
103
+ unlinkSync(temporary)
104
+ } catch {
105
+ // The rename already failed; the leftover temporary is not the failure to report.
106
+ }
107
+ throw error
108
+ }
109
+ }
110
+
111
+ export function createAccountRegistry(options: AccountRegistryOptions = {}): AccountRegistry {
112
+ const home = options.home ?? resolveNamzuHome()
113
+ const path = join(home, ACCOUNTS_FILE_NAME)
114
+ const lockPath = `${path}.lock`
115
+ const accountsRoot = join(home, 'accounts')
116
+ const now = options.now ?? (() => new Date())
117
+
118
+ function defaultAccount(provider: string): Account {
119
+ return {
120
+ id: accountId(provider, DEFAULT_ACCOUNT_LABEL),
121
+ provider,
122
+ label: DEFAULT_ACCOUNT_LABEL,
123
+ source: { kind: 'existing-default' },
124
+ createdAt: new Date(0).toISOString(),
125
+ }
126
+ }
127
+
128
+ function read(): StoreFile {
129
+ let text: string
130
+ try {
131
+ text = readFileSync(path, 'utf8')
132
+ } catch (error) {
133
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
134
+ return { version: ACCOUNTS_FILE_VERSION, accounts: [], keys: {} }
135
+ }
136
+ throw error
137
+ }
138
+ let json: unknown
139
+ try {
140
+ json = JSON.parse(text)
141
+ } catch {
142
+ throw new AccountRegistryError(`${path} is not valid JSON; no account was changed.`)
143
+ }
144
+ const parsed = FileSchema.safeParse(json)
145
+ if (!parsed.success) {
146
+ throw new AccountRegistryError(
147
+ `${path} is not a version ${ACCOUNTS_FILE_VERSION} account store.`,
148
+ )
149
+ }
150
+ return parsed.data
151
+ }
152
+
153
+ function mutate<T>(change: (file: StoreFile) => T): T {
154
+ mkdirSync(home, { recursive: true, mode: 0o700 })
155
+ return withAccountLockSync(
156
+ lockPath,
157
+ () => {
158
+ const file = read()
159
+ const result = change(file)
160
+ writeAtomic(path, `${JSON.stringify(file, null, 2)}\n`)
161
+ try {
162
+ chmodSync(path, 0o600)
163
+ } catch {
164
+ // Windows has no POSIX mode; the temporary was already created 0600 where it applies.
165
+ }
166
+ return result
167
+ },
168
+ { attempts: 80, wait: options.lockWait ?? sleepBlocking },
169
+ )
170
+ }
171
+
172
+ function ownDir(source: { kind: string; [key: string]: unknown }): string {
173
+ return String(source.dir)
174
+ }
175
+
176
+ function toAccount(stored: StoreFile['accounts'][number]): Account {
177
+ return stored as unknown as Account
178
+ }
179
+
180
+ function checkLabel(label: string): void {
181
+ if (!LABEL.test(label)) {
182
+ throw new AccountRegistryError(
183
+ 'An account label is 1 to 32 characters: lowercase letters, digits and hyphens.',
184
+ )
185
+ }
186
+ if (label === DEFAULT_ACCOUNT_LABEL) {
187
+ throw new AccountRegistryError('The label "default" belongs to the existing sign-in.')
188
+ }
189
+ }
190
+
191
+ function checkEndpointAndModel(input: { baseUrl?: string; defaultModel?: string }): void {
192
+ if (input.baseUrl !== undefined) {
193
+ let url: URL
194
+ try {
195
+ url = new URL(input.baseUrl)
196
+ } catch {
197
+ throw new AccountRegistryError('The base URL is not a valid address.')
198
+ }
199
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
200
+ throw new AccountRegistryError('The base URL must start with https:// or http://.')
201
+ }
202
+ if (url.username !== '' || url.password !== '') {
203
+ throw new AccountRegistryError('The base URL must not contain a user name or password.')
204
+ }
205
+ }
206
+ if (input.defaultModel !== undefined && !/^\S{1,200}$/.test(input.defaultModel)) {
207
+ throw new AccountRegistryError('The default model is one name of up to 200 characters.')
208
+ }
209
+ }
210
+
211
+ function realOrSelf(target: string): string {
212
+ // Resolve symlinks through the deepest existing ancestor, so a symlinked
213
+ // parent cannot lead outside the accounts folder.
214
+ let existing = resolve(target)
215
+ const rest: string[] = []
216
+ for (;;) {
217
+ try {
218
+ return join(realpathSync(existing), ...rest.reverse())
219
+ } catch {
220
+ const parent = dirname(existing)
221
+ if (parent === existing) return resolve(target)
222
+ rest.push(basename(existing))
223
+ existing = parent
224
+ }
225
+ }
226
+ }
227
+
228
+ function insideAccountsRoot(dir: string): boolean {
229
+ const rel = relative(realOrSelf(accountsRoot), realOrSelf(dir))
230
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
231
+ }
232
+
233
+ function nests(outer: string, inner: string): boolean {
234
+ const rel = relative(realOrSelf(outer), realOrSelf(inner))
235
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
236
+ }
237
+
238
+ const registry: AccountRegistry = {
239
+ list(provider) {
240
+ const stored = read().accounts.map(toAccount)
241
+ const providers = new Set<string>([
242
+ ...(options.defaultProviders ?? []),
243
+ ...stored.map((a) => a.provider),
244
+ ])
245
+ if (provider !== undefined) providers.add(provider)
246
+ const defaults = [...providers]
247
+ .filter((p) => provider === undefined || p === provider)
248
+ .map(defaultAccount)
249
+ return [
250
+ ...defaults,
251
+ ...stored.filter((a) => provider === undefined || a.provider === provider),
252
+ ]
253
+ },
254
+
255
+ get(id) {
256
+ const parsed = parseAccountId(id)
257
+ if (!parsed) return undefined
258
+ if (parsed.label === DEFAULT_ACCOUNT_LABEL) return defaultAccount(parsed.provider)
259
+ const stored = read().accounts.find((a) => a.id === id)
260
+ return stored ? toAccount(stored) : undefined
261
+ },
262
+
263
+ add(input: NewAccount) {
264
+ if (!PROVIDER.test(input.provider)) {
265
+ throw new AccountRegistryError('The provider name is not valid.')
266
+ }
267
+ checkLabel(input.label)
268
+ const id = accountId(input.provider, input.label)
269
+ return mutate((file) => {
270
+ if (file.accounts.some((a) => a.id === id)) {
271
+ throw new AccountRegistryError(`There is already an account labelled "${input.label}".`)
272
+ }
273
+ let source: AccountSource
274
+ if (input.kind === 'api-key') {
275
+ if (input.apiKey.length === 0) throw new AccountRegistryError('The API key is empty.')
276
+ checkEndpointAndModel(input)
277
+ const keyRef = `key-${randomBytes(9).toString('hex')}`
278
+ file.keys[keyRef] = input.apiKey
279
+ source = { kind: 'api-key', keyRef }
280
+ } else {
281
+ if ((input as { provider: string }).provider !== CODEX_PROVIDER) {
282
+ throw new AccountRegistryError(
283
+ 'A Codex home account belongs to the codex provider only.',
284
+ )
285
+ }
286
+ const dir = resolve(input.dir ?? join(accountsRoot, 'codex', input.label))
287
+ if (!insideAccountsRoot(dir)) {
288
+ throw new AccountRegistryError(
289
+ 'A Codex account directory must live under the Namzu accounts folder.',
290
+ )
291
+ }
292
+ if (
293
+ file.accounts.some(
294
+ (a) =>
295
+ a.source.kind === 'codex-home' &&
296
+ (nests(a.source.dir, dir) || nests(dir, a.source.dir)),
297
+ )
298
+ ) {
299
+ throw new AccountRegistryError(
300
+ 'Another account already uses that Codex directory, or one inside or around it.',
301
+ )
302
+ }
303
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
304
+ source = { kind: 'codex-home', dir }
305
+ }
306
+ const account: Account = {
307
+ id,
308
+ provider: input.provider,
309
+ label: input.label,
310
+ source,
311
+ createdAt: now().toISOString(),
312
+ ...(input.display !== undefined ? { display: input.display } : {}),
313
+ ...(input.kind === 'api-key' && input.baseUrl !== undefined
314
+ ? { baseUrl: input.baseUrl }
315
+ : {}),
316
+ ...(input.kind === 'api-key' && input.defaultModel !== undefined
317
+ ? { defaultModel: input.defaultModel }
318
+ : {}),
319
+ }
320
+ file.accounts.push(account as unknown as StoreFile['accounts'][number])
321
+ return account
322
+ })
323
+ },
324
+
325
+ rename(id, label) {
326
+ checkLabel(label)
327
+ return mutate((file) => {
328
+ const index = file.accounts.findIndex((a) => a.id === id)
329
+ const current = file.accounts[index]
330
+ if (!current) throw new AccountRegistryError('No such account.')
331
+ const nextId = accountId(current.provider, label)
332
+ if (file.accounts.some((a) => a.id === nextId)) {
333
+ throw new AccountRegistryError(`There is already an account labelled "${label}".`)
334
+ }
335
+ const renamed = { ...current, id: nextId, label }
336
+ file.accounts[index] = renamed
337
+ return toAccount(renamed)
338
+ })
339
+ },
340
+
341
+ remove(id) {
342
+ const parsed = parseAccountId(id)
343
+ if (parsed?.label === DEFAULT_ACCOUNT_LABEL) {
344
+ throw new AccountRegistryError(
345
+ 'The existing sign-in is not an account that can be removed.',
346
+ )
347
+ }
348
+ mutate((file) => {
349
+ const index = file.accounts.findIndex((a) => a.id === id)
350
+ const current = file.accounts[index]
351
+ if (!current) throw new AccountRegistryError('No such account.')
352
+ file.accounts.splice(index, 1)
353
+ if (current.source.kind === 'api-key') {
354
+ delete file.keys[current.source.keyRef]
355
+ } else if (
356
+ insideAccountsRoot(ownDir(current.source)) &&
357
+ !file.accounts.some(
358
+ (a) => a.source.kind === 'codex-home' && nests(ownDir(current.source), a.source.dir),
359
+ )
360
+ ) {
361
+ // Only a directory this registry could have created, and that holds no
362
+ // other account's directory, is ever deleted.
363
+ rmSync(ownDir(current.source), { recursive: true, force: true })
364
+ }
365
+ })
366
+ },
367
+
368
+ async resolve(id, signal): Promise<ResolvedAccountCredential> {
369
+ const account = registry.get(id)
370
+ if (!account) throw new CredentialWithdrawnError(id, 'That account no longer exists.')
371
+ const { source } = account
372
+ if (source.kind === 'existing-default') {
373
+ if (!options.readers?.existingDefault) {
374
+ throw new CredentialWithdrawnError(
375
+ id,
376
+ 'No reader is configured for the existing sign-in.',
377
+ )
378
+ }
379
+ return options.readers.existingDefault(account.provider, signal)
380
+ }
381
+ if (source.kind === 'codex-home') {
382
+ if (account.provider !== CODEX_PROVIDER) {
383
+ throw new CredentialWithdrawnError(
384
+ id,
385
+ 'A Codex home is only valid for the codex provider.',
386
+ )
387
+ }
388
+ if (!options.readers?.codexHome) {
389
+ throw new CredentialWithdrawnError(id, 'No reader is configured for Codex accounts.')
390
+ }
391
+ return options.readers.codexHome(
392
+ account as Account & { source: { kind: 'codex-home'; dir: string } },
393
+ signal,
394
+ )
395
+ }
396
+ const secret = read().keys[source.keyRef]
397
+ if (secret === undefined) {
398
+ throw new CredentialWithdrawnError(id, 'The saved key for this account is missing.')
399
+ }
400
+ return {
401
+ kind: 'api-key',
402
+ accessToken: secret,
403
+ ...(account.baseUrl !== undefined ? { baseUrl: account.baseUrl } : {}),
404
+ ...(account.defaultModel !== undefined ? { defaultModel: account.defaultModel } : {}),
405
+ }
406
+ },
407
+
408
+ next(provider, excluding) {
409
+ return registry.list(provider).find((a) => !excluding.includes(a.id))
410
+ },
411
+ }
412
+ return registry
413
+ }