@namzu/sdk 58.1.0 → 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 (90) hide show
  1. package/CHANGELOG.md +30 -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/runtime/query/iteration/phases/compaction-breaker.d.ts +7 -7
  47. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -1
  48. package/dist/runtime/query/iteration/phases/compaction-breaker.js +7 -7
  49. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -1
  50. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  51. package/dist/runtime/query/tool-output-budget.js +5 -3
  52. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  53. package/dist/tools/text-window.d.ts.map +1 -1
  54. package/dist/tools/text-window.js +4 -3
  55. package/dist/tools/text-window.js.map +1 -1
  56. package/dist/types/errors/catalog.d.ts.map +1 -1
  57. package/dist/types/errors/catalog.js +6 -0
  58. package/dist/types/errors/catalog.js.map +1 -1
  59. package/dist/types/provider/errors.d.ts +7 -0
  60. package/dist/types/provider/errors.d.ts.map +1 -1
  61. package/dist/types/provider/errors.js +27 -3
  62. package/dist/types/provider/errors.js.map +1 -1
  63. package/dist/types/session/records.d.ts +215 -1
  64. package/dist/types/session/records.d.ts.map +1 -1
  65. package/dist/types/session/records.js +13 -0
  66. package/dist/types/session/records.js.map +1 -1
  67. package/dist/utils/copy-string.d.ts +10 -0
  68. package/dist/utils/copy-string.d.ts.map +1 -0
  69. package/dist/utils/copy-string.js +12 -0
  70. package/dist/utils/copy-string.js.map +1 -0
  71. package/package.json +1 -1
  72. package/src/account/binding.ts +43 -0
  73. package/src/account/index.ts +32 -0
  74. package/src/account/lock.ts +184 -0
  75. package/src/account/registry.ts +413 -0
  76. package/src/account/types.ts +137 -0
  77. package/src/peers/record.ts +18 -0
  78. package/src/peers/registry.ts +2 -2
  79. package/src/provider/errors.ts +27 -1
  80. package/src/provider/token-budget.ts +9 -3
  81. package/src/public-runtime.ts +17 -0
  82. package/src/public-types.ts +15 -0
  83. package/src/runtime/query/executor.ts +10 -2
  84. package/src/runtime/query/iteration/phases/compaction-breaker.ts +7 -7
  85. package/src/runtime/query/tool-output-budget.ts +8 -4
  86. package/src/tools/text-window.ts +4 -3
  87. package/src/types/errors/catalog.ts +6 -0
  88. package/src/types/provider/errors.ts +40 -3
  89. package/src/types/session/records.ts +16 -0
  90. package/src/utils/copy-string.ts +11 -0
