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