audrey 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +64 -60
  3. package/SECURITY.md +2 -3
  4. package/dist/mcp-server/config.d.ts +1 -11
  5. package/dist/mcp-server/config.d.ts.map +1 -1
  6. package/dist/mcp-server/config.js +42 -4
  7. package/dist/mcp-server/config.js.map +1 -1
  8. package/dist/mcp-server/index.d.ts +6 -2
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +147 -16
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/mcp-server/tool-schemas.d.ts +46 -0
  13. package/dist/mcp-server/tool-schemas.d.ts.map +1 -1
  14. package/dist/mcp-server/tool-schemas.js +20 -1
  15. package/dist/mcp-server/tool-schemas.js.map +1 -1
  16. package/dist/src/action-key.d.ts.map +1 -1
  17. package/dist/src/action-key.js +7 -7
  18. package/dist/src/action-key.js.map +1 -1
  19. package/dist/src/audrey.d.ts +7 -4
  20. package/dist/src/audrey.d.ts.map +1 -1
  21. package/dist/src/audrey.js +210 -57
  22. package/dist/src/audrey.js.map +1 -1
  23. package/dist/src/autopilot.d.ts +1 -1
  24. package/dist/src/autopilot.d.ts.map +1 -1
  25. package/dist/src/autopilot.js +47 -224
  26. package/dist/src/autopilot.js.map +1 -1
  27. package/dist/src/capsule.d.ts +4 -17
  28. package/dist/src/capsule.d.ts.map +1 -1
  29. package/dist/src/capsule.js +21 -20
  30. package/dist/src/capsule.js.map +1 -1
  31. package/dist/src/chatgpt-auth.d.ts +82 -0
  32. package/dist/src/chatgpt-auth.d.ts.map +1 -0
  33. package/dist/src/chatgpt-auth.js +965 -0
  34. package/dist/src/chatgpt-auth.js.map +1 -0
  35. package/dist/src/chatgpt-llm.d.ts +18 -0
  36. package/dist/src/chatgpt-llm.d.ts.map +1 -0
  37. package/dist/src/chatgpt-llm.js +157 -0
  38. package/dist/src/chatgpt-llm.js.map +1 -0
  39. package/dist/src/consolidate.d.ts.map +1 -1
  40. package/dist/src/consolidate.js +30 -2
  41. package/dist/src/consolidate.js.map +1 -1
  42. package/dist/src/controller.d.ts +7 -1
  43. package/dist/src/controller.d.ts.map +1 -1
  44. package/dist/src/controller.js +88 -14
  45. package/dist/src/controller.js.map +1 -1
  46. package/dist/src/db.d.ts +1 -0
  47. package/dist/src/db.d.ts.map +1 -1
  48. package/dist/src/db.js +126 -45
  49. package/dist/src/db.js.map +1 -1
  50. package/dist/src/embedding.d.ts.map +1 -1
  51. package/dist/src/embedding.js +14 -1
  52. package/dist/src/embedding.js.map +1 -1
  53. package/dist/src/encode.d.ts +14 -0
  54. package/dist/src/encode.d.ts.map +1 -1
  55. package/dist/src/encode.js +56 -17
  56. package/dist/src/encode.js.map +1 -1
  57. package/dist/src/events.d.ts +17 -0
  58. package/dist/src/events.d.ts.map +1 -1
  59. package/dist/src/events.js +130 -3
  60. package/dist/src/events.js.map +1 -1
  61. package/dist/src/export.d.ts +1 -0
  62. package/dist/src/export.d.ts.map +1 -1
  63. package/dist/src/export.js +9 -2
  64. package/dist/src/export.js.map +1 -1
  65. package/dist/src/forget.d.ts.map +1 -1
  66. package/dist/src/forget.js +6 -2
  67. package/dist/src/forget.js.map +1 -1
  68. package/dist/src/fts.d.ts +2 -2
  69. package/dist/src/fts.d.ts.map +1 -1
  70. package/dist/src/fts.js +4 -4
  71. package/dist/src/fts.js.map +1 -1
  72. package/dist/src/grounding.d.ts +1 -1
  73. package/dist/src/grounding.d.ts.map +1 -1
  74. package/dist/src/grounding.js +24 -11
  75. package/dist/src/grounding.js.map +1 -1
  76. package/dist/src/hybrid-recall.d.ts +5 -8
  77. package/dist/src/hybrid-recall.d.ts.map +1 -1
  78. package/dist/src/hybrid-recall.js +25 -57
  79. package/dist/src/hybrid-recall.js.map +1 -1
  80. package/dist/src/import.d.ts +40 -3
  81. package/dist/src/import.d.ts.map +1 -1
  82. package/dist/src/import.js +101 -42
  83. package/dist/src/import.js.map +1 -1
  84. package/dist/src/index.d.ts +3 -0
  85. package/dist/src/index.d.ts.map +1 -1
  86. package/dist/src/index.js +2 -0
  87. package/dist/src/index.js.map +1 -1
  88. package/dist/src/llm-shared.d.ts +10 -0
  89. package/dist/src/llm-shared.d.ts.map +1 -0
  90. package/dist/src/llm-shared.js +20 -0
  91. package/dist/src/llm-shared.js.map +1 -0
  92. package/dist/src/llm.d.ts +2 -1
  93. package/dist/src/llm.d.ts.map +1 -1
  94. package/dist/src/llm.js +13 -22
  95. package/dist/src/llm.js.map +1 -1
  96. package/dist/src/migrate.d.ts.map +1 -1
  97. package/dist/src/migrate.js +34 -40
  98. package/dist/src/migrate.js.map +1 -1
  99. package/dist/src/preflight.d.ts +2 -0
  100. package/dist/src/preflight.d.ts.map +1 -1
  101. package/dist/src/preflight.js +5 -1
  102. package/dist/src/preflight.js.map +1 -1
  103. package/dist/src/project-memory.d.ts +14 -0
  104. package/dist/src/project-memory.d.ts.map +1 -0
  105. package/dist/src/project-memory.js +135 -0
  106. package/dist/src/project-memory.js.map +1 -0
  107. package/dist/src/project.d.ts +2 -0
  108. package/dist/src/project.d.ts.map +1 -1
  109. package/dist/src/project.js +12 -0
  110. package/dist/src/project.js.map +1 -1
  111. package/dist/src/promote.d.ts.map +1 -1
  112. package/dist/src/promote.js +2 -26
  113. package/dist/src/promote.js.map +1 -1
  114. package/dist/src/recall-scoring.d.ts +14 -0
  115. package/dist/src/recall-scoring.d.ts.map +1 -0
  116. package/dist/src/recall-scoring.js +167 -0
  117. package/dist/src/recall-scoring.js.map +1 -0
  118. package/dist/src/recall.d.ts.map +1 -1
  119. package/dist/src/recall.js +31 -168
  120. package/dist/src/recall.js.map +1 -1
  121. package/dist/src/rollback.d.ts +12 -0
  122. package/dist/src/rollback.d.ts.map +1 -1
  123. package/dist/src/rollback.js +118 -25
  124. package/dist/src/rollback.js.map +1 -1
  125. package/dist/src/routes.d.ts.map +1 -1
  126. package/dist/src/routes.js +102 -33
  127. package/dist/src/routes.js.map +1 -1
  128. package/dist/src/rules-compiler.d.ts +4 -0
  129. package/dist/src/rules-compiler.d.ts.map +1 -1
  130. package/dist/src/rules-compiler.js +34 -2
  131. package/dist/src/rules-compiler.js.map +1 -1
  132. package/dist/src/server.d.ts +2 -0
  133. package/dist/src/server.d.ts.map +1 -1
  134. package/dist/src/server.js +96 -38
  135. package/dist/src/server.js.map +1 -1
  136. package/dist/src/shell-command.d.ts.map +1 -1
  137. package/dist/src/shell-command.js +24 -7
  138. package/dist/src/shell-command.js.map +1 -1
  139. package/dist/src/success-checks.d.ts +41 -0
  140. package/dist/src/success-checks.d.ts.map +1 -0
  141. package/dist/src/success-checks.js +164 -0
  142. package/dist/src/success-checks.js.map +1 -0
  143. package/dist/src/types.d.ts +7 -1
  144. package/dist/src/types.d.ts.map +1 -1
  145. package/dist/src/validate.d.ts.map +1 -1
  146. package/dist/src/validate.js +23 -9
  147. package/dist/src/validate.js.map +1 -1
  148. package/package.json +7 -10
