@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.
- package/CHANGELOG.md +22 -0
- package/dist/account/binding.d.ts +20 -0
- package/dist/account/binding.d.ts.map +1 -0
- package/dist/account/binding.js +27 -0
- package/dist/account/binding.js.map +1 -0
- package/dist/account/index.d.ts +9 -0
- package/dist/account/index.d.ts.map +1 -0
- package/dist/account/index.js +5 -0
- package/dist/account/index.js.map +1 -0
- package/dist/account/lock.d.ts +33 -0
- package/dist/account/lock.d.ts.map +1 -0
- package/dist/account/lock.js +170 -0
- package/dist/account/lock.js.map +1 -0
- package/dist/account/registry.d.ts +21 -0
- package/dist/account/registry.d.ts.map +1 -0
- package/dist/account/registry.js +340 -0
- package/dist/account/registry.js.map +1 -0
- package/dist/account/types.d.ts +124 -0
- package/dist/account/types.d.ts.map +1 -0
- package/dist/account/types.js +35 -0
- package/dist/account/types.js.map +1 -0
- package/dist/pals/communication/ingress-schema.d.ts +38 -38
- package/dist/peers/record.d.ts +98 -0
- package/dist/peers/record.d.ts.map +1 -1
- package/dist/peers/record.js +17 -0
- package/dist/peers/record.js.map +1 -1
- package/dist/peers/registry.d.ts.map +1 -1
- package/dist/peers/registry.js +2 -2
- package/dist/peers/registry.js.map +1 -1
- package/dist/provider/errors.d.ts.map +1 -1
- package/dist/provider/errors.js +25 -2
- package/dist/provider/errors.js.map +1 -1
- package/dist/provider/token-budget.d.ts.map +1 -1
- package/dist/provider/token-budget.js +9 -1
- package/dist/provider/token-budget.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/executor.d.ts +7 -2
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +10 -2
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/types/errors/catalog.d.ts.map +1 -1
- package/dist/types/errors/catalog.js +6 -0
- package/dist/types/errors/catalog.js.map +1 -1
- package/dist/types/provider/errors.d.ts +7 -0
- package/dist/types/provider/errors.d.ts.map +1 -1
- package/dist/types/provider/errors.js +27 -3
- package/dist/types/provider/errors.js.map +1 -1
- package/dist/types/session/records.d.ts +215 -1
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +13 -0
- package/dist/types/session/records.js.map +1 -1
- package/package.json +1 -1
- package/src/account/binding.ts +43 -0
- package/src/account/index.ts +32 -0
- package/src/account/lock.ts +184 -0
- package/src/account/registry.ts +413 -0
- package/src/account/types.ts +137 -0
- package/src/peers/record.ts +18 -0
- package/src/peers/registry.ts +2 -2
- package/src/provider/errors.ts +27 -1
- package/src/provider/token-budget.ts +9 -3
- package/src/public-runtime.ts +17 -0
- package/src/public-types.ts +15 -0
- package/src/runtime/query/executor.ts +10 -2
- package/src/types/errors/catalog.ts +6 -0
- package/src/types/provider/errors.ts +40 -3
- 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
|
+
}
|