@@ -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
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Accounts: more than one credential for one provider, chosen per session.
3
+ *
4
+ * An account is a provider plus a label. Today's single sign-in for each
5
+ * provider is the account labelled `default`; it is read exactly as before and
6
+ * is never stored here. Further accounts are one of two kinds: a private
7
+ * Codex home directory that holds its own `auth.json`, or a labelled API key.
8
+ *
9
+ * @experimental
10
+ */
11
+
12
+ /** `<provider>:<label>`. The label is `[a-z0-9-]{1,32}`; `default` is reserved for today's sign-in. */
13
+ export type AccountId = string
14
+
15
+ /** The label of the account that wraps a provider's existing sign-in. */
16
+ export const DEFAULT_ACCOUNT_LABEL = 'default'
17
+
18
+ /** Where an account's credential comes from. */
19
+ export type AccountSource =
20
+ /** The provider's existing sign-in, read the way it was before accounts existed. Synthesised, never stored. */
21
+ | { readonly kind: 'existing-default' }
22
+ /** A private Codex home: `dir/auth.json` was written by `codex login` run with `CODEX_HOME=dir`. */
23
+ | { readonly kind: 'codex-home'; readonly dir: string }
24
+ /** A labelled API key. `keyRef` names a slot in the registry's secret map; the key itself is never in a log. */
25
+ | { readonly kind: 'api-key'; readonly keyRef: string }
26
+
27
+ export interface Account {
28
+ readonly id: AccountId
29
+ readonly provider: string
30
+ readonly label: string
31
+ readonly source: AccountSource
32
+ readonly createdAt: string
33
+ /** A short human tag such as the tail of the provider's account id. Never a token. */
34
+ readonly display?: string
35
+ /** API-key accounts only: the endpoint this key belongs to. A different endpoint is a different account. */
36
+ readonly baseUrl?: string
37
+ /** API-key accounts only: the model a session on this account starts with. */
38
+ readonly defaultModel?: string
39
+ }
40
+
41
+ /** What `add` accepts. The id, the creation time and the key slot are assigned by the registry. */
42
+ export type NewAccount =
43
+ | {
44
+ readonly provider: string
45
+ readonly label: string
46
+ readonly kind: 'api-key'
47
+ readonly apiKey: string
48
+ readonly display?: string
49
+ /** An http(s) endpoint without embedded credentials. */
50
+ readonly baseUrl?: string
51
+ readonly defaultModel?: string
52
+ }
53
+ | {
54
+ readonly provider: 'codex'
55
+ readonly label: string
56
+ readonly kind: 'codex-home'
57
+ /** Defaults to `<home>/accounts/codex/<label>`; must stay inside `<home>/accounts`. */
58
+ readonly dir?: string
59
+ readonly display?: string
60
+ }
61
+
62
+ /** A credential ready to put on a request. Never logged. */
63
+ export interface ResolvedAccountCredential {
64
+ readonly kind: 'api-key' | 'oauth'
65
+ readonly accessToken: string
66
+ /** Set for an API-key account that names its own endpoint. */
67
+ readonly baseUrl?: string
68
+ /** Set for an API-key account that names a starting model. */
69
+ readonly defaultModel?: string
70
+ /** The provider-side account identity some drivers send as a header. */
71
+ readonly accountId?: string
72
+ /** Epoch milliseconds, when the credential expires. */
73
+ readonly expiresAt?: number
74
+ }
75
+
76
+ /** The credential behind an account is gone or unusable; the caller should offer another account. */
77
+ export class CredentialWithdrawnError extends Error {
78
+ override readonly name = 'CredentialWithdrawnError'
79
+ constructor(
80
+ readonly accountId: AccountId,
81
+ message: string,
82
+ ) {
83
+ super(message)
84
+ }
85
+ }
86
+
87
+ /** A registry mutation was refused: bad label, duplicate, unknown account, unreadable store. */
88
+ export class AccountRegistryError extends Error {
89
+ override readonly name = 'AccountRegistryError'
90
+ }
91
+
92
+ /** Another owner holds the lock for a store or an account's credential file. */
93
+ export class AccountLockError extends Error {
94
+ override readonly name = 'AccountLockError'
95
+ constructor(
96
+ readonly lockPath: string,
97
+ message: string,
98
+ ) {
99
+ super(message)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Source-specific readers. The registry owns the bookkeeping; the code that
105
+ * knows how a provider's sign-in is discovered or refreshed lives with the
106
+ * provider and is handed in here.
107
+ */
108
+ export interface AccountCredentialReaders {
109
+ /** Read the provider's existing sign-in. Throws {@link CredentialWithdrawnError} when there is none. */
110
+ readonly existingDefault?: (
111
+ provider: string,
112
+ signal: AbortSignal,
113
+ ) => Promise<ResolvedAccountCredential>
114
+ /**
115
+ * Read, and refresh when due, the credential in a private Codex home. An
116
+ * implementation holds `withAccountRefreshLock` across the read-refresh-write
117
+ * so two processes never spend one refresh token twice.
118
+ */
119
+ readonly codexHome?: (
120
+ account: Account & { readonly source: { readonly kind: 'codex-home'; readonly dir: string } },
121
+ signal: AbortSignal,
122
+ ) => Promise<ResolvedAccountCredential>
123
+ }
124
+
125
+ export interface AccountRegistry {
126
+ /** Stored accounts plus the synthesised `default` of each known provider. Defaults first, then creation order. */
127
+ list(provider?: string): Account[]
128
+ get(id: AccountId): Account | undefined
129
+ add(account: NewAccount): Account
130
+ /** Change an account's label; its id changes with it. Sessions bound to the old id fall back to `default`. */
131
+ rename(id: AccountId, label: string): Account
132
+ /** Remove a stored account and its secret or private Codex directory. The `default` account cannot be removed. */
133
+ remove(id: AccountId): void
134
+ resolve(id: AccountId, signal: AbortSignal): Promise<ResolvedAccountCredential>
135
+ /** The next account of `provider` not in `excluding`, for the "continue with another account" picker. */
136
+ next(provider: string, excluding: readonly AccountId[]): Account | undefined
137
+ }
@@ -43,9 +43,27 @@ export const PeerRecordSchema = z
43
43
  token: z.string().min(1),
44
44
  protocol: z.literal(PEER_PROTOCOL_VERSION),
45
45
  cliVersion: z.string(),
46
+ /** The slot a spaces host gave this terminal at launch; how the host finds its own sessions. */
47
+ hostSlot: z.string().min(1).max(128).optional(),
48
+ /** The conversation this terminal is on, once it has one. */
49
+ conversationId: z.string().min(1).optional(),
50
+ /** The label of the non-default account the conversation is bound to; absent for the default. */
51
+ account: z.string().min(1).optional(),
52
+ /** True while the bound account is out of allowance and the terminal is waiting for a choice. */
53
+ limited: z.boolean().optional(),
54
+ /** When the allowance is expected back (epoch ms); absent when the provider did not say. */
55
+ limitedUntil: z.number().int().positive().optional(),
46
56
  })