@@ -0,0 +1,965 @@
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { chmod, lstat, mkdir, open, readFile, realpath, rename, unlink } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, join, resolve } from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ import { setTimeout as pause } from 'node:timers/promises';
9
+ import { createLocalJWKSet, jwtVerify } from 'jose';
10
+ const ISSUER = 'https://auth.openai.com';
11
+ const RESOURCE = 'https://api.openai.com/v1';
12
+ const SCOPES = 'openid profile email offline_access resource.invoke chatgpt.tokens.use.direct';
13
+ const runFile = promisify(execFile);
14
+ const PLAN_SCOPES = ['chatgpt.tokens.use.direct', 'resource.invoke'];
15
+ const MAX_FILE_BYTES = 1024 * 1024;
16
+ // OpenAI documents these refresh failures as terminal: clear the stored
17
+ // tokens and sign in again rather than retrying a dead grant.
18
+ const TERMINAL_GRANT_ERRORS = new Set([
19
+ 'invalid_grant',
20
+ 'invalid_refresh_token',
21
+ 'token_expired',
22
+ 'refresh_token_expired',
23
+ 'refresh_token_invalidated',
24
+ 'refresh_token_reused',
25
+ ]);
26
+ // Windows PowerShell by its installed path, never by PATH lookup from a
27
+ // hook whose working directory is the user's repository.
28
+ const POWERSHELL = join(process.env['SystemRoot'] ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
29
+ class GrantError extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.code = code;
34
+ this.name = 'GrantError';
35
+ }
36
+ }
37
+ // Longer than any legitimate lock hold (a refresh or three revocation attempts).
38
+ const STALE_LOCK_MS = 120_000;
39
+ function record(value) {
40
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
41
+ }
42
+ function text(value, max = 65536) {
43
+ return (typeof value === 'string' &&
44
+ value.length > 0 &&
45
+ value.length <= max &&
46
+ ![...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127));
47
+ }
48
+ function clientId(value) {
49
+ return text(value, 512) && value !== 'dynamic_agent_client' && /^[A-Za-z0-9_.:-]+$/.test(value);
50
+ }
51
+ function same(a, b) {
52
+ const left = Buffer.from(a);
53
+ const right = Buffer.from(b);
54
+ return left.length === right.length && timingSafeEqual(left, right);
55
+ }
56
+ function isMissing(error) {
57
+ return record(error) && error.code === 'ENOENT';
58
+ }
59
+ function safeEndpoint(value) {
60
+ if (!text(value, 2048))
61
+ return false;
62
+ try {
63
+ const url = new URL(value);
64
+ return url.origin === ISSUER && !url.username && !url.password && !url.hash && !url.search;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ function validCredentials(value) {
71
+ if (!record(value) ||
72
+ value.version !== 1 ||
73
+ !text(value.hostId, 64) ||
74
+ !/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.hostId) ||
75
+ !Array.isArray(value.accounts) ||
76
+ value.accounts.length > 100)
77
+ return false;
78
+ const ids = new Set();
79
+ const clients = new Set();
80
+ for (const item of value.accounts) {
81
+ if (!record(item) ||
82
+ !text(item.accountId, 64) ||
83
+ !clientId(item.clientId) ||
84
+ !text(item.subject, 512) ||
85
+ !text(item.label, 512) ||
86
+ !text(item.revision, 64) ||
87
+ (item.email !== undefined && !text(item.email, 320)) ||
88
+ !Array.isArray(item.scopes) ||
89
+ !item.scopes.every(scope => text(scope, 128)) ||
90
+ ids.has(item.accountId) ||
91
+ clients.has(item.clientId))
92
+ return false;
93
+ ids.add(item.accountId);
94
+ clients.add(item.clientId);
95
+ if (item.tokens !== undefined) {
96
+ const tokens = item.tokens;
97
+ if (!record(tokens) ||
98
+ !text(tokens.accessToken) ||
99
+ !text(tokens.idToken) ||
100
+ (tokens.refreshToken !== undefined && !text(tokens.refreshToken)) ||
101
+ typeof tokens.expiresAt !== 'number' ||
102
+ !Number.isFinite(tokens.expiresAt) ||
103
+ (tokens.earliestRefreshAt !== undefined &&
104
+ (typeof tokens.earliestRefreshAt !== 'number' ||
105
+ !Number.isFinite(tokens.earliestRefreshAt))))
106
+ return false;
107
+ }
108
+ }
109
+ return (value.activeAccountId === undefined ||
110
+ (typeof value.activeAccountId === 'string' && ids.has(value.activeAccountId)));
111
+ }
112
+ function checkAbort(signal) {
113
+ if (signal?.aborted)
114
+ throw new DOMException('ChatGPT authentication was cancelled.', 'AbortError');
115
+ }
116
+ /**
117
+ * The credential directory and everything inside it must be real files and
118
+ * directories. Ancestors above it may be symbolic links (/tmp on macOS, a
119
+ * relocated home directory); `root` is that directory with those resolved.
120
+ */
121
+ async function rejectLinks(path, root, signal) {
122
+ let current = resolve(path);
123
+ for (;;) {
124
+ checkAbort(signal);
125
+ try {
126
+ if ((await lstat(current)).isSymbolicLink())
127
+ throw new Error('ChatGPT credential paths cannot contain symbolic links.');
128
+ }
129
+ catch (error) {
130
+ if (!isMissing(error))
131
+ throw error;
132
+ }
133
+ const parent = dirname(current);
134
+ if (current === root || current === parent)
135
+ break;
136
+ current = parent;
137
+ }
138
+ }
139
+ /** The first error line from PowerShell, unwrapped from CLIXML when it uses it. */
140
+ function powershellErrorLine(stderr) {
141
+ const clixml = stderr.match(/<S S="Error">([\s\S]*?)<\/S>/);
142
+ const line = (clixml ? clixml[1] : stderr)
143
+ .replace(/_x000D_|_x000A_/g, '\n')
144
+ .replace(/&lt;/g, '<')
145
+ .replace(/&gt;/g, '>')
146
+ .replace(/&quot;/g, '"')
147
+ .replace(/&amp;/g, '&')
148
+ .trim()
149
+ .split('\n')[0];
150
+ return line ? line.slice(0, 300) : undefined;
151
+ }
152
+ async function protect(path, directory, signal) {
153
+ checkAbort(signal);
154
+ if (process.platform !== 'win32') {
155
+ await chmod(path, directory ? 0o700 : 0o600);
156
+ checkAbort(signal);
157
+ return;
158
+ }
159
+ const encodedPath = Buffer.from(path, 'utf8').toString('base64');
160
+ // Pure .NET, no cmdlets: Set-Acl writes the audit section too, which needs
161
+ // SeSecurityPrivilege outside an elevated shell, and a powershell.exe
162
+ // started from PowerShell 7 inherits a PSModulePath on which Get-Acl's
163
+ // module does not even load. The owner and access sections are all that
164
+ // change, and GetAccessControl/SetAccessControl write only those.
165
+ // Pass paths as encoded data, never interpolate a shell command or account name.
166
+ const api = directory ? 'IO.Directory' : 'IO.File';
167
+ const script = `$ErrorActionPreference='Stop'; $target=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}')); $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; $acl=[${api}]::GetAccessControl($target); $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); foreach($existing in @($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]))) { [void]$acl.RemoveAccessRule($existing) }; $rule=[Security.AccessControl.FileSystemAccessRule]::new($sid,'FullControl',${directory ? "'ContainerInherit,ObjectInherit','None'," : ''}'Allow'); $acl.AddAccessRule($rule); [${api}]::SetAccessControl($target,$acl); $checked=[${api}]::GetAccessControl($target); if (!$checked.AreAccessRulesProtected -or $checked.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'Credential protection failed' }; foreach($entry in $checked.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { if ($entry.IdentityReference.Value -ne $sid.Value -or $entry.AccessControlType -ne 'Allow') { throw 'Credential protection failed' } }`;
168
+ try {
169
+ await runFile(POWERSHELL, [
170
+ '-NoProfile',
171
+ '-NonInteractive',
172
+ '-EncodedCommand',
173
+ Buffer.from(script, 'utf16le').toString('base64'),
174
+ ], { windowsHide: true, timeout: 10000, signal });
175
+ }
176
+ catch (error) {
177
+ checkAbort(signal);
178
+ // Surface PowerShell's own first line so the operator can see which
179
+ // ACL step failed; it names the path and the cmdlet, never a token.
180
+ const detail = powershellErrorLine(record(error) && typeof error.stderr === 'string' ? error.stderr : '');
181
+ throw new Error(`Could not protect ChatGPT credentials with an owner-only Windows ACL.${detail ? ` ${detail}` : ''}`, { cause: error });
182
+ }
183
+ checkAbort(signal);
184
+ }
185
+ async function launchBrowser(url) {
186
+ try {
187
+ if (process.platform === 'win32') {
188
+ const data = Buffer.from(url, 'utf8').toString('base64');
189
+ const script = `$ErrorActionPreference='Stop'; Start-Process ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${data}')))`;
190
+ await runFile(POWERSHELL, [
191
+ '-NoProfile',
192
+ '-NonInteractive',
193
+ '-EncodedCommand',
194
+ Buffer.from(script, 'utf16le').toString('base64'),
195
+ ], { windowsHide: true, timeout: 10000 });
196
+ }
197
+ else {
198
+ await runFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { timeout: 10000 });
199
+ }
200
+ }
201
+ catch {
202
+ throw new Error('Could not open the browser. Use the displayed Continue with ChatGPT URL.');
203
+ }
204
+ }
205
+ /** Public-client OAuth credentials owned by this app; never reads another app's sign-in. */
206
+ export class ChatGPTAuth {
207
+ directory;
208
+ canonicalRoot;
209
+ appName;
210
+ request;
211
+ browser;
212
+ now;
213
+ requestTimeout;
214
+ loginTimeout;
215
+ refreshLeeway;
216
+ lockTimeout;
217
+ prepared = false;
218
+ discovery;
219
+ keys;
220
+ constructor(options = {}) {
221
+ this.directory = resolve((options.directory ?? join(homedir(), '.audrey', 'auth')).replace(/^~(?=$|[\\/])/, homedir()));
222
+ this.appName = options.appName ?? 'Audrey';
223
+ if (!text(this.appName, 128))
224
+ throw new Error('ChatGPT app name is invalid.');
225
+ this.request = options.fetch ?? globalThis.fetch;
226
+ this.browser = options.openBrowser ?? launchBrowser;
227
+ this.now = options.now ?? Date.now;
228
+ this.requestTimeout = bounded(options.requestTimeoutMs, 15000, 100, 60000);
229
+ this.loginTimeout = bounded(options.loginTimeoutMs, 300000, 100, 600000);
230
+ this.refreshLeeway = bounded(options.refreshLeewayMs, 60000, 0, 300000);
231
+ this.lockTimeout = bounded(options.lockTimeoutMs, 20000, 100, 60000);
232
+ }
233
+ /** The configured directory with symbolic links above it resolved. */
234
+ async root(signal) {
235
+ if (this.canonicalRoot)
236
+ return this.canonicalRoot;
237
+ checkAbort(signal);
238
+ let existing = dirname(this.directory);
239
+ const missing = [basename(this.directory)];
240
+ for (;;) {
241
+ try {
242
+ this.canonicalRoot = join(await realpath(existing), ...missing);
243
+ return this.canonicalRoot;
244
+ }
245
+ catch (error) {
246
+ if (!isMissing(error))
247
+ throw error;
248
+ }
249
+ const parent = dirname(existing);
250
+ if (parent === existing)
251
+ throw new Error('ChatGPT credential directory is unreachable.');
252
+ missing.unshift(basename(existing));
253
+ existing = parent;
254
+ }
255
+ }
256
+ async prepare(signal) {
257
+ checkAbort(signal);
258
+ if (this.prepared)
259
+ return;
260
+ // Only cache successful protection; cancellation in one call must not poison another.
261
+ const root = await this.root(signal);
262
+ await rejectLinks(root, root, signal);
263
+ await mkdir(root, { recursive: true, mode: 0o700 });
264
+ await protect(root, true, signal);
265
+ this.prepared = true;
266
+ }
267
+ async read(signal) {
268
+ const root = await this.root(signal);
269
+ const path = join(root, 'credentials.json');
270
+ await rejectLinks(path, root, signal);
271
+ try {
272
+ const stat = await lstat(path);
273
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
274
+ throw new Error('Invalid ChatGPT credential file.');
275
+ await this.prepare(signal);
276
+ await protect(path, false, signal);
277
+ const value = JSON.parse(await readFile(path, { encoding: 'utf8', signal }));
278
+ if (!validCredentials(value))
279
+ throw new Error('Invalid ChatGPT credential record.');
280
+ return value;
281
+ }
282
+ catch (error) {
283
+ if (isMissing(error))
284
+ return null;
285
+ // JSON parser errors can contain credential text; do not retain their cause.
286
+ // eslint-disable-next-line preserve-caught-error
287
+ if (error instanceof SyntaxError)
288
+ throw new Error('Invalid ChatGPT credential record.');
289
+ throw error;
290
+ }
291
+ }
292
+ async stage(signal) {
293
+ await this.prepare(signal);
294
+ const temporary = join(await this.root(signal), `credentials.${randomUUID()}.tmp`);
295
+ const handle = await open(temporary, 'wx', 0o600);
296
+ try {
297
+ await protect(temporary, false, signal);
298
+ return { handle, temporary };
299
+ }
300
+ catch (error) {
301
+ await handle.close().catch(() => { });
302
+ await unlink(temporary).catch(() => { });
303
+ throw error;
304
+ }
305
+ }
306
+ async dispose(stage) {
307
+ await stage.handle.close().catch(() => { });
308
+ await unlink(stage.temporary).catch(() => { });
309
+ }
310
+ async save(value, signal, staged) {
311
+ if (!validCredentials(value))
312
+ throw new Error('Invalid ChatGPT credential record.');
313
+ const root = await this.root(signal);
314
+ const path = join(root, 'credentials.json');
315
+ await rejectLinks(path, root, signal);
316
+ const stage = staged ?? (await this.stage(signal));
317
+ try {
318
+ checkAbort(signal);
319
+ await stage.handle.writeFile(JSON.stringify(value));
320
+ await stage.handle.sync();
321
+ await stage.handle.close();
322
+ checkAbort(signal);
323
+ await rename(stage.temporary, path);
324
+ if (process.platform !== 'win32') {
325
+ // The rename itself is only durable once the directory entry is.
326
+ const dir = await open(dirname(path), 'r');
327
+ try {
328
+ await dir.sync();
329
+ }
330
+ finally {
331
+ await dir.close();
332
+ }
333
+ }
334
+ }
335
+ finally {
336
+ await this.dispose(stage);
337
+ }
338
+ }
339
+ async locked(operation, signal) {
340
+ await this.prepare(signal);
341
+ const root = await this.root(signal);
342
+ const path = join(root, 'credentials.lock');
343
+ await rejectLinks(path, root, signal);
344
+ const deadline = Date.now() + this.lockTimeout;
345
+ let handle;
346
+ while (!handle) {
347
+ checkAbort(signal);
348
+ try {
349
+ handle = await open(path, 'wx', 0o600);
350
+ }
351
+ catch (error) {
352
+ if (!record(error) || error.code !== 'EEXIST')
353
+ throw error;
354
+ if (await this.breakStaleLock(path, signal))
355
+ continue;
356
+ if (Date.now() >= deadline)
357
+ throw new Error('ChatGPT credentials are locked by another operation. If its process stopped, remove credentials.lock from the auth directory and retry.', { cause: error });
358
+ await delay(50, signal);
359
+ }
360
+ }
361
+ const stamp = JSON.stringify({ pid: process.pid, startedAt: Date.now() });
362
+ try {
363
+ checkAbort(signal);
364
+ await handle.writeFile(stamp);
365
+ return await operation();
366
+ }
367
+ finally {
368
+ await handle.close();
369
+ await this.releaseLock(path, stamp);
370
+ }
371
+ }
372
+ /**
373
+ * If another process judged this lock stale and replaced it, the file is
374
+ * theirs now; removing it would let a third writer in beside them.
375
+ */
376
+ async releaseLock(path, stamp) {
377
+ try {
378
+ if ((await readFile(path, 'utf8')) === stamp)
379
+ await unlink(path);
380
+ }
381
+ catch (error) {
382
+ if (!isMissing(error))
383
+ throw error;
384
+ }
385
+ }
386
+ /**
387
+ * A hook process killed at its host deadline leaves the lock file behind.
388
+ * Remove it when the recorded process is gone or the lock is older than any
389
+ * legitimate hold, so later calls do not wait out the timeout and fail.
390
+ */
391
+ async breakStaleLock(path, signal) {
392
+ checkAbort(signal);
393
+ let stat;
394
+ try {
395
+ stat = await lstat(path);
396
+ }
397
+ catch (error) {
398
+ if (isMissing(error))
399
+ return true;
400
+ throw error;
401
+ }
402
+ if (!stat.isFile())
403
+ return false;
404
+ let pid;
405
+ let startedAt = stat.mtimeMs;
406
+ try {
407
+ const raw = JSON.parse(await readFile(path, 'utf8'));
408
+ if (record(raw)) {
409
+ if (typeof raw.pid === 'number' && Number.isInteger(raw.pid) && raw.pid > 0)
410
+ pid = raw.pid;
411
+ if (typeof raw.startedAt === 'number' && Number.isFinite(raw.startedAt))
412
+ startedAt = raw.startedAt;
413
+ }
414
+ }
415
+ catch {
416
+ // Unreadable or still being written: judge it by age alone.
417
+ }
418
+ let stale = Date.now() - startedAt > STALE_LOCK_MS;
419
+ if (!stale && pid !== undefined && pid !== process.pid) {
420
+ try {
421
+ process.kill(pid, 0);
422
+ }
423
+ catch (error) {
424
+ stale = record(error) && error.code === 'ESRCH';
425
+ }
426
+ }
427
+ if (!stale)
428
+ return false;
429
+ try {
430
+ await unlink(path);
431
+ }
432
+ catch (error) {
433
+ if (!isMissing(error))
434
+ throw error;
435
+ }
436
+ return true;
437
+ }
438
+ summary(account, store) {
439
+ return {
440
+ accountId: account.accountId,
441
+ clientId: account.clientId,
442
+ subject: account.subject,
443
+ ...(account.email ? { email: account.email } : {}),
444
+ label: account.label,
445
+ active: store.activeAccountId === account.accountId,
446
+ signedIn: !!account.tokens,
447
+ ...(account.tokens ? { expiresAt: account.tokens.expiresAt } : {}),
448
+ scopes: [...account.scopes],
449
+ planEnabled: PLAN_SCOPES.every(scope => account.scopes.includes(scope)),
450
+ };
451
+ }
452
+ async accounts() {
453
+ const store = await this.read();
454
+ return store ? store.accounts.map(account => this.summary(account, store)) : [];
455
+ }
456
+ async status() {
457
+ return (await this.accounts()).find(account => account.active) ?? null;
458
+ }
459
+ async select(accountId) {
460
+ return this.locked(async () => {
461
+ const store = await this.read();
462
+ const account = store?.accounts.find(item => item.accountId === accountId);
463
+ if (!store || !account)
464
+ throw new Error('Saved ChatGPT account was not found.');
465
+ if (!account.tokens)
466
+ throw new Error('That ChatGPT account is signed out. Run audrey auth login for that account.');
467
+ store.activeAccountId = accountId;
468
+ await this.save(store);
469
+ return this.summary(account, store);
470
+ });
471
+ }
472
+ async response(url, init = {}, signal) {
473
+ checkAbort(signal);
474
+ try {
475
+ return await this.request(url, {
476
+ ...init,
477
+ redirect: 'error',
478
+ signal: signal
479
+ ? AbortSignal.any([signal, AbortSignal.timeout(this.requestTimeout)])
480
+ : AbortSignal.timeout(this.requestTimeout),
481
+ });
482
+ }
483
+ catch {
484
+ checkAbort(signal);
485
+ throw new Error('ChatGPT authentication request failed or timed out.');
486
+ }
487
+ }
488
+ async json(response, signal) {
489
+ checkAbort(signal);
490
+ try {
491
+ const body = await response.text();
492
+ checkAbort(signal);
493
+ if (body.length > MAX_FILE_BYTES)
494
+ throw new Error();
495
+ const value = JSON.parse(body);
496
+ if (!record(value))
497
+ throw new Error();
498
+ return value;
499
+ }
500
+ catch {
501
+ checkAbort(signal);
502
+ throw new Error('ChatGPT authentication returned an invalid response.');
503
+ }
504
+ }
505
+ async configuration(signal) {
506
+ checkAbort(signal);
507
+ if (this.discovery)
508
+ return this.discovery;
509
+ const response = await this.response(`${ISSUER}/.well-known/openid-configuration`, {}, signal);
510
+ if (!response.ok)
511
+ throw new Error(`ChatGPT discovery failed (HTTP ${response.status}).`);
512
+ const body = await this.json(response, signal);
513
+ if (body.issuer !== ISSUER ||
514
+ !safeEndpoint(body.authorization_endpoint) ||
515
+ !safeEndpoint(body.token_endpoint) ||
516
+ !safeEndpoint(body.jwks_uri) ||
517
+ (body.revocation_endpoint !== undefined && !safeEndpoint(body.revocation_endpoint))) {
518
+ throw new Error('ChatGPT discovery endpoints could not be verified.');
519
+ }
520
+ this.discovery = body;
521
+ return this.discovery;
522
+ }
523
+ async signingKeys(signal) {
524
+ const configuration = await this.configuration(signal);
525
+ const response = await this.response(configuration.jwks_uri, {}, signal);
526
+ if (!response.ok)
527
+ throw new Error('ChatGPT signing keys could not be loaded.');
528
+ const body = await this.json(response, signal);
529
+ if (!Array.isArray(body.keys) || body.keys.length > 100)
530
+ throw new Error('ChatGPT signing keys are invalid.');
531
+ this.keys = createLocalJWKSet(body);
532
+ }
533
+ async identity(token, audience, nonce, signal) {
534
+ for (let attempt = 0; attempt < 2; attempt++) {
535
+ checkAbort(signal);
536
+ if (!this.keys)
537
+ await this.signingKeys(signal);
538
+ try {
539
+ const { payload } = await jwtVerify(token, this.keys, {
540
+ issuer: ISSUER,
541
+ audience,
542
+ requiredClaims: ['sub', 'exp', 'iat'],
543
+ algorithms: ['RS256', 'PS256', 'ES256', 'EdDSA'],
544
+ clockTolerance: 5,
545
+ currentDate: new Date(this.now()),
546
+ });
547
+ checkAbort(signal);
548
+ if (!text(payload.sub, 512) ||
549
+ (nonce !== undefined &&
550
+ (typeof payload.nonce !== 'string' || !same(payload.nonce, nonce))) ||
551
+ (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== audience)) {
552
+ throw new Error('Invalid identity');
553
+ }
554
+ return payload;
555
+ }
556
+ catch (error) {
557
+ checkAbort(signal);
558
+ // Key rotation is the only verification failure that merits a network retry.
559
+ if (attempt === 0 && record(error) && error.code === 'ERR_JWKS_NO_MATCHING_KEY') {
560
+ this.keys = undefined;
561
+ continue;
562
+ }
563
+ // JOSE errors can retain JWT payload data; callers only receive this safe message.
564
+ // eslint-disable-next-line preserve-caught-error
565
+ throw new Error('ChatGPT ID token could not be verified (signature, issuer, audience, expiry, or nonce).');
566
+ }
567
+ }
568
+ throw new Error('ChatGPT ID token could not be verified.');
569
+ }
570
+ async exchange(form, signal, preserveRotation = false) {
571
+ const configuration = await this.configuration(signal);
572
+ // Fetch ties response-body reads to its request signal. Forward cancellation
573
+ // only until successful refresh headers arrive, then retain the request timeout
574
+ // while receiving the replacement grant and committing it independently.
575
+ const cancellation = new AbortController();
576
+ const cancel = () => cancellation.abort();
577
+ checkAbort(signal);
578
+ if (preserveRotation)
579
+ signal?.addEventListener('abort', cancel, { once: true });
580
+ try {
581
+ const response = await this.response(configuration.token_endpoint, {
582
+ method: 'POST',
583
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
584
+ body: form.toString(),
585
+ }, preserveRotation ? cancellation.signal : signal);
586
+ if (preserveRotation && response.ok)
587
+ signal?.removeEventListener('abort', cancel);
588
+ const body = await this.json(response, preserveRotation && response.ok ? undefined : signal);
589
+ if (!response.ok) {
590
+ const code = typeof body.error === 'string' && /^[a-z_]{1,64}$/.test(body.error)
591
+ ? body.error
592
+ : undefined;
593
+ if (code && TERMINAL_GRANT_ERRORS.has(code))
594
+ throw new GrantError(code, `ChatGPT session expired or was revoked (${code}). Run audrey auth login to sign in again.`);
595
+ if (code === 'refresh_token_not_ready')
596
+ throw new GrantError(code, 'ChatGPT refresh is not ready yet. Retry after the current access token expires.');
597
+ throw new Error(`ChatGPT token exchange failed (HTTP ${response.status}).`);
598
+ }
599
+ return body;
600
+ }
601
+ finally {
602
+ if (preserveRotation)
603
+ signal?.removeEventListener('abort', cancel);
604
+ }
605
+ }
606
+ tokens(body, previous) {
607
+ if (!text(body.access_token) ||
608
+ body.token_type !== 'Bearer' ||
609
+ typeof body.expires_in !== 'number' ||
610
+ !Number.isFinite(body.expires_in) ||
611
+ body.expires_in <= 0 ||
612
+ body.expires_in > 86400 ||
613
+ (body.refresh_token !== undefined && !text(body.refresh_token)) ||
614
+ (body.id_token !== undefined && !text(body.id_token)) ||
615
+ (body.scope !== undefined && typeof body.scope !== 'string')) {
616
+ throw new Error('ChatGPT token response is incomplete.');
617
+ }
618
+ const scopes = typeof body.scope === 'string'
619
+ ? [...new Set(body.scope.split(/\s+/).filter(Boolean))]
620
+ : previous
621
+ ? [...previous.scopes]
622
+ : [];
623
+ const idToken = typeof body.id_token === 'string' ? body.id_token : previous?.tokens?.idToken;
624
+ if (!idToken)
625
+ throw new Error('ChatGPT token response did not include an ID token.');
626
+ if (scopes.includes('offline_access') && !text(body.refresh_token)) {
627
+ throw new Error('ChatGPT token response did not include a replacement refresh token.');
628
+ }
629
+ let earliestRefreshAt;
630
+ if (body.earliest_refresh_at !== undefined) {
631
+ if (typeof body.earliest_refresh_at !== 'number' ||
632
+ !Number.isFinite(body.earliest_refresh_at) ||
633
+ body.earliest_refresh_at <= 0) {
634
+ throw new Error('ChatGPT token refresh time is invalid.');
635
+ }
636
+ earliestRefreshAt = body.earliest_refresh_at * 1000;
637
+ }
638
+ const expiresAt = this.now() + body.expires_in * 1000;
639
+ // The field's unit is undocumented. A value past the token's own expiry
640
+ // would lock the account out until a manual login, so treat it as absent.
641
+ if (earliestRefreshAt !== undefined && earliestRefreshAt > expiresAt + 60_000)
642
+ earliestRefreshAt = undefined;
643
+ return {
644
+ scopes,
645
+ tokens: {
646
+ accessToken: body.access_token,
647
+ idToken,
648
+ ...(text(body.refresh_token) ? { refreshToken: body.refresh_token } : {}),
649
+ expiresAt,
650
+ ...(earliestRefreshAt !== undefined ? { earliestRefreshAt } : {}),
651
+ },
652
+ };
653
+ }
654
+ async login(options = {}) {
655
+ const pending = await this.locked(async () => {
656
+ let store = await this.read();
657
+ if (!store) {
658
+ store = { version: 1, hostId: `urn:uuid:${randomUUID()}`, accounts: [] };
659
+ await this.save(store);
660
+ }
661
+ const account = options.accountId
662
+ ? store.accounts.find(item => item.accountId === options.accountId)
663
+ : undefined;
664
+ if (options.accountId && !account)
665
+ throw new Error('Saved ChatGPT account was not found.');
666
+ return { hostId: store.hostId, account };
667
+ });
668
+ const configuration = await this.configuration();
669
+ const state = randomBytes(32).toString('base64url');
670
+ const nonce = randomBytes(32).toString('base64url');
671
+ const verifier = randomBytes(64).toString('base64url');
672
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
673
+ let settled = false;
674
+ let resolveCallback;
675
+ let rejectCallback;
676
+ const callback = new Promise((resolvePromise, rejectPromise) => {
677
+ resolveCallback = resolvePromise;
678
+ rejectCallback = rejectPromise;
679
+ });
680
+ // Install the rejection handler before any browser work, which may itself fail.
681
+ void callback.catch(() => { });
682
+ const server = createServer((request, response) => {
683
+ response.setHeader('Cache-Control', 'no-store');
684
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8');
685
+ response.setHeader('X-Content-Type-Options', 'nosniff');
686
+ if (settled || request.method !== 'GET' || !request.url || request.url.length > 16384) {
687
+ response.writeHead(400).end('Invalid sign-in callback.');
688
+ return;
689
+ }
690
+ let url;
691
+ try {
692
+ url = new URL(request.url ?? '', 'http://127.0.0.1');
693
+ }
694
+ catch {
695
+ response.writeHead(400).end('Bad request.');
696
+ return;
697
+ }
698
+ if (url.pathname !== '/auth/callback') {
699
+ response.writeHead(404).end('Not found.');
700
+ return;
701
+ }
702
+ settled = true;
703
+ const parameters = url.searchParams;
704
+ const unique = ['state', 'code', 'client_id', 'error'].every(key => parameters.getAll(key).length <= 1);
705
+ if (!unique || !same(parameters.get('state') ?? '', state)) {
706
+ response.writeHead(400).end('Sign-in could not be verified.');
707
+ rejectCallback(new Error('ChatGPT sign-in state did not match. Start sign-in again.'));
708
+ }
709
+ else if (parameters.has('error')) {
710
+ response.writeHead(400).end('Sign-in was declined or could not be completed.');
711
+ rejectCallback(new Error('ChatGPT sign-in was declined or could not be completed.'));
712
+ }
713
+ else {
714
+ response.end('Continue with ChatGPT completed. You can return to the app.');
715
+ resolveCallback(parameters);
716
+ }
717
+ });
718
+ server.headersTimeout = Math.min(this.loginTimeout, 10000);
719
+ server.requestTimeout = Math.min(this.loginTimeout, 10000);
720
+ let timer;
721
+ try {
722
+ await new Promise((resolveListen, rejectListen) => {
723
+ server.once('error', () => rejectListen(new Error('Could not start the ChatGPT loopback callback listener.')));
724
+ server.listen(0, '127.0.0.1', resolveListen);
725
+ });
726
+ const address = server.address();
727
+ if (!address || typeof address === 'string')
728
+ throw new Error('Could not start the ChatGPT loopback callback listener.');
729
+ const redirectUri = `http://127.0.0.1:${address.port}/auth/callback`;
730
+ const url = new URL(configuration.authorization_endpoint);
731
+ url.search = new URLSearchParams({
732
+ client_id: pending.account?.clientId ?? 'dynamic_agent_client',
733
+ ext_agent_host_id: pending.hostId,
734
+ ...(pending.account ? {} : { agent_name_hint: this.appName }),
735
+ response_type: 'code',
736
+ redirect_uri: redirectUri,
737
+ scope: SCOPES,
738
+ resource: RESOURCE,
739
+ state,
740
+ nonce,
741
+ code_challenge_method: 'S256',
742
+ code_challenge: challenge,
743
+ ...(options.requestConsent ? { prompt: 'consent' } : {}),
744
+ }).toString();
745
+ // Hints are optional; omitting them keeps these printable URLs credential-free.
746
+ timer = setTimeout(() => {
747
+ settled = true;
748
+ rejectCallback(new Error('ChatGPT sign-in timed out. Start sign-in again.'));
749
+ }, this.loginTimeout);
750
+ await options.onAuthorizationUrl?.(url.toString());
751
+ if (options.openBrowser !== false)
752
+ await this.browser(url.toString());
753
+ const parameters = await callback;
754
+ const code = parameters.get('code');
755
+ const issuedClient = parameters.get('client_id') ?? pending.account?.clientId;
756
+ if (!text(code, 8192) ||
757
+ !clientId(issuedClient) ||
758
+ (pending.account && issuedClient !== pending.account.clientId)) {
759
+ throw new Error('ChatGPT callback registration or client ID could not be verified.');
760
+ }
761
+ const body = await this.exchange(new URLSearchParams({
762
+ grant_type: 'authorization_code',
763
+ client_id: issuedClient,
764
+ code,
765
+ code_verifier: verifier,
766
+ redirect_uri: redirectUri,
767
+ resource: RESOURCE,
768
+ }));
769
+ const fresh = this.tokens(body);
770
+ const identity = await this.identity(fresh.tokens.idToken, issuedClient, nonce);
771
+ if (pending.account && identity.sub !== pending.account.subject) {
772
+ throw new Error('ChatGPT account identity did not match the selected registration.');
773
+ }
774
+ return await this.locked(async () => {
775
+ const store = await this.read();
776
+ if (!store || store.hostId !== pending.hostId)
777
+ throw new Error('ChatGPT registration changed during sign-in.');
778
+ const existing = store.accounts.find(item => item.clientId === issuedClient);
779
+ if (existing &&
780
+ (existing.subject !== identity.sub ||
781
+ (pending.account && existing.revision !== pending.account.revision))) {
782
+ throw new Error('ChatGPT registration changed during sign-in. Start sign-in again.');
783
+ }
784
+ const account = {
785
+ accountId: existing?.accountId ?? randomUUID(),
786
+ clientId: issuedClient,
787
+ subject: identity.sub,
788
+ label: existing?.label ??
789
+ `${text(identity.email, 320) ? identity.email : 'ChatGPT account'} (${issuedClient.slice(-8)})`,
790
+ ...(text(identity.email, 320) ? { email: identity.email } : {}),
791
+ revision: randomUUID(),
792
+ ...fresh,
793
+ };
794
+ if (existing)
795
+ store.accounts[store.accounts.indexOf(existing)] = account;
796
+ else
797
+ store.accounts.push(account);
798
+ store.activeAccountId = account.accountId;
799
+ await this.save(store);
800
+ return this.summary(account, store);
801
+ });
802
+ }
803
+ finally {
804
+ settled = true;
805
+ if (timer)
806
+ clearTimeout(timer);
807
+ server.closeAllConnections();
808
+ await new Promise(resolveClose => server.close(() => resolveClose()));
809
+ }
810
+ }
811
+ async accessToken(accountId, signal) {
812
+ // Most calls need no refresh. Answer them from the (atomically written)
813
+ // credential file without the writer lock, so concurrent hooks never
814
+ // queue behind one another just to read a token that is still valid.
815
+ const current = await this.read(signal);
816
+ const selected = current?.accounts.find(item => item.accountId === (accountId ?? current.activeAccountId));
817
+ if (selected?.tokens &&
818
+ PLAN_SCOPES.every(scope => selected.scopes.includes(scope)) &&
819
+ selected.tokens.expiresAt > this.now() + this.refreshLeeway) {
820
+ return selected.tokens.accessToken;
821
+ }
822
+ // Discovery and signing keys do not depend on the credentials; fetch
823
+ // them before taking the lock so a holder never spends a waiter's
824
+ // timeout on network calls that could have happened outside it.
825
+ await this.configuration(signal);
826
+ await this.signingKeys(signal);
827
+ return this.locked(async () => {
828
+ const store = await this.read(signal);
829
+ const account = store?.accounts.find(item => item.accountId === (accountId ?? store.activeAccountId));
830
+ if (!store || !account?.tokens)
831
+ throw new Error('Continue with ChatGPT first: run audrey auth login.');
832
+ if (!PLAN_SCOPES.every(scope => account.scopes.includes(scope))) {
833
+ throw new Error('ChatGPT plan usage is not enabled for this account. Run audrey auth login with consent to enable it.');
834
+ }
835
+ if (account.tokens.expiresAt > this.now() + this.refreshLeeway)
836
+ return account.tokens.accessToken;
837
+ if (account.tokens.earliestRefreshAt !== undefined &&
838
+ account.tokens.earliestRefreshAt > this.now()) {
839
+ if (account.tokens.expiresAt > this.now())
840
+ return account.tokens.accessToken;
841
+ throw new Error('ChatGPT access expired before refresh is allowed. Try again after the refresh time.');
842
+ }
843
+ if (!account.tokens.refreshToken || !account.scopes.includes('offline_access')) {
844
+ if (account.tokens.expiresAt > this.now())
845
+ return account.tokens.accessToken;
846
+ throw new Error('ChatGPT session cannot be renewed. Run audrey auth login.');
847
+ }
848
+ let body;
849
+ // Keys and discovery were fetched before the lock; stage the file now so
850
+ // nothing expensive remains between consuming the grant and saving it.
851
+ const staged = await this.stage(signal);
852
+ try {
853
+ body = await this.exchange(new URLSearchParams({
854
+ grant_type: 'refresh_token',
855
+ client_id: account.clientId,
856
+ refresh_token: account.tokens.refreshToken,
857
+ resource: RESOURCE,
858
+ }), signal, true);
859
+ }
860
+ catch (error) {
861
+ await this.dispose(staged);
862
+ if (error instanceof GrantError && TERMINAL_GRANT_ERRORS.has(error.code)) {
863
+ delete account.tokens;
864
+ account.revision = randomUUID();
865
+ await this.save(store, signal);
866
+ }
867
+ throw error;
868
+ }
869
+ try {
870
+ const fresh = this.tokens(body, account);
871
+ // The server has consumed the old refresh token. Persist its
872
+ // replacement before anything that can still fail (identity
873
+ // verification, a cancelled caller, a slow disk), or the account is
874
+ // signed out and needs a manual login.
875
+ account.tokens = fresh.tokens;
876
+ account.scopes = fresh.scopes;
877
+ account.revision = randomUUID();
878
+ await this.save(store, undefined, staged);
879
+ if (typeof body.id_token === 'string') {
880
+ // A key can rotate after the preflight fetch. Allow one bounded JWKS
881
+ // reload without inheriting cancellation of a consumed refresh grant.
882
+ const identity = await this.identity(body.id_token, account.clientId, undefined, AbortSignal.timeout(this.requestTimeout));
883
+ if (identity.sub !== account.subject) {
884
+ delete account.tokens;
885
+ account.revision = randomUUID();
886
+ await this.save(store);
887
+ throw new Error('Refreshed ChatGPT identity did not match the registration.');
888
+ }
889
+ }
890
+ checkAbort(signal);
891
+ if (!PLAN_SCOPES.every(scope => account.scopes.includes(scope))) {
892
+ throw new Error('ChatGPT plan usage is no longer enabled. Run audrey auth login with consent.');
893
+ }
894
+ return account.tokens.accessToken;
895
+ }
896
+ finally {
897
+ await this.dispose(staged);
898
+ }
899
+ }, signal);
900
+ }
901
+ async logout(accountId) {
902
+ return this.locked(async () => {
903
+ const store = await this.read();
904
+ const account = store?.accounts.find(item => item.accountId === (accountId ?? store.activeAccountId));
905
+ if (!store || !account)
906
+ return { revoked: true };
907
+ // Nothing to revoke only when no tokens exist; an access token with no
908
+ // refresh token stays valid until it expires and is reported as such.
909
+ let revoked = !account.tokens;
910
+ if (account.tokens?.refreshToken) {
911
+ for (let attempt = 0; attempt < 3; attempt++) {
912
+ try {
913
+ const configuration = await this.configuration();
914
+ if (!configuration.revocation_endpoint)
915
+ break;
916
+ const response = await this.response(configuration.revocation_endpoint, {
917
+ method: 'POST',
918
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
919
+ body: new URLSearchParams({
920
+ token: account.tokens.refreshToken,
921
+ token_type_hint: 'refresh_token',
922
+ client_id: account.clientId,
923
+ }).toString(),
924
+ });
925
+ if (response.status === 200) {
926
+ revoked = true;
927
+ break;
928
+ }
929
+ if (response.status < 500)
930
+ break;
931
+ }
932
+ catch {
933
+ /* Credentials still clear locally after bounded revocation attempts. */
934
+ }
935
+ if (attempt < 2)
936
+ await delay(100 * 2 ** attempt);
937
+ }
938
+ }
939
+ delete account.tokens;
940
+ account.revision = randomUUID();
941
+ if (store.activeAccountId === account.accountId)
942
+ delete store.activeAccountId;
943
+ await this.save(store);
944
+ return { revoked };
945
+ });
946
+ }
947
+ }
948
+ function bounded(value, fallback, min, max) {
949
+ if (value === undefined)
950
+ return fallback;
951
+ if (!Number.isFinite(value) || value < min || value > max)
952
+ throw new Error('ChatGPT auth timeout or refresh setting is invalid.');
953
+ return value;
954
+ }
955
+ async function delay(ms, signal) {
956
+ checkAbort(signal);
957
+ try {
958
+ await pause(ms, undefined, { signal });
959
+ }
960
+ catch {
961
+ checkAbort(signal);
962
+ throw new Error('ChatGPT authentication wait failed.');
963
+ }
964
+ }
965
+ //# sourceMappingURL=chatgpt-auth.js.map