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.
Files changed (33) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +2 -0
  3. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
  4. package/extensions/oracle/index.ts +8 -1
  5. package/extensions/oracle/lib/commands.ts +11 -24
  6. package/extensions/oracle/lib/config.ts +5 -0
  7. package/extensions/oracle/lib/jobs.ts +117 -217
  8. package/extensions/oracle/lib/locks.ts +41 -209
  9. package/extensions/oracle/lib/poller.ts +14 -51
  10. package/extensions/oracle/lib/queue.ts +75 -112
  11. package/extensions/oracle/lib/runtime.ts +60 -14
  12. package/extensions/oracle/lib/tools.ts +66 -65
  13. package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
  14. package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
  15. package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
  16. package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
  17. package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
  18. package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
  19. package/extensions/oracle/shared/process-helpers.d.mts +20 -0
  20. package/extensions/oracle/shared/process-helpers.mjs +128 -0
  21. package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
  22. package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
  23. package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
  24. package/extensions/oracle/worker/auth-bootstrap.mjs +76 -130
  25. package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
  26. package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
  27. package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
  28. package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
  29. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
  30. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
  31. package/extensions/oracle/worker/run-job.mjs +166 -274
  32. package/extensions/oracle/worker/state-locks.mjs +31 -216
  33. 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
- ...options,
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", reject);
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"], { allowFailure: true });
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"], { allowFailure: true });
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"], { allowFailure: true });
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") || Object.hasOwn(maybeOptions, "input") || Object.hasOwn(maybeOptions, "cwd") || Object.hasOwn(maybeOptions, "logLabel"))
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
- if (!result || typeof result !== "object") {
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
- const text = `${snapshot}\n${body}`;
584
- const allowedOrigins = buildAllowedChatGptOrigins(config.browser.chatUrl, config.browser.authUrl);
585
-
586
- const challengePatterns = [
587
- /just a moment/i,
588
- /verify you are human/i,
589
- /cloudflare/i,
590
- /captcha|turnstile|hcaptcha/i,
591
- /unusual activity detected/i,
592
- /we detect suspicious activity/i,
593
- ];
594
- if (challengePatterns.some((pattern) => pattern.test(text))) {
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$/,