47
57
  .strict()
48
58
 
59
+ /**
60
+ * The reader's view of a record: the same fields, but a key this build does
61
+ * not know is dropped instead of failing the whole record. A record written by
62
+ * a newer build (extra optional fields) stays visible to an older reader.
63
+ * Writers keep using the strict `PeerRecordSchema`.
64
+ */
65
+ export const PeerRecordReaderSchema = PeerRecordSchema.strip()
66
+
49
67
  export type PeerRecord = z.infer<typeof PeerRecordSchema>
50
68
 
51
69
  const PEER_REF_HEX_LENGTH = 6
@@ -20,7 +20,7 @@ import {
20
20
  import { basename, dirname, join } from 'node:path'
21
21
  import { parsePeerAddress } from './address.js'
22
22
  import { pingPeer } from './client.js'
23
- import { type PeerRecord, PeerRecordSchema } from './record.js'
23
+ import { type PeerRecord, PeerRecordReaderSchema, PeerRecordSchema } from './record.js'
24
24
 
25
25
  const RECORD_FILE_MODE = 0o600
26
26
 
@@ -75,7 +75,7 @@ function parseRecordText(text: string): PeerRecord | undefined {
75
75
  } catch {
76
76
  return undefined
77
77
  }
78
- const result = PeerRecordSchema.safeParse(parsed)
78
+ const result = PeerRecordReaderSchema.safeParse(parsed)
79
79
  return result.success ? result.data : undefined
80
80
  }
81
81
 
@@ -33,6 +33,7 @@ import { LOG_SECRET_PATTERNS } from '../constants/secret-patterns.js'
33
33
  import type { ProviderErrorKind, ProviderRequestErrorInit } from '../types/provider/error.js'
34
34
  import {
35
35
  type TransportCause,
36
+ isUsageLimitSignal,
36
37
  readCertificateFailure,
37
38
  readTransportCause,
38
39
  } from '../types/provider/errors.js'
@@ -130,6 +131,31 @@ function providerCode(value: unknown): string | undefined {
130
131
  return safeProviderCode(record.code ?? nested)
131
132
  }
132
133
 
