pi-oracle 0.3.4 → 0.4.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 +21 -0
- package/README.md +2 -0
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +11 -24
- package/extensions/oracle/lib/config.ts +5 -0
- package/extensions/oracle/lib/jobs.ts +117 -217
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +14 -51
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +60 -14
- package/extensions/oracle/lib/tools.ts +66 -65
- package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
- package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
- package/extensions/oracle/shared/process-helpers.d.mts +20 -0
- package/extensions/oracle/shared/process-helpers.mjs +128 -0
- package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
- package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
- package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +76 -130
- package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
- package/extensions/oracle/worker/run-job.mjs +166 -274
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +4 -3
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// Purpose: Provide shared atomic lock/lease state helpers for oracle coordination across extension and worker processes.
|
|
2
|
+
// Responsibilities: Create lock/lease directories atomically, publish metadata safely, reclaim stale incomplete state, and enumerate lease metadata.
|
|
3
|
+
// Scope: Filesystem-backed concurrency primitives only; higher-level admission and queue behavior stays in wrapper modules.
|
|
4
|
+
// Usage: Imported by lib/locks.ts and worker/state-locks.mjs so both layers share identical crash-recovery semantics.
|
|
5
|
+
// Invariants/Assumptions: State lives under a private per-machine directory, and final published state dirs must never appear without complete metadata.
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
11
|
+
import { isProcessAlive } from "./process-helpers.mjs";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_WAIT_MS = 30_000;
|
|
14
|
+
const POLL_MS = 200;
|
|
15
|
+
export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
|
|
16
|
+
/** Incomplete `.tmp-*` dirs are in-flight atomic creates; a 1s grace is too short under multi-process sweep + slow FS. */
|
|
17
|
+
export const ORACLE_TMP_STATE_DIR_GRACE_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} path
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
async function ensurePrivateDir(path) {
|
|
28
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
29
|
+
await chmod(path, 0o700).catch(() => undefined);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} kind
|
|
34
|
+
* @param {string} key
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function hashOracleStateKey(kind, key) {
|
|
38
|
+
return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} stateDir
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function getStateLocksDir(stateDir) {
|
|
46
|
+
return join(stateDir, "locks");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} stateDir
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function getStateLeasesDir(stateDir) {
|
|
54
|
+
return join(stateDir, "leases");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} stateDir
|
|
59
|
+
* @param {string} kind
|
|
60
|
+
* @param {string} key
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function lockPath(stateDir, kind, key) {
|
|
64
|
+
return join(getStateLocksDir(stateDir), hashOracleStateKey(kind, key));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} stateDir
|
|
69
|
+
* @param {string} kind
|
|
70
|
+
* @param {string} key
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function leasePath(stateDir, kind, key) {
|
|
74
|
+
return join(getStateLeasesDir(stateDir), hashOracleStateKey(kind, key));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} path
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function getMetadataPath(path) {
|
|
82
|
+
return join(path, "metadata.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} path
|
|
87
|
+
* @param {unknown} metadata
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async function writeMetadata(path, metadata) {
|
|
91
|
+
const targetPath = getMetadataPath(path);
|
|
92
|
+
const tempPath = join(path, `metadata.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
93
|
+
await writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
94
|
+
await chmod(tempPath, 0o600).catch(() => undefined);
|
|
95
|
+
await rename(tempPath, targetPath);
|
|
96
|
+
await chmod(targetPath, 0o600).catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} parentDir
|
|
101
|
+
* @param {string} finalPath
|
|
102
|
+
* @param {unknown} metadata
|
|
103
|
+
* @returns {Promise<void>}
|
|
104
|
+
*/
|
|
105
|
+
async function createStateDirAtomically(parentDir, finalPath, metadata) {
|
|
106
|
+
const tempPath = join(parentDir, `.tmp-${basename(finalPath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`);
|
|
107
|
+
await mkdir(tempPath, { recursive: false, mode: 0o700 });
|
|
108
|
+
try {
|
|
109
|
+
await writeMetadata(tempPath, metadata);
|
|
110
|
+
await rename(tempPath, finalPath);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
await rm(tempPath, { recursive: true, force: true }).catch(() => undefined);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} path
|
|
119
|
+
* @returns {"present" | "missing" | "invalid"}
|
|
120
|
+
*/
|
|
121
|
+
function getMetadataState(path) {
|
|
122
|
+
const metadataPath = getMetadataPath(path);
|
|
123
|
+
if (!existsSync(metadataPath)) return "missing";
|
|
124
|
+
try {
|
|
125
|
+
JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
126
|
+
return "present";
|
|
127
|
+
} catch {
|
|
128
|
+
return "invalid";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} path
|
|
134
|
+
* @param {number} [now]
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
function isIncompleteStateDirStale(path, now = Date.now()) {
|
|
138
|
+
try {
|
|
139
|
+
const stats = statSync(path);
|
|
140
|
+
const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
|
|
141
|
+
const graceMs = basename(path).startsWith(".tmp-") ? ORACLE_TMP_STATE_DIR_GRACE_MS : ORACLE_METADATA_WRITE_GRACE_MS;
|
|
142
|
+
return now - baselineMs >= graceMs;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} path
|
|
150
|
+
* @returns {number | undefined}
|
|
151
|
+
*/
|
|
152
|
+
function readLockProcessPid(path) {
|
|
153
|
+
const metadataPath = getMetadataPath(path);
|
|
154
|
+
if (!existsSync(metadataPath)) return undefined;
|
|
155
|
+
try {
|
|
156
|
+
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
157
|
+
return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
|
|
158
|
+
? metadata.processPid
|
|
159
|
+
: undefined;
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {unknown} error
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
function isStateDirExistsError(error) {
|
|
170
|
+
return Boolean(error && typeof error === "object" && "code" in error && (error.code === "EEXIST" || error.code === "ENOTEMPTY"));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} path
|
|
175
|
+
* @param {number} [now]
|
|
176
|
+
* @returns {Promise<boolean>}
|
|
177
|
+
*/
|
|
178
|
+
async function maybeReclaimIncompleteStateDir(path, now = Date.now()) {
|
|
179
|
+
if (getMetadataState(path) === "present") return false;
|
|
180
|
+
if (!isIncompleteStateDirStale(path, now)) return false;
|
|
181
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {string} path
|
|
187
|
+
* @param {number} [now]
|
|
188
|
+
* @returns {Promise<boolean>}
|
|
189
|
+
*/
|
|
190
|
+
async function maybeReclaimStaleLock(path, now = Date.now()) {
|
|
191
|
+
if (await maybeReclaimIncompleteStateDir(path, now)) return true;
|
|
192
|
+
const processPid = readLockProcessPid(path);
|
|
193
|
+
if (!processPid || isProcessAlive(processPid)) return false;
|
|
194
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} stateDir
|
|
200
|
+
* @param {number} [now]
|
|
201
|
+
* @returns {Promise<string[]>}
|
|
202
|
+
*/
|
|
203
|
+
export async function sweepStaleStateLocks(stateDir, now = Date.now()) {
|
|
204
|
+
const dir = getStateLocksDir(stateDir);
|
|
205
|
+
if (!existsSync(dir)) return [];
|
|
206
|
+
const removed = [];
|
|
207
|
+
for (const name of readdirSync(dir)) {
|
|
208
|
+
const path = join(dir, name);
|
|
209
|
+
if (await maybeReclaimStaleLock(path, now)) {
|
|
210
|
+
removed.push(path);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return removed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {string} stateDir
|
|
218
|
+
* @param {string} kind
|
|
219
|
+
* @param {string} key
|
|
220
|
+
* @param {unknown} metadata
|
|
221
|
+
* @param {number} [timeoutMs]
|
|
222
|
+
* @returns {Promise<string>}
|
|
223
|
+
*/
|
|
224
|
+
export async function acquireStateLock(stateDir, kind, key, metadata, timeoutMs = DEFAULT_WAIT_MS) {
|
|
225
|
+
const parentDir = getStateLocksDir(stateDir);
|
|
226
|
+
const path = join(parentDir, hashOracleStateKey(kind, key));
|
|
227
|
+
const deadline = Date.now() + timeoutMs;
|
|
228
|
+
await ensurePrivateDir(stateDir);
|
|
229
|
+
await ensurePrivateDir(parentDir);
|
|
230
|
+
|
|
231
|
+
while (Date.now() < deadline) {
|
|
232
|
+
try {
|
|
233
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
234
|
+
return path;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
237
|
+
if (await maybeReclaimStaleLock(path)) continue;
|
|
238
|
+
}
|
|
239
|
+
await sleep(POLL_MS);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string | undefined} path
|
|
247
|
+
* @returns {Promise<void>}
|
|
248
|
+
*/
|
|
249
|
+
export async function releaseStatePath(path) {
|
|
250
|
+
if (!path) return;
|
|
251
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @template T
|
|
256
|
+
* @param {string} stateDir
|
|
257
|
+
* @param {string} kind
|
|
258
|
+
* @param {string} key
|
|
259
|
+
* @param {unknown} metadata
|
|
260
|
+
* @param {() => Promise<T>} fn
|
|
261
|
+
* @param {number} [timeoutMs]
|
|
262
|
+
* @returns {Promise<T>}
|
|
263
|
+
*/
|
|
264
|
+
export async function withStateLock(stateDir, kind, key, metadata, fn, timeoutMs = DEFAULT_WAIT_MS) {
|
|
265
|
+
const handle = await acquireStateLock(stateDir, kind, key, metadata, timeoutMs);
|
|
266
|
+
try {
|
|
267
|
+
return await fn();
|
|
268
|
+
} finally {
|
|
269
|
+
await releaseStatePath(handle);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {string} stateDir
|
|
275
|
+
* @param {string} kind
|
|
276
|
+
* @param {string} key
|
|
277
|
+
* @param {unknown} metadata
|
|
278
|
+
* @param {number} [timeoutMs]
|
|
279
|
+
* @returns {Promise<string>}
|
|
280
|
+
*/
|
|
281
|
+
export async function createStateLease(stateDir, kind, key, metadata, timeoutMs = DEFAULT_WAIT_MS) {
|
|
282
|
+
const parentDir = getStateLeasesDir(stateDir);
|
|
283
|
+
const path = join(parentDir, hashOracleStateKey(kind, key));
|
|
284
|
+
const deadline = Date.now() + timeoutMs;
|
|
285
|
+
await ensurePrivateDir(stateDir);
|
|
286
|
+
await ensurePrivateDir(parentDir);
|
|
287
|
+
|
|
288
|
+
while (Date.now() < deadline) {
|
|
289
|
+
try {
|
|
290
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
291
|
+
return path;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
294
|
+
if (await maybeReclaimIncompleteStateDir(path)) continue;
|
|
295
|
+
if (getMetadataState(path) === "present") throw error;
|
|
296
|
+
}
|
|
297
|
+
await sleep(POLL_MS);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
throw new Error(`Timed out waiting for oracle ${kind} lease: ${key}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} stateDir
|
|
305
|
+
* @param {string} kind
|
|
306
|
+
* @param {string} key
|
|
307
|
+
* @param {unknown} metadata
|
|
308
|
+
* @returns {Promise<string>}
|
|
309
|
+
*/
|
|
310
|
+
export async function writeStateLeaseMetadata(stateDir, kind, key, metadata) {
|
|
311
|
+
const parentDir = getStateLeasesDir(stateDir);
|
|
312
|
+
const path = join(parentDir, hashOracleStateKey(kind, key));
|
|
313
|
+
await ensurePrivateDir(stateDir);
|
|
314
|
+
await ensurePrivateDir(parentDir);
|
|
315
|
+
if (existsSync(path)) {
|
|
316
|
+
await chmod(path, 0o700).catch(() => undefined);
|
|
317
|
+
await writeMetadata(path, metadata);
|
|
318
|
+
return path;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
324
|
+
if (await maybeReclaimIncompleteStateDir(path)) {
|
|
325
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
326
|
+
} else {
|
|
327
|
+
await writeMetadata(path, metadata);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return path;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @template T
|
|
335
|
+
* @param {string} stateDir
|
|
336
|
+
* @param {string} kind
|
|
337
|
+
* @param {string} key
|
|
338
|
+
* @returns {Promise<T | undefined>}
|
|
339
|
+
*/
|
|
340
|
+
export async function readStateLeaseMetadata(stateDir, kind, key) {
|
|
341
|
+
const path = getMetadataPath(leasePath(stateDir, kind, key));
|
|
342
|
+
if (!existsSync(path)) return undefined;
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
345
|
+
} catch {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @template T
|
|
352
|
+
* @param {string} stateDir
|
|
353
|
+
* @param {string} kind
|
|
354
|
+
* @returns {T[]}
|
|
355
|
+
*/
|
|
356
|
+
export function listStateLeaseMetadata(stateDir, kind) {
|
|
357
|
+
const dir = getStateLeasesDir(stateDir);
|
|
358
|
+
if (!existsSync(dir)) return [];
|
|
359
|
+
return readdirSync(dir)
|
|
360
|
+
.filter((name) => name.startsWith(`${kind}-`))
|
|
361
|
+
.map((name) => join(dir, name, "metadata.json"))
|
|
362
|
+
.filter((path) => existsSync(path))
|
|
363
|
+
.flatMap((path) => {
|
|
364
|
+
try {
|
|
365
|
+
return [JSON.parse(readFileSync(path, "utf8"))];
|
|
366
|
+
} catch {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {string} stateDir
|
|
374
|
+
* @param {string} kind
|
|
375
|
+
* @param {string | undefined} key
|
|
376
|
+
* @returns {Promise<void>}
|
|
377
|
+
*/
|
|
378
|
+
export async function releaseStateLease(stateDir, kind, key) {
|
|
379
|
+
if (!key) return;
|
|
380
|
+
await rm(leasePath(stateDir, kind, key), { recursive: true, force: true }).catch(() => undefined);
|
|
381
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Purpose: Parse agent-browser snapshots and infer likely downloadable artifact labels from response UI structure.
|
|
2
|
+
// Responsibilities: Extract snapshot entries, detect candidate filenames, and partition strong versus suspicious artifact signals.
|
|
3
|
+
// Scope: Pure heuristic parsing only; browser interaction and artifact download orchestration stay in the worker.
|
|
4
|
+
// Usage: Imported by worker runtime code, UI helper modules, and sanity tests to keep artifact detection behavior deterministic.
|
|
5
|
+
// Invariants/Assumptions: Snapshot text comes from agent-browser `snapshot -i`, and heuristics should prefer false negatives over noisy false positives.
|
|
1
6
|
export const FILE_LABEL_PATTERN_SOURCE = String.raw`(?:^|[^A-Za-z0-9._~/-])((?:(?:[A-Za-z]:)?[\\/]|[.~][\\/])?(?:[^\\/\s"'<>|]+[\\/])*[^\\/\s"'<>|]+\.[A-Za-z0-9]{1,12})(?=$|[^A-Za-z0-9._~/-])`;
|
|
2
7
|
const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE, "g");
|
|
3
8
|
export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Purpose: Bootstrap isolated oracle browser auth by importing real Chrome cookies and validating ChatGPT session readiness.
|
|
2
|
+
// Responsibilities: Copy/import cookies, classify auth pages, drive lightweight account-selection flows, and persist diagnostics for auth failures.
|
|
3
|
+
// Scope: Auth bootstrap worker only; long-running oracle job execution stays in run-job.mjs and shared lifecycle/state helpers stay elsewhere.
|
|
4
|
+
// Usage: Spawned by /oracle-auth to prepare the shared auth seed profile used by future oracle jobs.
|
|
5
|
+
// Invariants/Assumptions: Runs against a local macOS Chrome profile, preserves private diagnostics, and must fail clearly when auth state cannot be verified.
|
|
1
6
|
import { withLock } from "./state-locks.mjs";
|
|
2
7
|
import { spawn } from "node:child_process";
|
|
3
8
|
import { existsSync } from "node:fs";
|
|
@@ -7,6 +12,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
7
12
|
import { getCookies } from "@steipete/sweet-cookie";
|
|
8
13
|
import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
|
|
9
14
|
import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
|
|
15
|
+
import { buildAccountChooserCandidateLabels, classifyChatAuthPage, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
|
|
10
16
|
|
|
11
17
|
const rawConfig = process.argv[2];
|
|
12
18
|
if (!rawConfig) {
|
|
@@ -42,6 +48,17 @@ const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/ag
|
|
|
42
48
|
(candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
|
|
43
49
|
) || "agent-browser";
|
|
44
50
|
|
|
51
|
+
function readPositiveIntEnv(name, fallback) {
|
|
52
|
+
const value = process.env[name]?.trim();
|
|
53
|
+
if (!value) return fallback;
|
|
54
|
+
const parsed = Number.parseInt(value, 10);
|
|
55
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const AGENT_BROWSER_COMMAND_TIMEOUT_MS = readPositiveIntEnv("PI_ORACLE_AUTH_AGENT_BROWSER_TIMEOUT_MS", 30_000);
|
|
59
|
+
const AGENT_BROWSER_CLOSE_TIMEOUT_MS = readPositiveIntEnv("PI_ORACLE_AUTH_CLOSE_TIMEOUT_MS", 10_000);
|
|
60
|
+
const AGENT_BROWSER_KILL_GRACE_MS = readPositiveIntEnv("PI_ORACLE_AUTH_KILL_GRACE_MS", 2_000);
|
|
61
|
+
|
|
45
62
|
let runtimeProfileDir = config.browser.authSeedProfileDir;
|
|
46
63
|
|
|
47
64
|
function authSessionName() {
|
|
@@ -78,12 +95,25 @@ async function log(message) {
|
|
|
78
95
|
|
|
79
96
|
function spawnCommand(command, args, options = {}) {
|
|
80
97
|
return new Promise((resolve, reject) => {
|
|
98
|
+
const { timeoutMs = AGENT_BROWSER_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
|
|
81
99
|
const child = spawn(command, args, {
|
|
82
100
|
stdio: ["pipe", "pipe", "pipe"],
|
|
83
|
-
...
|
|
101
|
+
...spawnOptions,
|
|
84
102
|
});
|
|
85
103
|
let stdout = "";
|
|
86
104
|
let stderr = "";
|
|
105
|
+
let timedOut = false;
|
|
106
|
+
let killTimer;
|
|
107
|
+
let killGraceTimer;
|
|
108
|
+
if (typeof timeoutMs === "number" && timeoutMs > 0) {
|
|
109
|
+
killTimer = setTimeout(() => {
|
|
110
|
+
timedOut = true;
|
|
111
|
+
child.kill("SIGTERM");
|
|
112
|
+
killGraceTimer = setTimeout(() => child.kill("SIGKILL"), AGENT_BROWSER_KILL_GRACE_MS);
|
|
113
|
+
killGraceTimer.unref?.();
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
killTimer.unref?.();
|
|
116
|
+
}
|
|
87
117
|
if (options.input) child.stdin.end(options.input);
|
|
88
118
|
else child.stdin.end();
|
|
89
119
|
child.stdout.on("data", (data) => {
|
|
@@ -92,8 +122,20 @@ function spawnCommand(command, args, options = {}) {
|
|
|
92
122
|
child.stderr.on("data", (data) => {
|
|
93
123
|
stderr += String(data);
|
|
94
124
|
});
|
|
95
|
-
child.on("error",
|
|
125
|
+
child.on("error", (error) => {
|
|
126
|
+
if (killTimer) clearTimeout(killTimer);
|
|
127
|
+
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
128
|
+
reject(error);
|
|
129
|
+
});
|
|
96
130
|
child.on("close", (code) => {
|
|
131
|
+
if (killTimer) clearTimeout(killTimer);
|
|
132
|
+
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
133
|
+
if (timedOut) {
|
|
134
|
+
const error = new Error(stderr || stdout || `${command} timed out after ${timeoutMs}ms`);
|
|
135
|
+
if (options.allowFailure) resolve({ code, stdout: stdout.trim(), stderr: error.message });
|
|
136
|
+
else reject(error);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
97
139
|
if (code === 0 || options.allowFailure) resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
98
140
|
else reject(new Error(stderr || stdout || `${command} exited with code ${code}`));
|
|
99
141
|
});
|
|
@@ -114,7 +156,10 @@ function targetBrowserBaseArgs(options = {}) {
|
|
|
114
156
|
|
|
115
157
|
async function closeTargetBrowser() {
|
|
116
158
|
await log(`Closing target browser session ${authSessionName()} if present`);
|
|
117
|
-
const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "close"], {
|
|
159
|
+
const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "close"], {
|
|
160
|
+
allowFailure: true,
|
|
161
|
+
timeoutMs: AGENT_BROWSER_CLOSE_TIMEOUT_MS,
|
|
162
|
+
});
|
|
118
163
|
await log(`close result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
|
|
119
164
|
}
|
|
120
165
|
|
|
@@ -131,7 +176,10 @@ async function ensureNotSymlink(path, label) {
|
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
async function isAuthBrowserConnected() {
|
|
134
|
-
const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], {
|
|
179
|
+
const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], {
|
|
180
|
+
allowFailure: true,
|
|
181
|
+
timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS,
|
|
182
|
+
});
|
|
135
183
|
try {
|
|
136
184
|
const parsed = JSON.parse(result.stdout || "{}");
|
|
137
185
|
return parsed?.data?.connected === true;
|
|
@@ -223,7 +271,7 @@ async function launchTargetBrowser() {
|
|
|
223
271
|
await closeTargetBrowser();
|
|
224
272
|
const args = [...targetBrowserBaseArgs({ withLaunchOptions: true, mode: "headed" }), "open", "about:blank"];
|
|
225
273
|
await log(`Launching isolated browser: agent-browser ${JSON.stringify(args)}`);
|
|
226
|
-
const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true });
|
|
274
|
+
const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true, timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS });
|
|
227
275
|
await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
|
|
228
276
|
if (result.code !== 0) {
|
|
229
277
|
throw new Error(result.stderr || result.stdout || "Failed to launch isolated oracle browser");
|
|
@@ -231,7 +279,10 @@ async function launchTargetBrowser() {
|
|
|
231
279
|
}
|
|
232
280
|
|
|
233
281
|
async function streamStatus() {
|
|
234
|
-
const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], {
|
|
282
|
+
const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], {
|
|
283
|
+
allowFailure: true,
|
|
284
|
+
timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS,
|
|
285
|
+
});
|
|
235
286
|
await log(`stream status: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
|
|
236
287
|
try {
|
|
237
288
|
const parsed = JSON.parse(result.stdout || "{}");
|
|
@@ -255,7 +306,11 @@ async function targetCommand(...args) {
|
|
|
255
306
|
maybeOptions &&
|
|
256
307
|
typeof maybeOptions === "object" &&
|
|
257
308
|
!Array.isArray(maybeOptions) &&
|
|
258
|
-
(Object.hasOwn(maybeOptions, "allowFailure") ||
|
|
309
|
+
(Object.hasOwn(maybeOptions, "allowFailure") ||
|
|
310
|
+
Object.hasOwn(maybeOptions, "input") ||
|
|
311
|
+
Object.hasOwn(maybeOptions, "cwd") ||
|
|
312
|
+
Object.hasOwn(maybeOptions, "logLabel") ||
|
|
313
|
+
Object.hasOwn(maybeOptions, "timeoutMs"))
|
|
259
314
|
) {
|
|
260
315
|
options = args.pop();
|
|
261
316
|
}
|
|
@@ -545,22 +600,7 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
545
600
|
|
|
546
601
|
async function loginProbe() {
|
|
547
602
|
const result = await evalPage(buildLoginProbeScript(LOGIN_PROBE_TIMEOUT_MS), "login probe eval");
|
|
548
|
-
|
|
549
|
-
return { ok: false, status: 0, error: "invalid-probe-result" };
|
|
550
|
-
}
|
|
551
|
-
return {
|
|
552
|
-
ok: result.ok === true,
|
|
553
|
-
status: typeof result.status === "number" ? result.status : 0,
|
|
554
|
-
pageUrl: typeof result.pageUrl === "string" ? result.pageUrl : undefined,
|
|
555
|
-
domLoginCta: result.domLoginCta === true,
|
|
556
|
-
onAuthPage: result.onAuthPage === true,
|
|
557
|
-
error: typeof result.error === "string" ? result.error : undefined,
|
|
558
|
-
bodyKeys: Array.isArray(result.bodyKeys) ? result.bodyKeys : [],
|
|
559
|
-
bodyHasId: result.bodyHasId === true,
|
|
560
|
-
bodyHasEmail: result.bodyHasEmail === true,
|
|
561
|
-
name: typeof result.name === "string" ? result.name : undefined,
|
|
562
|
-
responsePreview: typeof result.responsePreview === "string" ? result.responsePreview : undefined,
|
|
563
|
-
};
|
|
603
|
+
return normalizeLoginProbeResult(result);
|
|
564
604
|
}
|
|
565
605
|
|
|
566
606
|
async function captureDiagnostics(reason) {
|
|
@@ -580,116 +620,22 @@ async function captureDiagnostics(reason) {
|
|
|
580
620
|
}
|
|
581
621
|
|
|
582
622
|
function classifyChatPage({ url, snapshot, body, probe }) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
return {
|
|
596
|
-
state: "challenge_blocking",
|
|
597
|
-
message:
|
|
598
|
-
`ChatGPT challenge detected after syncing cookies from ${cookieSourceLabel()}. ` +
|
|
599
|
-
`The isolated oracle browser was left open on profile ${runtimeProfileDir}; complete the challenge there, then rerun /oracle-auth. Logs: ${LOG_PATH}`,
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
if (/http error 431|request header or cookie too large/i.test(text)) {
|
|
604
|
-
return {
|
|
605
|
-
state: "login_required",
|
|
606
|
-
message:
|
|
607
|
-
`Imported auth hit HTTP 431 during ChatGPT auth resolution, which usually means the imported cookie set is too large or stale. ` +
|
|
608
|
-
`Inspect ${LOG_PATH}.`,
|
|
609
|
-
};
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
const outagePatterns = [
|
|
613
|
-
/something went wrong/i,
|
|
614
|
-
/a network error occurred/i,
|
|
615
|
-
/an error occurred while connecting to the websocket/i,
|
|
616
|
-
/try again later/i,
|
|
617
|
-
];
|
|
618
|
-
if (outagePatterns.some((pattern) => pattern.test(text))) {
|
|
619
|
-
return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${LOG_PATH}` };
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
const onAllowedOrigin = allowedOrigins.some((origin) => url.startsWith(origin));
|
|
623
|
-
const hasComposer = snapshot.includes(`textbox \"${CHATGPT_LABELS.composer}\"`);
|
|
624
|
-
const hasAddFiles = snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`);
|
|
625
|
-
const hasModelControl =
|
|
626
|
-
snapshot.includes('button "Model selector"') ||
|
|
627
|
-
/button "(Instant|Thinking|Pro)(?: [^"]*)?"/.test(snapshot);
|
|
628
|
-
|
|
629
|
-
if (probe?.status === 401 || probe?.status === 403) {
|
|
630
|
-
return {
|
|
631
|
-
state: "login_required",
|
|
632
|
-
message:
|
|
633
|
-
`Synced cookies from ${cookieSourceLabel()}, but ChatGPT still rejected the session ` +
|
|
634
|
-
`(status=${probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${LOG_PATH}.`,
|
|
635
|
-
};
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
if (probe?.onAuthPage) {
|
|
639
|
-
if (probe?.bodyHasId || probe?.bodyHasEmail) {
|
|
640
|
-
return {
|
|
641
|
-
state: "auth_transitioning",
|
|
642
|
-
message:
|
|
643
|
-
`ChatGPT is on /auth/login, but /backend-api/me returned a partial authenticated session. ` +
|
|
644
|
-
`Trying to drive the login resolution flow. Logs: ${LOG_PATH}`,
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
return {
|
|
648
|
-
state: "login_required",
|
|
649
|
-
message:
|
|
650
|
-
`Synced cookies from ${cookieSourceLabel()}, but ChatGPT still rejected the session ` +
|
|
651
|
-
`(status=${probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${LOG_PATH}.`,
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
if (onAllowedOrigin && probe?.status === 200 && hasComposer && hasAddFiles && hasModelControl) {
|
|
656
|
-
if (!probe?.domLoginCta) {
|
|
657
|
-
return {
|
|
658
|
-
state: "authenticated_and_ready",
|
|
659
|
-
message: `Imported ChatGPT auth from ${cookieSourceLabel()} into the isolated oracle profile. Logs: ${LOG_PATH}`,
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
return {
|
|
664
|
-
state: "auth_transitioning",
|
|
665
|
-
message:
|
|
666
|
-
probe?.bodyHasId || probe?.bodyHasEmail
|
|
667
|
-
? `ChatGPT backend session is authenticated but the shell still shows public CTA chrome. Logs: ${LOG_PATH}`
|
|
668
|
-
: `ChatGPT accepted cookies but is still hydrating/auth-selecting. Logs: ${LOG_PATH}`,
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (onAllowedOrigin && probe?.ok && hasComposer && hasAddFiles && hasModelControl) {
|
|
673
|
-
return {
|
|
674
|
-
state: "authenticated_and_ready",
|
|
675
|
-
message: `Imported ChatGPT auth from ${cookieSourceLabel()} into the isolated oracle profile. Logs: ${LOG_PATH}`,
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
if (url && !onAllowedOrigin) {
|
|
680
|
-
return { state: "login_required", message: `Imported auth redirected away from the expected ChatGPT origin. Logs: ${LOG_PATH}` };
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
return { state: "unknown", message: `ChatGPT page state is not yet ready. Logs: ${LOG_PATH}` };
|
|
623
|
+
return classifyChatAuthPage({
|
|
624
|
+
url,
|
|
625
|
+
snapshot,
|
|
626
|
+
body,
|
|
627
|
+
probe,
|
|
628
|
+
allowedOrigins: buildAllowedChatGptOrigins(config.browser.chatUrl, config.browser.authUrl),
|
|
629
|
+
cookieSourceLabel: cookieSourceLabel(),
|
|
630
|
+
runtimeProfileDir,
|
|
631
|
+
logPath: LOG_PATH,
|
|
632
|
+
composerLabel: CHATGPT_LABELS.composer,
|
|
633
|
+
addFilesLabel: CHATGPT_LABELS.addFiles,
|
|
634
|
+
});
|
|
684
635
|
}
|
|
685
636
|
|
|
686
637
|
async function maybeSelectAccountIdentity(snapshot, probe) {
|
|
687
|
-
const candidates =
|
|
688
|
-
if (typeof probe?.name === "string" && probe.name.trim()) {
|
|
689
|
-
candidates.push(probe.name.trim());
|
|
690
|
-
const firstToken = probe.name.trim().split(/\s+/)[0];
|
|
691
|
-
if (firstToken && firstToken !== probe.name.trim()) candidates.push(firstToken);
|
|
692
|
-
}
|
|
638
|
+
const candidates = buildAccountChooserCandidateLabels(probe?.name);
|
|
693
639
|
|
|
694
640
|
for (const label of candidates) {
|
|
695
641
|
const entry = findEntry(
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Purpose: Define the allowlist/drop policy for importing ChatGPT/OpenAI auth cookies into the isolated oracle browser profile.
|
|
2
|
+
// Responsibilities: Recognize required auth cookies, drop noisy/irrelevant cookies, and normalize cookie import decisions.
|
|
3
|
+
// Scope: Pure cookie-policy logic only; reading cookies from Chrome and writing them into the isolated profile happen elsewhere.
|
|
4
|
+
// Usage: Imported by auth-bootstrap and sanity tests to keep cookie import behavior deterministic and reviewable.
|
|
5
|
+
// Invariants/Assumptions: Security-sensitive auth cookies are allowlisted intentionally, and analytics/ambient cookies should be excluded by default.
|
|
1
6
|
const AUTH_COOKIE_NAME_PATTERNS = [
|
|
2
7
|
/^__Secure-next-auth\.session-token(?:\.|$)/,
|
|
3
8
|
/^__Secure-next-auth\.callback-url$/,
|