praxis-agent 0.43.1 → 0.45.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/README.md +45 -13
- package/dist/application/session-service.js +84 -4
- package/dist/application/top-level-agent-manager.d.ts +7 -0
- package/dist/application/top-level-agent-manager.js +21 -3
- package/dist/cli/controls.js +4 -0
- package/dist/cli/interactive.js +3 -1
- package/dist/cli/protocol.d.ts +5 -0
- package/dist/cli/protocol.js +45 -0
- package/dist/cli/provider-auth-command.d.ts +32 -0
- package/dist/cli/provider-auth-command.js +249 -0
- package/dist/cli/tui/quiet-frame.js +4 -4
- package/dist/cli/tui/transcript-viewport.js +3 -3
- package/dist/cli/workspace-trust-prompt.js +2 -2
- package/dist/cli-runtime.d.ts +34 -1
- package/dist/cli-runtime.js +587 -184
- package/dist/maintenance/doctor.js +100 -22
- package/dist/persistence/provider-credential-vault.d.ts +72 -0
- package/dist/persistence/provider-credential-vault.js +498 -0
- package/dist/providers/codex-oauth.d.ts +104 -0
- package/dist/providers/codex-oauth.js +770 -0
- package/dist/providers/codex-subscription.d.ts +33 -0
- package/dist/providers/codex-subscription.js +849 -0
- package/dist/providers/provider-auth.d.ts +71 -0
- package/dist/providers/provider-auth.js +149 -0
- package/dist/providers/provider-registry.d.ts +52 -0
- package/dist/providers/provider-registry.js +187 -0
- package/dist/providers/provider-settings.d.ts +38 -0
- package/dist/providers/provider-settings.js +334 -0
- package/dist/security/workspace-trust.d.ts +4 -3
- package/dist/security/workspace-trust.js +35 -1
- package/package.json +6 -3
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { chmod, lstat, mkdir, open, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import { getuid } from 'node:process';
|
|
6
|
+
import { userInfo } from 'node:os';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
10
|
+
import { ExclusiveFileLease } from '../platform/exclusive-file-lease.js';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const MAX_BYTES = 1024 * 1024;
|
|
13
|
+
const LOCK_WAIT_MS = 10_000;
|
|
14
|
+
export class ProviderKeychainUnavailableError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('Provider credential Keychain is unavailable');
|
|
17
|
+
this.name = 'ProviderKeychainUnavailableError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function providerCredentialFilePath(configRoot) {
|
|
21
|
+
return join(resolve(configRoot), '.provider-credentials.json');
|
|
22
|
+
}
|
|
23
|
+
export function providerCredentialService(configRoot) {
|
|
24
|
+
const canonical = resolve(configRoot);
|
|
25
|
+
const digest = createHash('sha256')
|
|
26
|
+
.update(canonical)
|
|
27
|
+
.digest('hex')
|
|
28
|
+
.slice(0, 12);
|
|
29
|
+
return `Praxis-provider-credentials-${digest}`;
|
|
30
|
+
}
|
|
31
|
+
function isRecord(value) {
|
|
32
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function validId(value) {
|
|
35
|
+
return (typeof value === 'string' &&
|
|
36
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value));
|
|
37
|
+
}
|
|
38
|
+
function validUpdatedAt(value) {
|
|
39
|
+
return (typeof value === 'string' &&
|
|
40
|
+
Number.isFinite(Date.parse(value)) &&
|
|
41
|
+
new Date(value).toISOString() === value);
|
|
42
|
+
}
|
|
43
|
+
function validAccountId(value) {
|
|
44
|
+
return (typeof value === 'string' &&
|
|
45
|
+
value.length > 0 &&
|
|
46
|
+
value === value.trim() &&
|
|
47
|
+
Buffer.byteLength(value) <= 512);
|
|
48
|
+
}
|
|
49
|
+
function validateRecord(value, path) {
|
|
50
|
+
if (!isRecord(value) || (value.type !== 'api-key' && value.type !== 'oauth'))
|
|
51
|
+
throw new Error(`Invalid provider credential record: ${path}`);
|
|
52
|
+
if (!Number.isSafeInteger(value.revision) ||
|
|
53
|
+
Number(value.revision) <= 0 ||
|
|
54
|
+
!validUpdatedAt(value.updatedAt))
|
|
55
|
+
throw new Error(`Invalid provider credential metadata: ${path}`);
|
|
56
|
+
const revision = value.revision;
|
|
57
|
+
if (value.type === 'api-key') {
|
|
58
|
+
if (typeof value.secret !== 'string' ||
|
|
59
|
+
value.secret.trim().length === 0 ||
|
|
60
|
+
value.secret.length > MAX_BYTES)
|
|
61
|
+
throw new Error(`Invalid provider API key credential: ${path}`);
|
|
62
|
+
return {
|
|
63
|
+
type: 'api-key',
|
|
64
|
+
secret: value.secret,
|
|
65
|
+
revision,
|
|
66
|
+
updatedAt: value.updatedAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (typeof value.accessToken !== 'string' ||
|
|
70
|
+
value.accessToken.length === 0 ||
|
|
71
|
+
typeof value.refreshToken !== 'string' ||
|
|
72
|
+
value.refreshToken.length === 0 ||
|
|
73
|
+
typeof value.expiresAt !== 'number' ||
|
|
74
|
+
!Number.isFinite(value.expiresAt) ||
|
|
75
|
+
value.expiresAt < 0 ||
|
|
76
|
+
value.accessToken.length > MAX_BYTES ||
|
|
77
|
+
value.refreshToken.length > MAX_BYTES)
|
|
78
|
+
throw new Error(`Invalid provider OAuth credential: ${path}`);
|
|
79
|
+
if (value.accountId !== undefined && !validAccountId(value.accountId))
|
|
80
|
+
throw new Error(`Invalid provider OAuth account ID: ${path}`);
|
|
81
|
+
return {
|
|
82
|
+
type: 'oauth',
|
|
83
|
+
accessToken: value.accessToken,
|
|
84
|
+
refreshToken: value.refreshToken,
|
|
85
|
+
expiresAt: value.expiresAt,
|
|
86
|
+
...(value.accountId === undefined ? {} : { accountId: value.accountId }),
|
|
87
|
+
revision,
|
|
88
|
+
updatedAt: value.updatedAt,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function validateInput(value, path) {
|
|
92
|
+
if (!isRecord(value) || (value.type !== 'api-key' && value.type !== 'oauth'))
|
|
93
|
+
throw new Error(`Invalid provider credential input: ${path}`);
|
|
94
|
+
if (value.revision !== undefined || value.updatedAt !== undefined)
|
|
95
|
+
throw new Error(`Provider credential input must not include persistence metadata: ${path}`);
|
|
96
|
+
if (value.type === 'api-key') {
|
|
97
|
+
if (typeof value.secret !== 'string' ||
|
|
98
|
+
value.secret.trim().length === 0 ||
|
|
99
|
+
value.secret.length > MAX_BYTES)
|
|
100
|
+
throw new Error(`Invalid provider API key input: ${path}`);
|
|
101
|
+
return { type: 'api-key', secret: value.secret };
|
|
102
|
+
}
|
|
103
|
+
if (typeof value.accessToken !== 'string' ||
|
|
104
|
+
value.accessToken.length === 0 ||
|
|
105
|
+
value.accessToken.length > MAX_BYTES ||
|
|
106
|
+
typeof value.refreshToken !== 'string' ||
|
|
107
|
+
value.refreshToken.length === 0 ||
|
|
108
|
+
value.refreshToken.length > MAX_BYTES ||
|
|
109
|
+
typeof value.expiresAt !== 'number' ||
|
|
110
|
+
!Number.isFinite(value.expiresAt) ||
|
|
111
|
+
value.expiresAt < 0)
|
|
112
|
+
throw new Error(`Invalid provider OAuth input: ${path}`);
|
|
113
|
+
if (value.accountId !== undefined && !validAccountId(value.accountId))
|
|
114
|
+
throw new Error(`Invalid provider OAuth account ID: ${path}`);
|
|
115
|
+
return {
|
|
116
|
+
type: 'oauth',
|
|
117
|
+
accessToken: value.accessToken,
|
|
118
|
+
refreshToken: value.refreshToken,
|
|
119
|
+
expiresAt: value.expiresAt,
|
|
120
|
+
...(value.accountId === undefined ? {} : { accountId: value.accountId }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function keyString(key) {
|
|
124
|
+
if (!validId(key.providerId) || !validId(key.profileId))
|
|
125
|
+
throw new Error('Invalid provider credential key');
|
|
126
|
+
return `${key.providerId}|${key.profileId}`;
|
|
127
|
+
}
|
|
128
|
+
function parseKey(value) {
|
|
129
|
+
const separator = value.indexOf('|');
|
|
130
|
+
if (separator <= 0 || separator === value.length - 1)
|
|
131
|
+
throw new Error(`Invalid provider credential key: ${value}`);
|
|
132
|
+
const key = {
|
|
133
|
+
providerId: value.slice(0, separator),
|
|
134
|
+
profileId: value.slice(separator + 1),
|
|
135
|
+
};
|
|
136
|
+
keyString(key);
|
|
137
|
+
return key;
|
|
138
|
+
}
|
|
139
|
+
function parseEnvelope(source, path) {
|
|
140
|
+
if (Buffer.byteLength(source) > MAX_BYTES)
|
|
141
|
+
throw new Error(`Provider credential store exceeds 1 MiB: ${path}`);
|
|
142
|
+
let value;
|
|
143
|
+
try {
|
|
144
|
+
value = JSON.parse(source);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
throw new Error(`Invalid provider credential JSON: ${path}`, {
|
|
148
|
+
cause: error,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.credentials))
|
|
152
|
+
throw new Error(`Unsupported provider credential envelope: ${path}`);
|
|
153
|
+
const credentials = {};
|
|
154
|
+
for (const [key, record] of Object.entries(value.credentials)) {
|
|
155
|
+
parseKey(key);
|
|
156
|
+
credentials[key] = validateRecord(record, `${path}:${key}`);
|
|
157
|
+
}
|
|
158
|
+
let deleted;
|
|
159
|
+
if (value.deleted !== undefined) {
|
|
160
|
+
if (!isRecord(value.deleted))
|
|
161
|
+
throw new Error(`Invalid provider credential tombstones: ${path}`);
|
|
162
|
+
deleted = {};
|
|
163
|
+
for (const [key, tombstone] of Object.entries(value.deleted)) {
|
|
164
|
+
parseKey(key);
|
|
165
|
+
const revision = isRecord(tombstone) ? tombstone.revision : undefined;
|
|
166
|
+
const updatedAt = isRecord(tombstone) ? tombstone.updatedAt : undefined;
|
|
167
|
+
if (!isRecord(tombstone) ||
|
|
168
|
+
!Number.isSafeInteger(revision) ||
|
|
169
|
+
revision <= 0 ||
|
|
170
|
+
!validUpdatedAt(updatedAt))
|
|
171
|
+
throw new Error(`Invalid provider credential tombstone: ${path}:${key}`);
|
|
172
|
+
if (credentials[key] !== undefined)
|
|
173
|
+
throw new Error(`Provider credential has both record and tombstone: ${key}`);
|
|
174
|
+
deleted[key] = {
|
|
175
|
+
revision: revision,
|
|
176
|
+
updatedAt,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
version: 1,
|
|
182
|
+
credentials,
|
|
183
|
+
...(deleted === undefined || Object.keys(deleted).length === 0
|
|
184
|
+
? {}
|
|
185
|
+
: { deleted }),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async function secureRoot(root, forWrite) {
|
|
189
|
+
let information;
|
|
190
|
+
try {
|
|
191
|
+
const linkInformation = await lstat(root);
|
|
192
|
+
if (linkInformation.isSymbolicLink())
|
|
193
|
+
throw new Error(`Provider credential config root must not be a symlink: ${root}`);
|
|
194
|
+
information = await stat(root);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.code !== 'ENOENT')
|
|
198
|
+
throw error;
|
|
199
|
+
if (!forWrite)
|
|
200
|
+
return;
|
|
201
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
202
|
+
information = await stat(root);
|
|
203
|
+
}
|
|
204
|
+
if (!information.isDirectory())
|
|
205
|
+
throw new Error(`Provider credential config root is not a directory: ${root}`);
|
|
206
|
+
if (getuid && information.uid !== getuid())
|
|
207
|
+
throw new Error(`Provider credential config root is not owned by the current user: ${root}`);
|
|
208
|
+
if (forWrite) {
|
|
209
|
+
await chmod(root, 0o700);
|
|
210
|
+
information = await stat(root);
|
|
211
|
+
}
|
|
212
|
+
if ((information.mode & 0o077) !== 0)
|
|
213
|
+
throw new Error(`Provider credential config root must not be group/world accessible: ${root}`);
|
|
214
|
+
}
|
|
215
|
+
async function readFileEnvelope(path) {
|
|
216
|
+
let handle;
|
|
217
|
+
try {
|
|
218
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
219
|
+
handle = await open(path, constants.O_RDONLY | noFollow);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error.code === 'ENOENT')
|
|
223
|
+
return undefined;
|
|
224
|
+
if (error.code === 'ELOOP')
|
|
225
|
+
throw new Error(`Provider credential path must be a regular non-symlink file: ${path}`);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const information = await handle.stat();
|
|
230
|
+
if (!information.isFile())
|
|
231
|
+
throw new Error(`Provider credential path must be a regular non-symlink file: ${path}`);
|
|
232
|
+
if (getuid && information.uid !== getuid())
|
|
233
|
+
throw new Error(`Provider credential file is not owned by the current user: ${path}`);
|
|
234
|
+
if ((information.mode & 0o077) !== 0)
|
|
235
|
+
throw new Error(`Provider credential file must not be group/world accessible: ${path}`);
|
|
236
|
+
if (information.size > MAX_BYTES)
|
|
237
|
+
throw new Error(`Provider credential store exceeds 1 MiB: ${path}`);
|
|
238
|
+
const source = await handle.readFile('utf8');
|
|
239
|
+
return parseEnvelope(source, path);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
await handle.close();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function sameCandidate(left, right) {
|
|
246
|
+
return (left.kind === right.kind &&
|
|
247
|
+
JSON.stringify(left.value) === JSON.stringify(right.value));
|
|
248
|
+
}
|
|
249
|
+
function mergeEnvelopes(file, keychain) {
|
|
250
|
+
const credentials = {};
|
|
251
|
+
const deleted = {};
|
|
252
|
+
const keys = new Set([
|
|
253
|
+
...Object.keys(file?.credentials ?? {}),
|
|
254
|
+
...Object.keys(file?.deleted ?? {}),
|
|
255
|
+
...Object.keys(keychain?.credentials ?? {}),
|
|
256
|
+
...Object.keys(keychain?.deleted ?? {}),
|
|
257
|
+
]);
|
|
258
|
+
for (const key of keys) {
|
|
259
|
+
const fromFile = file?.credentials[key]
|
|
260
|
+
? { kind: 'credential', value: file.credentials[key] }
|
|
261
|
+
: file?.deleted?.[key]
|
|
262
|
+
? { kind: 'deleted', value: file.deleted[key] }
|
|
263
|
+
: undefined;
|
|
264
|
+
const fromKeychain = keychain?.credentials[key]
|
|
265
|
+
? { kind: 'credential', value: keychain.credentials[key] }
|
|
266
|
+
: keychain?.deleted?.[key]
|
|
267
|
+
? { kind: 'deleted', value: keychain.deleted[key] }
|
|
268
|
+
: undefined;
|
|
269
|
+
if (fromFile && fromKeychain) {
|
|
270
|
+
if (fromFile.value.revision === fromKeychain.value.revision &&
|
|
271
|
+
!sameCandidate(fromFile, fromKeychain))
|
|
272
|
+
throw new Error(`Conflicting provider credentials for ${key}`);
|
|
273
|
+
const winner = fromFile.value.revision > fromKeychain.value.revision
|
|
274
|
+
? fromFile
|
|
275
|
+
: fromKeychain;
|
|
276
|
+
if (winner.kind === 'credential')
|
|
277
|
+
credentials[key] = winner.value;
|
|
278
|
+
else
|
|
279
|
+
deleted[key] = winner.value;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const winner = fromFile ?? fromKeychain;
|
|
283
|
+
if (!winner)
|
|
284
|
+
continue;
|
|
285
|
+
if (winner.kind === 'credential')
|
|
286
|
+
credentials[key] = winner.value;
|
|
287
|
+
else
|
|
288
|
+
deleted[key] = winner.value;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
version: 1,
|
|
293
|
+
credentials,
|
|
294
|
+
...(Object.keys(deleted).length === 0 ? {} : { deleted }),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function metadata(key, record) {
|
|
298
|
+
const parsed = parseKey(key);
|
|
299
|
+
return {
|
|
300
|
+
key: parsed,
|
|
301
|
+
type: record.type,
|
|
302
|
+
revision: record.revision,
|
|
303
|
+
updatedAt: record.updatedAt,
|
|
304
|
+
...(record.type === 'oauth' && record.expiresAt !== undefined
|
|
305
|
+
? { expiresAt: record.expiresAt }
|
|
306
|
+
: {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function productionKeychain() {
|
|
310
|
+
let account;
|
|
311
|
+
try {
|
|
312
|
+
const username = userInfo().username;
|
|
313
|
+
account = username.length > 0 && username.length <= 256 ? username : 'user';
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
account = 'user';
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
async read(service) {
|
|
320
|
+
try {
|
|
321
|
+
const result = await execFileAsync('security', ['find-generic-password', '-a', account, '-s', service, '-w'], { maxBuffer: MAX_BYTES });
|
|
322
|
+
return result.stdout;
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
const detail = `${String(error)}\n${String(error.stderr ?? '')}`;
|
|
326
|
+
if (/could not be found|SecKeychainSearchCopyNext/u.test(detail))
|
|
327
|
+
return undefined;
|
|
328
|
+
if (error.code === 'ENOENT')
|
|
329
|
+
throw new ProviderKeychainUnavailableError();
|
|
330
|
+
throw new Error('Unable to read macOS Keychain provider credentials');
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
async write(service, serializedEnvelope) {
|
|
334
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
335
|
+
const child = spawn('security', ['add-generic-password', '-U', '-a', account, '-s', service, '-w'], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
336
|
+
let settled = false;
|
|
337
|
+
const reject = (error) => {
|
|
338
|
+
if (!settled) {
|
|
339
|
+
settled = true;
|
|
340
|
+
rejectPromise(error?.code === 'ENOENT'
|
|
341
|
+
? new ProviderKeychainUnavailableError()
|
|
342
|
+
: new Error('Unable to update macOS Keychain provider credentials'));
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
child.once('error', reject);
|
|
346
|
+
child.once('close', (code) => {
|
|
347
|
+
if (settled)
|
|
348
|
+
return;
|
|
349
|
+
settled = true;
|
|
350
|
+
if (code === 0)
|
|
351
|
+
resolvePromise();
|
|
352
|
+
else
|
|
353
|
+
rejectPromise(new Error('Unable to update macOS Keychain provider credentials'));
|
|
354
|
+
});
|
|
355
|
+
child.stdin.once('error', reject);
|
|
356
|
+
child.stdin.end(`${serializedEnvelope}\n`);
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
async delete(service) {
|
|
360
|
+
try {
|
|
361
|
+
await execFileAsync('security', ['delete-generic-password', '-a', account, '-s', service], { maxBuffer: MAX_BYTES });
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
const detail = `${String(error)}\n${String(error.stderr ?? '')}`;
|
|
365
|
+
if (/could not be found|SecKeychainSearchCopyNext/u.test(detail))
|
|
366
|
+
return;
|
|
367
|
+
if (error.code === 'ENOENT')
|
|
368
|
+
throw new ProviderKeychainUnavailableError();
|
|
369
|
+
throw new Error('Unable to delete macOS Keychain provider credentials');
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
export class ProviderCredentialVault {
|
|
375
|
+
root;
|
|
376
|
+
file;
|
|
377
|
+
useKeychain;
|
|
378
|
+
service;
|
|
379
|
+
keychain;
|
|
380
|
+
lease;
|
|
381
|
+
constructor(options) {
|
|
382
|
+
this.root = resolve(options.configRoot);
|
|
383
|
+
this.file = providerCredentialFilePath(this.root);
|
|
384
|
+
const environment = options.environment ?? process.env;
|
|
385
|
+
this.useKeychain =
|
|
386
|
+
options.useKeychain ??
|
|
387
|
+
(process.platform === 'darwin' &&
|
|
388
|
+
environment.PRAXIS_PROVIDER_CREDENTIAL_STORE !== 'file');
|
|
389
|
+
this.service = providerCredentialService(this.root);
|
|
390
|
+
this.keychain = options.keychain ?? productionKeychain();
|
|
391
|
+
this.lease = new ExclusiveFileLease(join(this.root, '.provider-credentials.lock'));
|
|
392
|
+
}
|
|
393
|
+
async read(key) {
|
|
394
|
+
const keyName = keyString(key);
|
|
395
|
+
const envelope = await this.readReconciled();
|
|
396
|
+
return envelope.credentials[keyName];
|
|
397
|
+
}
|
|
398
|
+
async list() {
|
|
399
|
+
const envelope = await this.readReconciled();
|
|
400
|
+
return Object.entries(envelope.credentials).map(([key, record]) => metadata(key, record));
|
|
401
|
+
}
|
|
402
|
+
async modify(key, callback) {
|
|
403
|
+
const keyName = keyString(key);
|
|
404
|
+
await secureRoot(this.root, true);
|
|
405
|
+
const handle = await this.acquire();
|
|
406
|
+
try {
|
|
407
|
+
await secureRoot(this.root, true);
|
|
408
|
+
const currentEnvelope = await this.readReconciled();
|
|
409
|
+
const next = await callback(currentEnvelope.credentials[keyName]);
|
|
410
|
+
const previousRevision = Math.max(currentEnvelope.credentials[keyName]?.revision ?? 0, currentEnvelope.deleted?.[keyName]?.revision ?? 0);
|
|
411
|
+
const revision = nextRevision(previousRevision);
|
|
412
|
+
if (next === undefined) {
|
|
413
|
+
delete currentEnvelope.credentials[keyName];
|
|
414
|
+
currentEnvelope.deleted = {
|
|
415
|
+
...(currentEnvelope.deleted ?? {}),
|
|
416
|
+
[keyName]: { revision, updatedAt: new Date().toISOString() },
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
const checked = validateInput(next, keyName);
|
|
421
|
+
currentEnvelope.credentials[keyName] = {
|
|
422
|
+
...checked,
|
|
423
|
+
revision,
|
|
424
|
+
updatedAt: new Date().toISOString(),
|
|
425
|
+
};
|
|
426
|
+
if (currentEnvelope.deleted !== undefined) {
|
|
427
|
+
delete currentEnvelope.deleted[keyName];
|
|
428
|
+
if (Object.keys(currentEnvelope.deleted).length === 0)
|
|
429
|
+
delete currentEnvelope.deleted;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
await this.persist(currentEnvelope);
|
|
433
|
+
return currentEnvelope.credentials[keyName];
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await handle.release();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async delete(key) {
|
|
440
|
+
await this.modify(key, () => undefined);
|
|
441
|
+
}
|
|
442
|
+
async readReconciled() {
|
|
443
|
+
await secureRoot(this.root, false);
|
|
444
|
+
const file = await readFileEnvelope(this.file);
|
|
445
|
+
let keychain;
|
|
446
|
+
if (this.useKeychain) {
|
|
447
|
+
let source;
|
|
448
|
+
try {
|
|
449
|
+
source = await this.keychain.read(this.service);
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (!(error instanceof ProviderKeychainUnavailableError))
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
if (source !== undefined)
|
|
456
|
+
keychain = parseEnvelope(source, 'Keychain');
|
|
457
|
+
}
|
|
458
|
+
return mergeEnvelopes(file, keychain);
|
|
459
|
+
}
|
|
460
|
+
async persist(envelope) {
|
|
461
|
+
const source = JSON.stringify(envelope);
|
|
462
|
+
if (Buffer.byteLength(source) > MAX_BYTES)
|
|
463
|
+
throw new Error('Provider credential store exceeds 1 MiB');
|
|
464
|
+
if (this.useKeychain) {
|
|
465
|
+
try {
|
|
466
|
+
await this.keychain.write(this.service, source);
|
|
467
|
+
await rm(this.file, { force: true });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
if (!(error instanceof ProviderKeychainUnavailableError))
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const committed = await writeFileAtomically(this.file, source, {
|
|
476
|
+
mode: 0o600,
|
|
477
|
+
});
|
|
478
|
+
if (!committed)
|
|
479
|
+
throw new Error('Provider credential store write was interrupted');
|
|
480
|
+
}
|
|
481
|
+
async acquire() {
|
|
482
|
+
const started = Date.now();
|
|
483
|
+
while (Date.now() - started < LOCK_WAIT_MS) {
|
|
484
|
+
const handle = await this.lease.tryAcquire();
|
|
485
|
+
if (handle)
|
|
486
|
+
return handle;
|
|
487
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 20));
|
|
488
|
+
}
|
|
489
|
+
throw new Error('Provider credential store lock timed out');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function nextRevision(previousRevision) {
|
|
493
|
+
const revision = Math.max(previousRevision + 1, Date.now());
|
|
494
|
+
if (!Number.isSafeInteger(revision) || revision <= 0)
|
|
495
|
+
throw new Error('Provider credential revision overflow');
|
|
496
|
+
return revision;
|
|
497
|
+
}
|
|
498
|
+
//# sourceMappingURL=provider-credential-vault.js.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export declare const CODEX_OAUTH: Readonly<{
|
|
2
|
+
readonly clientId: "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
3
|
+
readonly authorizeUrl: "https://auth.openai.com/oauth/authorize";
|
|
4
|
+
readonly tokenUrl: "https://auth.openai.com/oauth/token";
|
|
5
|
+
readonly redirectUri: "http://localhost:1455/auth/callback";
|
|
6
|
+
readonly scope: "openid profile email offline_access";
|
|
7
|
+
readonly deviceCodeUrl: "https://auth.openai.com/api/accounts/deviceauth/usercode";
|
|
8
|
+
readonly deviceTokenUrl: "https://auth.openai.com/api/accounts/deviceauth/token";
|
|
9
|
+
readonly deviceVerificationUrl: "https://auth.openai.com/codex/device";
|
|
10
|
+
readonly deviceRedirectUri: "https://auth.openai.com/deviceauth/callback";
|
|
11
|
+
}>;
|
|
12
|
+
export type CodexOAuthErrorCode = 'invalid_callback' | 'invalid_state' | 'invalid_token' | 'invalid_account_claim' | 'authorization_timeout' | 'authorization_cancelled' | 'device_failure' | 'device_timeout' | 'missing_credential' | 'refresh_failure';
|
|
13
|
+
export declare class CodexOAuthError extends Error {
|
|
14
|
+
readonly code: CodexOAuthErrorCode;
|
|
15
|
+
constructor(code: CodexOAuthErrorCode, message: string);
|
|
16
|
+
}
|
|
17
|
+
export interface CodexOAuthCredentialInput {
|
|
18
|
+
type: 'oauth';
|
|
19
|
+
accessToken: string;
|
|
20
|
+
refreshToken: string;
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
accountId?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface CodexOAuthCredentialRecord extends CodexOAuthCredentialInput {
|
|
25
|
+
revision?: number;
|
|
26
|
+
updatedAt?: string;
|
|
27
|
+
}
|
|
28
|
+
type CodexOAuthApiKeyRecord = {
|
|
29
|
+
type: 'api-key';
|
|
30
|
+
secret: string;
|
|
31
|
+
revision?: number;
|
|
32
|
+
updatedAt?: string;
|
|
33
|
+
};
|
|
34
|
+
type CodexOAuthVaultRecord = CodexOAuthCredentialRecord | CodexOAuthApiKeyRecord;
|
|
35
|
+
type CodexOAuthVaultInput = CodexOAuthCredentialInput | {
|
|
36
|
+
type: 'api-key';
|
|
37
|
+
secret: string;
|
|
38
|
+
};
|
|
39
|
+
export interface CodexOAuthVault {
|
|
40
|
+
read(key: {
|
|
41
|
+
providerId: string;
|
|
42
|
+
profileId: string;
|
|
43
|
+
}): Promise<CodexOAuthVaultRecord | undefined>;
|
|
44
|
+
modify(key: {
|
|
45
|
+
providerId: string;
|
|
46
|
+
profileId: string;
|
|
47
|
+
}, callback: (current: CodexOAuthVaultRecord | undefined) => CodexOAuthVaultInput | undefined | Promise<CodexOAuthVaultInput | undefined>): Promise<CodexOAuthVaultRecord | undefined>;
|
|
48
|
+
}
|
|
49
|
+
type FetchImplementation = typeof fetch;
|
|
50
|
+
export interface CodexOAuthLoginOptions {
|
|
51
|
+
profileId: string;
|
|
52
|
+
vault: CodexOAuthVault;
|
|
53
|
+
fetchImplementation?: FetchImplementation;
|
|
54
|
+
now?: () => number;
|
|
55
|
+
randomBytes?: (size: number) => Uint8Array;
|
|
56
|
+
openBrowser?: (url: URL) => Promise<void> | void;
|
|
57
|
+
noBrowser?: boolean;
|
|
58
|
+
readRedirectUrl?: () => Promise<string>;
|
|
59
|
+
/** Test seam only. Production callers omit this and use the registered port. */
|
|
60
|
+
callbackPort?: number;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
authorizationTimeoutMs?: number;
|
|
63
|
+
deviceTimeoutMs?: number;
|
|
64
|
+
requestTimeoutMs?: number;
|
|
65
|
+
sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
66
|
+
write?: (text: string) => void;
|
|
67
|
+
}
|
|
68
|
+
export interface CodexAuthorizationBrowserOptions {
|
|
69
|
+
platform?: NodeJS.Platform;
|
|
70
|
+
randomBytes?: (size: number) => Uint8Array;
|
|
71
|
+
bridgeTimeoutMs?: number;
|
|
72
|
+
runCommand?: (command: string, args: readonly string[]) => Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
export interface CodexOAuthAccess {
|
|
75
|
+
accessToken: string;
|
|
76
|
+
accountId: string;
|
|
77
|
+
expiresAt: number;
|
|
78
|
+
}
|
|
79
|
+
export interface CodexOAuthCredentialManagerOptions {
|
|
80
|
+
fetchImplementation?: FetchImplementation;
|
|
81
|
+
now?: () => number;
|
|
82
|
+
requestTimeoutMs?: number;
|
|
83
|
+
}
|
|
84
|
+
export interface CodexOAuthAccessOptions {
|
|
85
|
+
/** Force refresh only if this is still the current failed access token. */
|
|
86
|
+
forceAfter?: string;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
}
|
|
89
|
+
export declare function validateAuthorizationCallback(value: string, expectedState: string, redirectUri?: string): URL;
|
|
90
|
+
export declare function createAuthorizationCallbackValidator(expectedState: string, redirectUri?: string): (value: string) => URL;
|
|
91
|
+
export declare function openCodexAuthorizationUrl(authorizationUrl: URL, options?: CodexAuthorizationBrowserOptions): Promise<void>;
|
|
92
|
+
export declare function loginWithCodexOAuth(options: CodexOAuthLoginOptions): Promise<CodexOAuthCredentialRecord>;
|
|
93
|
+
export declare function deviceLoginWithCodexOAuth(options: CodexOAuthLoginOptions): Promise<CodexOAuthCredentialRecord>;
|
|
94
|
+
export declare class CodexOAuthCredentialManager {
|
|
95
|
+
private readonly vault;
|
|
96
|
+
private readonly profileId;
|
|
97
|
+
private readonly options;
|
|
98
|
+
private refreshFlight;
|
|
99
|
+
constructor(vault: CodexOAuthVault, profileId: string, options?: CodexOAuthCredentialManagerOptions);
|
|
100
|
+
access(options?: CodexOAuthAccessOptions): Promise<CodexOAuthAccess>;
|
|
101
|
+
private refresh;
|
|
102
|
+
}
|
|
103
|
+
export {};
|
|
104
|
+
//# sourceMappingURL=codex-oauth.d.ts.map
|