134
+ /**
135
+ * A vendor can name a spent allowance in `type` instead of `code`
136
+ * (`{"error":{"type":"usage_limit_reached"}}`, `{"error":{"type":"billing_error"}}`).
137
+ * Only those identifiers are lifted to a provider code; any other `type` is a
138
+ * category and stays where it was.
139
+ */
140
+ function usageLimitTypeOf(error: unknown): string | undefined {
141
+ if (typeof error !== 'object' || error === null) return undefined
142
+ const outer = error as { type?: unknown; error?: unknown }
143
+ const inner =
144
+ typeof outer.error === 'object' && outer.error !== null
145
+ ? (outer.error as { type?: unknown; error?: unknown })
146
+ : undefined
147
+ const innermost =
148
+ typeof inner?.error === 'object' && inner.error !== null
149
+ ? (inner.error as { type?: unknown })
150
+ : undefined
151
+ for (const candidate of [outer.type, inner?.type, innermost?.type]) {
152
+ if (typeof candidate === 'string' && isUsageLimitSignal(candidate, undefined)) {
153
+ return safeProviderCode(candidate)
154
+ }
155
+ }
156
+ return undefined
157
+ }
158
+
133
159
  /**
134
160
  * Credential shapes to scrub before a provider's words are kept.
135
161
  *
@@ -524,7 +550,7 @@ export function providerVendorError(input: {
524
550
  }): ProviderRequestError {
525
551
  const { error } = input
526
552
  const status = vendorErrorStatus(error)
527
- const exactProviderCode = providerCode(error)
553
+ const exactProviderCode = providerCode(error) ?? usageLimitTypeOf(error)
528
554
  const name = error instanceof Error ? error.name : ''
529
555
  const message = error instanceof Error ? error.message : ''
530
556
 
@@ -29,9 +29,14 @@ function rejectedBeforeGeneration(error: unknown): boolean {
29
29
  [400, 401, 403, 404, 413, 422, 429].includes(classified.status))
30
30
  return (
31
31
  explicitRejection &&
32
- ['auth', 'rate_limit', 'context_length_exceeded', 'invalid_request', 'not_found'].includes(
33
- classified.code,
34
- )
32
+ [
33
+ 'auth',
34
+ 'rate_limit',
35
+ 'usage_limit',
36
+ 'context_length_exceeded',
37
+ 'invalid_request',
38
+ 'not_found',
39
+ ].includes(classified.code)
35
40
  )
36
41
  }
37
42
 
@@ -96,6 +101,7 @@ export function withTokenBudget(provider: LLMProvider, budget: SessionTokenBudge
96
101
  ![
97
102
  'auth',
98
103
  'rate_limit',
104
+ 'usage_limit',
99
105
  'context_length_exceeded',
100
106
  'invalid_request',
101
107
  'not_found',
@@ -1869,6 +1869,23 @@ export {
1869
1869
  udsPeerAddress,
1870
1870
  writePeerRecord,
1871
1871
  } from './peers/index.js'
1872
+ export {
1873
+ ACCOUNTS_FILE_NAME,
1874
+ ACCOUNTS_FILE_VERSION,
1875
+ AccountLockError,
1876
+ AccountRegistryError,
1877
+ CredentialWithdrawnError,
1878
+ DEFAULT_ACCOUNT_LABEL,
1879
+ accountBoundPayload,
1880
+ accountId,
1881
+ codexRefreshLockPath,
1882
+ createAccountRegistry,
1883
+ parseAccountId,
1884
+ resolveBoundAccount,
1885
+ withAccountLock,
1886
+ withAccountLockSync,
1887
+ withAccountRefreshLock,
1888
+ } from './account/index.js'
1872
1889
  export { DiskPalStore, PalConflictError } from './pals/store.js'
1873
1890
  export { PalRuntime, PalUnavailableError } from './pals/runtime.js'
1874
1891
  export { buildPalSystemPrompt, palConversationGreeting } from './pals/prompt.js'