pi-oracle 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ An oracle job:
30
30
  3. uploads the archive and sends the prompt
31
31
  4. waits in the background
32
32
  5. persists the response and any artifacts under `/tmp/oracle-<job-id>/`
33
+ - old terminal jobs are later pruned according to cleanup retention settings
33
34
  6. wakes the originating `pi` session on completion
34
35
 
35
36
  ## Example
@@ -105,9 +106,17 @@ Common settings:
105
106
  - `browser.runtimeProfilesDir`
106
107
  - `auth.chromeProfile`
107
108
  - `auth.chromeCookiePath`
109
+ - `cleanup.completeJobRetentionMs`
110
+ - `cleanup.failedJobRetentionMs`
108
111
 
109
112
  Project config should only override safe, non-privileged settings.
110
113
 
114
+ Cleanup behavior:
115
+ - terminal job directories under `/tmp/oracle-<job-id>/` are retained for inspection, then pruned conservatively
116
+ - completed/cancelled jobs are pruned after `cleanup.completeJobRetentionMs` once they have been notified
117
+ - failed jobs are pruned after `cleanup.failedJobRetentionMs`
118
+ - `/oracle-clean` performs runtime cleanup before removing the job directory and reports cleanup warnings if any residual cleanup step fails
119
+
111
120
  Detailed design and maintainer docs:
112
121
  - `docs/ORACLE_DESIGN.md`
113
122
  - `docs/ORACLE_RECOVERY_DRILL.md`
@@ -3,6 +3,8 @@ import { dirname, join } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
4
  import { loadOracleConfig } from "./lib/config.js";
5
5
  import { registerOracleCommands } from "./lib/commands.js";
6
+ import { pruneTerminalOracleJobs, reconcileStaleOracleJobs } from "./lib/jobs.js";
7
+ import { isLockTimeoutError, withGlobalReconcileLock } from "./lib/locks.js";
6
8
  import { refreshOracleStatus, startPoller, stopPoller, stopPollerForSession } from "./lib/poller.js";
7
9
  import { registerOracleTools } from "./lib/tools.js";
8
10
 
@@ -14,10 +16,24 @@ export default function oracleExtension(pi: ExtensionAPI) {
14
16
  registerOracleCommands(pi, authWorkerPath);
15
17
  registerOracleTools(pi, workerPath);
16
18
 
19
+ async function runStartupMaintenance(ctx: ExtensionContext): Promise<void> {
20
+ try {
21
+ await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_session_start", cwd: ctx.cwd }, async () => {
22
+ await reconcileStaleOracleJobs();
23
+ await pruneTerminalOracleJobs();
24
+ }, { timeoutMs: 250 });
25
+ } catch (error) {
26
+ if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
27
+ }
28
+ }
29
+
17
30
  function startPollerForContext(previousSessionFile: string | undefined, ctx: ExtensionContext) {
18
31
  stopPollerForSession(previousSessionFile, ctx.cwd);
19
32
  try {
20
33
  const config = loadOracleConfig(ctx.cwd);
34
+ void runStartupMaintenance(ctx).catch((error) => {
35
+ console.error("Oracle startup maintenance failed:", error);
36
+ });
21
37
  startPoller(pi, ctx, config.poller.intervalMs);
22
38
  refreshOracleStatus(ctx);
23
39
  } catch (error) {
@@ -1,10 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
- import { rm } from "node:fs/promises";
3
- import { join } from "node:path";
4
2
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
5
3
  import { loadOracleConfig } from "./config.js";
6
4
  import { buildOracleDispatchPrompt } from "./instructions.js";
7
- import { cancelOracleJob, isActiveOracleJob, listJobsForCwd, readJob, reconcileStaleOracleJobs } from "./jobs.js";
5
+ import {
6
+ cancelOracleJob,
7
+ isActiveOracleJob,
8
+ listJobsForCwd,
9
+ pruneTerminalOracleJobs,
10
+ readJob,
11
+ reconcileStaleOracleJobs,
12
+ removeTerminalOracleJob,
13
+ } from "./jobs.js";
8
14
  import { refreshOracleStatus } from "./poller.js";
9
15
  import { isLockTimeoutError, withGlobalReconcileLock } from "./locks.js";
10
16
  import { getProjectId } from "./runtime.js";
@@ -27,6 +33,8 @@ function summarizeJob(jobId: string): string {
27
33
  job.responsePath ? `response: ${job.responsePath}` : undefined,
28
34
  job.responseFormat ? `response-format: ${job.responseFormat}` : undefined,
29
35
  typeof job.artifactFailureCount === "number" ? `artifact-failures: ${job.artifactFailureCount}` : undefined,
36
+ job.lastCleanupAt ? `last-cleanup: ${job.lastCleanupAt}` : undefined,
37
+ job.cleanupWarnings?.length ? `cleanup-warnings: ${job.cleanupWarnings.join(" | ")}` : undefined,
30
38
  job.error ? `error: ${job.error}` : undefined,
31
39
  ]
32
40
  .filter(Boolean)
@@ -48,6 +56,7 @@ async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<st
48
56
  try {
49
57
  await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_auth", cwd }, async () => {
50
58
  await reconcileStaleOracleJobs();
59
+ await pruneTerminalOracleJobs();
51
60
  });
52
61
  } catch (error) {
53
62
  if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
@@ -176,13 +185,28 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string)
176
185
  return;
177
186
  }
178
187
 
179
- for (const job of jobs) {
180
- if (!job) continue;
181
- await rm(join("/tmp", `oracle-${job.id}`), { recursive: true, force: true });
188
+ const cleanupWarnings: string[] = [];
189
+ const removeJobs = async () => {
190
+ for (const job of jobs) {
191
+ if (!job) continue;
192
+ const result = await removeTerminalOracleJob(job);
193
+ cleanupWarnings.push(...result.cleanupReport.warnings.map((warning) => `${job.id}: ${warning}`));
194
+ }
195
+ };
196
+
197
+ try {
198
+ await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_clean", cwd: ctx.cwd }, async () => {
199
+ await reconcileStaleOracleJobs();
200
+ await removeJobs();
201
+ });
202
+ } catch (error) {
203
+ if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
204
+ await removeJobs();
182
205
  }
183
206
 
184
207
  refreshOracleStatus(ctx);
185
- ctx.ui.notify(`Removed ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}`, "info");
208
+ const warningSuffix = cleanupWarnings.length > 0 ? ` Cleanup warnings:\n${cleanupWarnings.join("\n")}` : "";
209
+ ctx.ui.notify(`Removed ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}.${warningSuffix}`, cleanupWarnings.length > 0 ? "warning" : "info");
186
210
  },
187
211
  });
188
212
  }
@@ -18,7 +18,7 @@ export type OracleCloneStrategy = (typeof CLONE_STRATEGIES)[number];
18
18
 
19
19
  const PRO_EFFORTS = ["standard", "extended"] as const satisfies readonly OracleEffort[];
20
20
  const ALLOWED_CHATGPT_ORIGINS = new Set(["https://chatgpt.com", "https://chat.openai.com"]);
21
- const PROJECT_OVERRIDE_KEYS = new Set(["defaults", "worker", "poller", "artifacts"]);
21
+ const PROJECT_OVERRIDE_KEYS = new Set(["defaults", "worker", "poller", "artifacts", "cleanup"]);
22
22
  const DEFAULT_MAC_CHROME_EXECUTABLE = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
23
23
  const DEFAULT_MAC_CHROME_USER_DATA_DIR = join(homedir(), "Library", "Application Support", "Google", "Chrome");
24
24
 
@@ -57,6 +57,10 @@ export interface OracleConfig {
57
57
  artifacts: {
58
58
  capture: boolean;
59
59
  };
60
+ cleanup: {
61
+ completeJobRetentionMs: number;
62
+ failedJobRetentionMs: number;
63
+ };
60
64
  }
61
65
 
62
66
  function detectDefaultChromeExecutablePath(): string | undefined {
@@ -127,6 +131,10 @@ export const DEFAULT_CONFIG: OracleConfig = {
127
131
  artifacts: {
128
132
  capture: true,
129
133
  },
134
+ cleanup: {
135
+ completeJobRetentionMs: 14 * 24 * 60 * 60 * 1000,
136
+ failedJobRetentionMs: 30 * 24 * 60 * 60 * 1000,
137
+ },
130
138
  };
131
139
 
132
140
  function isObject(value: unknown): value is Record<string, unknown> {
@@ -303,6 +311,7 @@ function validateOracleConfig(value: unknown): OracleConfig {
303
311
  const worker = expectObject(root.worker, "worker");
304
312
  const poller = expectObject(root.poller, "poller");
305
313
  const artifacts = expectObject(root.artifacts, "artifacts");
314
+ const cleanup = expectObject(root.cleanup, "cleanup");
306
315
 
307
316
  const authSeedProfileDir = expectSafeProfileDir(browser.authSeedProfileDir, "browser.authSeedProfileDir");
308
317
  const runtimeProfilesDir = expectSafeProfileDir(browser.runtimeProfilesDir, "browser.runtimeProfilesDir");
@@ -345,6 +354,10 @@ function validateOracleConfig(value: unknown): OracleConfig {
345
354
  artifacts: {
346
355
  capture: expectBoolean(artifacts.capture, "artifacts.capture"),
347
356
  },
357
+ cleanup: {
358
+ completeJobRetentionMs: expectInteger(cleanup.completeJobRetentionMs, "cleanup.completeJobRetentionMs", 0),
359
+ failedJobRetentionMs: expectInteger(cleanup.failedJobRetentionMs, "cleanup.failedJobRetentionMs", 0),
360
+ },
348
361
  };
349
362
  }
350
363
 
@@ -1,12 +1,12 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { spawn, execFileSync } from "node:child_process";
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
- import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
7
7
  import type { OracleConfig, OracleEffort, OracleModelFamily } from "./config.js";
8
8
  import { withJobLock } from "./locks.js";
9
- import { cleanupRuntimeArtifacts, getProjectId, getSessionId, parseConversationId } from "./runtime.js";
9
+ import { cleanupRuntimeArtifacts, getProjectId, getSessionId, parseConversationId, type OracleCleanupReport } from "./runtime.js";
10
10
 
11
11
  export type OracleJobStatus = "preparing" | "submitted" | "waiting" | "complete" | "failed" | "cancelled";
12
12
  export type OracleJobPhase =
@@ -28,6 +28,8 @@ export const ACTIVE_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["preparing", "subm
28
28
  export const ORACLE_MISSING_WORKER_GRACE_MS = 30_000;
29
29
  export const ORACLE_STALE_HEARTBEAT_MS = 3 * 60 * 1000;
30
30
  export const ORACLE_NOTIFICATION_CLAIM_TTL_MS = 60_000;
31
+ const ORACLE_COMPLETE_JOB_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
32
+ const ORACLE_FAILED_JOB_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
31
33
 
32
34
  export function isActiveOracleJob(job: Pick<OracleJob, "status">): boolean {
33
35
  return ACTIVE_ORACLE_JOB_STATUSES.includes(job.status);
@@ -119,6 +121,8 @@ export interface OracleJob {
119
121
  runtimeProfileDir: string;
120
122
  seedGeneration?: string;
121
123
  config: OracleConfig;
124
+ cleanupWarnings?: string[];
125
+ lastCleanupAt?: string;
122
126
  }
123
127
 
124
128
  export interface OracleSubmitInput {
@@ -301,13 +305,63 @@ export function getStaleOracleJobReason(job: OracleJob, now = Date.now()): strin
301
305
  return undefined;
302
306
  }
303
307
 
304
- async function cleanupJobResources(job: OracleJob): Promise<void> {
305
- await cleanupRuntimeArtifacts({
308
+ export async function cleanupJobResources(
309
+ job: Pick<OracleJob, "runtimeId" | "runtimeProfileDir" | "runtimeSessionName" | "conversationId">,
310
+ ): Promise<OracleCleanupReport> {
311
+ return cleanupRuntimeArtifacts({
306
312
  runtimeId: job.runtimeId,
307
313
  runtimeProfileDir: job.runtimeProfileDir,
308
314
  runtimeSessionName: job.runtimeSessionName,
309
315
  conversationId: job.conversationId,
310
- }).catch(() => undefined);
316
+ });
317
+ }
318
+
319
+ function getCleanupRetentionMs(job: OracleJob): { complete: number; failed: number } {
320
+ return {
321
+ complete: job.config.cleanup?.completeJobRetentionMs ?? ORACLE_COMPLETE_JOB_RETENTION_MS,
322
+ failed: job.config.cleanup?.failedJobRetentionMs ?? ORACLE_FAILED_JOB_RETENTION_MS,
323
+ };
324
+ }
325
+
326
+ function shouldPruneTerminalJob(job: OracleJob, now = Date.now()): boolean {
327
+ if (!isTerminalOracleJobStatus(job.status)) return false;
328
+ const completedMs = parseTimestamp(job.completedAt) ?? parseTimestamp(job.createdAt);
329
+ if (completedMs === undefined) return false;
330
+ const ageMs = now - completedMs;
331
+
332
+ const retention = getCleanupRetentionMs(job);
333
+
334
+ if ((job.status === "complete" || job.status === "cancelled") && job.notifiedAt) {
335
+ return ageMs >= retention.complete;
336
+ }
337
+
338
+ if (job.status === "failed") {
339
+ return ageMs >= retention.failed;
340
+ }
341
+
342
+ return false;
343
+ }
344
+
345
+ export async function removeTerminalOracleJob(job: OracleJob): Promise<{ removed: boolean; cleanupReport: OracleCleanupReport }> {
346
+ if (isActiveOracleJob(job)) return { removed: false, cleanupReport: { attempted: [], warnings: [] } };
347
+ const cleanupReport = await cleanupJobResources(job);
348
+ await rm(getJobDir(job.id), { recursive: true, force: true });
349
+ return { removed: true, cleanupReport };
350
+ }
351
+
352
+ export async function pruneTerminalOracleJobs(now = Date.now()): Promise<string[]> {
353
+ const removedJobIds: string[] = [];
354
+
355
+ for (const jobDir of listOracleJobDirs()) {
356
+ const job = readJob(jobDir);
357
+ if (!job || !shouldPruneTerminalJob(job, now)) continue;
358
+ const removed = await removeTerminalOracleJob(job);
359
+ if (removed.removed) {
360
+ removedJobIds.push(job.id);
361
+ }
362
+ }
363
+
364
+ return removedJobIds;
311
365
  }
312
366
 
313
367
  export async function reconcileStaleOracleJobs(): Promise<OracleJob[]> {
@@ -340,7 +394,15 @@ export async function reconcileStaleOracleJobs(): Promise<OracleJob[]> {
340
394
  : `Recovered stale job: ${staleReason}.${suffix}`.trim(),
341
395
  }, new Date(now).toISOString()),
342
396
  }));
343
- await cleanupJobResources(repairedJob);
397
+ const cleanupReport = await cleanupJobResources(repairedJob);
398
+ if (cleanupReport.warnings.length > 0) {
399
+ await updateJob(repairedJob.id, (current) => ({
400
+ ...current,
401
+ cleanupWarnings: [...(current.cleanupWarnings || []), ...cleanupReport.warnings],
402
+ lastCleanupAt: new Date(now).toISOString(),
403
+ error: [current.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
404
+ }));
405
+ }
344
406
  repaired.push(repairedJob);
345
407
  }
346
408
 
@@ -425,8 +487,15 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
425
487
  error: terminated ? reason : `${reason}; worker PID ${job.workerPid ?? "unknown"} did not exit`,
426
488
  }, now),
427
489
  }));
428
- await cleanupJobResources(cancelled);
429
- return cancelled;
490
+ const cleanupReport = await cleanupJobResources(cancelled);
491
+ if (cleanupReport.warnings.length === 0) return cancelled;
492
+
493
+ return updateJob(id, (job) => ({
494
+ ...job,
495
+ cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupReport.warnings],
496
+ lastCleanupAt: now,
497
+ error: [job.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
498
+ }));
430
499
  }
431
500
 
432
501
  export async function createJob(
@@ -83,6 +83,20 @@ async function maybeReclaimStaleLock(path: string): Promise<boolean> {
83
83
  return true;
84
84
  }
85
85
 
86
+ export async function sweepStaleLocks(): Promise<string[]> {
87
+ const dir = getLocksDir();
88
+ const removed: string[] = [];
89
+
90
+ for (const name of readdirSync(dir)) {
91
+ const path = join(dir, name);
92
+ if (await maybeReclaimStaleLock(path)) {
93
+ removed.push(path);
94
+ }
95
+ }
96
+
97
+ return removed;
98
+ }
99
+
86
100
  export async function acquireLock(
87
101
  kind: string,
88
102
  key: string,
@@ -143,6 +157,7 @@ export async function withGlobalReconcileLock<T>(
143
157
  fn: () => Promise<T>,
144
158
  options?: { timeoutMs?: number },
145
159
  ): Promise<T> {
160
+ await sweepStaleLocks();
146
161
  return withLock("reconcile", "global", metadata, fn, { timeoutMs: options?.timeoutMs ?? 30_000 });
147
162
  }
148
163
 
@@ -172,24 +172,78 @@ export async function cloneSeedProfileToRuntime(config: OracleConfig, runtimePro
172
172
  return getSeedGeneration(config);
173
173
  }
174
174
 
175
+ const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
176
+
177
+ export interface OracleCleanupReport {
178
+ attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease">;
179
+ warnings: string[];
180
+ }
181
+
182
+ async function closeRuntimeBrowserSession(runtimeSessionName: string): Promise<string | undefined> {
183
+ return new Promise<string | undefined>((resolve) => {
184
+ const child = spawn("agent-browser", ["--session", runtimeSessionName, "close"], { stdio: "ignore" });
185
+ let settled = false;
186
+ let timeout: NodeJS.Timeout | undefined;
187
+ let timedOut = false;
188
+
189
+ const finish = (warning?: string) => {
190
+ if (settled) return;
191
+ settled = true;
192
+ if (timeout) clearTimeout(timeout);
193
+ resolve(warning);
194
+ };
195
+
196
+ timeout = setTimeout(() => {
197
+ timedOut = true;
198
+ child.kill("SIGTERM");
199
+ setTimeout(() => {
200
+ child.kill("SIGKILL");
201
+ finish(`Timed out closing agent-browser session ${runtimeSessionName} after ${AGENT_BROWSER_CLOSE_TIMEOUT_MS}ms`);
202
+ }, 2_000).unref?.();
203
+ }, AGENT_BROWSER_CLOSE_TIMEOUT_MS);
204
+ timeout.unref?.();
205
+
206
+ child.on("error", (error) => finish(`Failed to close agent-browser session ${runtimeSessionName}: ${error.message}`));
207
+ child.on("close", (code) => {
208
+ if (timedOut || code === 0) finish();
209
+ else finish(`agent-browser close exited with code ${code} for session ${runtimeSessionName}`);
210
+ });
211
+ });
212
+ }
213
+
175
214
  export async function cleanupRuntimeArtifacts(runtime: {
176
215
  runtimeId?: string;
177
216
  runtimeProfileDir?: string;
178
217
  runtimeSessionName?: string;
179
218
  conversationId?: string;
180
- }): Promise<void> {
219
+ }): Promise<OracleCleanupReport> {
220
+ const report: OracleCleanupReport = { attempted: [], warnings: [] };
221
+
181
222
  if (runtime.runtimeSessionName) {
182
- await new Promise<void>((resolve) => {
183
- const child = spawn("agent-browser", ["--session", runtime.runtimeSessionName, "close"], { stdio: "ignore" });
184
- child.on("error", () => resolve());
185
- child.on("close", () => resolve());
186
- });
223
+ report.attempted.push("browser");
224
+ const warning = await closeRuntimeBrowserSession(runtime.runtimeSessionName).catch((error: Error) => error.message);
225
+ if (warning) report.warnings.push(warning);
187
226
  }
188
227
  if (runtime.runtimeProfileDir) {
189
- await rm(runtime.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
228
+ report.attempted.push("runtimeProfileDir");
229
+ await rm(runtime.runtimeProfileDir, { recursive: true, force: true }).catch((error: Error) => {
230
+ report.warnings.push(`Failed to remove runtime profile ${runtime.runtimeProfileDir}: ${error.message}`);
231
+ });
232
+ }
233
+ if (runtime.conversationId) {
234
+ report.attempted.push("conversationLease");
235
+ }
236
+ await releaseConversationLease(runtime.conversationId).catch((error: Error) => {
237
+ report.warnings.push(`Failed to release conversation lease ${runtime.conversationId}: ${error.message}`);
238
+ });
239
+ if (runtime.runtimeId) {
240
+ report.attempted.push("runtimeLease");
190
241
  }
191
- await releaseConversationLease(runtime.conversationId);
192
- await releaseRuntimeLease(runtime.runtimeId);
242
+ await releaseRuntimeLease(runtime.runtimeId).catch((error: Error) => {
243
+ report.warnings.push(`Failed to release runtime lease ${runtime.runtimeId}: ${error.message}`);
244
+ });
245
+
246
+ return report;
193
247
  }
194
248
 
195
249
  export function stableProjectLabel(projectId: string): string {
@@ -13,6 +13,7 @@ import {
13
13
  getSessionFile,
14
14
  isActiveOracleJob,
15
15
  readJob,
16
+ pruneTerminalOracleJobs,
16
17
  reconcileStaleOracleJobs,
17
18
  resolveArchiveInputs,
18
19
  sha256File,
@@ -196,6 +197,8 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
196
197
  artifactsManifestPath: job.artifactsManifestPath,
197
198
  archiveDeletedAfterUpload: job.archiveDeletedAfterUpload,
198
199
  runtimeId: job.runtimeId,
200
+ cleanupWarnings: job.cleanupWarnings,
201
+ lastCleanupAt: job.lastCleanupAt,
199
202
  error: job.error,
200
203
  };
201
204
  }
@@ -231,6 +234,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
231
234
  try {
232
235
  await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
233
236
  await reconcileStaleOracleJobs();
237
+ await pruneTerminalOracleJobs();
234
238
  });
235
239
  } catch (error) {
236
240
  if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
@@ -1,10 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { appendFile, chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { appendFile, chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
- import { dirname, join, resolve } from "node:path";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
7
  import { getCookies } from "@steipete/sweet-cookie";
8
+ import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
8
9
 
9
10
  const rawConfig = process.argv[2];
10
11
  if (!rawConfig) {
@@ -34,6 +35,7 @@ const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
34
35
  const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
35
36
  const ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
36
37
  const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
38
+ const STALE_STAGING_PROFILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
37
39
 
38
40
  let runtimeProfileDir = config.browser.authSeedProfileDir;
39
41
 
@@ -179,6 +181,42 @@ async function ensureNotSymlink(path, label) {
179
181
  }
180
182
  }
181
183
 
184
+ async function isAuthBrowserConnected() {
185
+ const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
186
+ try {
187
+ const parsed = JSON.parse(result.stdout || "{}");
188
+ return parsed?.data?.connected === true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ async function sweepStaleStagingProfiles(targetDir) {
195
+ const parentDir = dirname(targetDir);
196
+ const prefix = `${basename(targetDir)}.staging-`;
197
+ const now = Date.now();
198
+
199
+ if (await isAuthBrowserConnected()) {
200
+ await log(`Skipping stale staging-profile sweep while auth browser session ${authSessionName()} is still connected`);
201
+ return;
202
+ }
203
+
204
+ const names = await readdir(parentDir).catch(() => []);
205
+ for (const name of names) {
206
+ if (!name.startsWith(prefix)) continue;
207
+ const candidatePath = join(parentDir, name);
208
+ try {
209
+ const stats = await stat(candidatePath);
210
+ if (!stats.isDirectory()) continue;
211
+ if (now - stats.mtimeMs < STALE_STAGING_PROFILE_MAX_AGE_MS) continue;
212
+ await rm(candidatePath, { recursive: true, force: true });
213
+ await log(`Removed stale auth staging profile ${candidatePath}`);
214
+ } catch (error) {
215
+ await log(`Failed to remove stale auth staging profile ${candidatePath}: ${error instanceof Error ? error.message : String(error)}`);
216
+ }
217
+ }
218
+ }
219
+
182
220
  async function createProfilePlan(profileDir) {
183
221
  const targetDir = resolve(profileDir);
184
222
  if (!targetDir.startsWith("/")) {
@@ -197,6 +235,7 @@ async function createProfilePlan(profileDir) {
197
235
  await ensureNotSymlink(dirname(targetDir), "Oracle profile parent directory");
198
236
  await ensureNotSymlink(targetDir, "Oracle profile directory");
199
237
  await ensureNotSymlink(backupDir, "Oracle backup profile directory");
238
+ await sweepStaleStagingProfiles(targetDir);
200
239
  return { targetDir, stagingDir, backupDir };
201
240
  }
202
241
 
@@ -367,42 +406,6 @@ function stripQuery(url) {
367
406
  }
368
407
  }
369
408
 
370
- function normalizeSameSite(value) {
371
- if (value === "Lax" || value === "Strict" || value === "None") return value;
372
- return undefined;
373
- }
374
-
375
- function normalizeExpiration(expires) {
376
- if (!expires || Number.isNaN(expires)) return undefined;
377
- const value = Number(expires);
378
- if (!Number.isFinite(value) || value <= 0) return undefined;
379
- // Chrome cookie readers can surface expiries in a few formats:
380
- // - Unix seconds (~1.7e9 in 2026)
381
- // - Unix milliseconds (~1.7e12)
382
- // - WebKit microseconds since 1601 (~1.3e16)
383
- if (value > 10_000_000_000_000) return Math.round(value / 1_000_000 - 11644473600);
384
- if (value > 10_000_000_000) return Math.round(value / 1000);
385
- return Math.round(value);
386
- }
387
-
388
- function normalizeCookie(cookie, fallbackHost) {
389
- if (!cookie?.name) return undefined;
390
- const domain = typeof cookie.domain === "string" && cookie.domain.trim() ? cookie.domain.trim() : fallbackHost;
391
- if (!domain) return undefined;
392
-
393
- const expires = normalizeExpiration(cookie.expires);
394
- return {
395
- name: cookie.name,
396
- value: cookie.value ?? "",
397
- domain,
398
- path: cookie.path || "/",
399
- expires,
400
- httpOnly: cookie.httpOnly ?? false,
401
- secure: cookie.secure ?? true,
402
- sameSite: normalizeSameSite(cookie.sameSite),
403
- };
404
- }
405
-
406
409
  function cookieOrigins() {
407
410
  return Array.from(new Set([stripQuery(config.browser.chatUrl), ...CHATGPT_COOKIE_ORIGINS]));
408
411
  }
@@ -432,23 +435,20 @@ async function readSourceCookies() {
432
435
  await log(`sweet-cookie warnings: ${warnings.join(" | ")}`);
433
436
  }
434
437
 
435
- const fallbackHost = new URL(config.browser.chatUrl).hostname;
436
- const merged = new Map();
437
- for (const cookie of cookies) {
438
- const normalized = normalizeCookie(cookie, fallbackHost);
439
- if (!normalized) continue;
440
- const key = `${normalized.domain}:${normalized.name}`;
441
- if (!merged.has(key)) merged.set(key, normalized);
442
- }
443
-
444
- const normalizedCookies = Array.from(merged.values());
438
+ const filtered = filterImportableAuthCookies(cookies, config.browser.chatUrl);
439
+ let normalizedCookies = filtered.cookies;
445
440
  await log(
446
- `Read ${normalizedCookies.length} merged cookies: ${normalizedCookies.map((cookie) => `${cookie.name}@${cookie.domain}`).join(", ")}`,
441
+ `Read ${normalizedCookies.length} filtered auth cookies: ${normalizedCookies.map((cookie) => `${cookie.name}@${cookie.domain}`).join(", ")}`,
447
442
  );
443
+ if (filtered.dropped.length) {
444
+ await log(
445
+ `Dropped ${filtered.dropped.length} non-importable cookies: ` +
446
+ filtered.dropped.map(({ cookie, reason }) => `${cookie.name}@${cookie.domain}(${reason})`).join(", "),
447
+ );
448
+ }
448
449
 
449
450
  const hasSessionToken = normalizedCookies.some((cookie) => cookie.name.startsWith("__Secure-next-auth.session-token"));
450
451
  const hasAccountCookie = normalizedCookies.some((cookie) => cookie.name === "_account");
451
- const fedrampCookie = normalizedCookies.find((cookie) => cookie.name === "_account_is_fedramp");
452
452
  await log(`Cookie presence: sessionToken=${hasSessionToken} account=${hasAccountCookie}`);
453
453
 
454
454
  if (!hasSessionToken) {
@@ -458,18 +458,11 @@ async function readSourceCookies() {
458
458
  }
459
459
 
460
460
  if (!hasAccountCookie) {
461
- const isFedramp = /^(1|true|yes)$/i.test(String(fedrampCookie?.value || ""));
462
- const fallbackAccountValue = isFedramp ? "fedramp" : "personal";
463
- normalizedCookies.push({
464
- name: "_account",
465
- value: fallbackAccountValue,
466
- domain: new URL(config.browser.chatUrl).hostname,
467
- path: "/",
468
- secure: true,
469
- httpOnly: false,
470
- sameSite: "Lax",
471
- });
472
- await log(`Synthesized missing _account cookie with value=${fallbackAccountValue}`);
461
+ const ensured = ensureAccountCookie(normalizedCookies, config.browser.chatUrl);
462
+ normalizedCookies = ensured.cookies;
463
+ if (ensured.synthesized) {
464
+ await log(`Synthesized missing _account cookie with value=${ensured.value}`);
465
+ }
473
466
  }
474
467
 
475
468
  return normalizedCookies;
@@ -658,6 +651,15 @@ function classifyChatPage({ url, snapshot, body, probe }) {
658
651
  };
659
652
  }
660
653
 
654
+ if (/http error 431|request header or cookie too large/i.test(text)) {
655
+ return {
656
+ state: "login_required",
657
+ message:
658
+ `Imported auth hit HTTP 431 during ChatGPT auth resolution, which usually means the imported cookie set is too large or stale. ` +
659
+ `Inspect ${LOG_PATH}.`,
660
+ };
661
+ }
662
+
661
663
  const outagePatterns = [
662
664
  /something went wrong/i,
663
665
  /a network error occurred/i,
@@ -0,0 +1,155 @@
1
+ const AUTH_COOKIE_NAME_PATTERNS = [
2
+ /^__Secure-next-auth\.session-token(?:\.|$)/,
3
+ /^__Secure-next-auth\.callback-url$/,
4
+ /^_account$/,
5
+ /^_account_is_fedramp$/,
6
+ /^_puid$/,
7
+ /^unified_session_manifest$/,
8
+ /^oai-(?:client-auth-info|client-auth-session|sc|did|hlib|asli|last-model-config|chat-web-route)$/,
9
+ /^auth-session-minimized(?:-client-checksum)?$/,
10
+ /^(?:login_session|auth_provider|hydra_redirect|iss_context|rg_context)$/,
11
+ /^cf_clearance$/,
12
+ ];
13
+
14
+ const DROPPED_COOKIE_NAME_PATTERNS = [
15
+ /^_ga(?:_|$)/,
16
+ /^_uet/,
17
+ /^_rdt_uuid$/,
18
+ /^(?:marketing|analytics)_consent$/,
19
+ /^__cf_bm$/,
20
+ /^__cflb$/,
21
+ /^_cfuvid$/,
22
+ /^_dd_s$/,
23
+ /^g_state$/,
24
+ /^country$/,
25
+ /^oai-nav-state$/,
26
+ /^oai-login-csrf/,
27
+ /^__Secure-next-auth\.state$/,
28
+ /^__Host-next-auth\.csrf-token$/,
29
+ ];
30
+
31
+ const BASE_ALLOWED_COOKIE_HOSTS = new Set([
32
+ 'chatgpt.com',
33
+ 'chat.openai.com',
34
+ 'openai.com',
35
+ 'auth.openai.com',
36
+ 'sentinel.openai.com',
37
+ 'atlas.openai.com',
38
+ 'ws.chatgpt.com',
39
+ ]);
40
+
41
+ function normalizeSameSite(value) {
42
+ if (value === 'Lax' || value === 'Strict' || value === 'None') return value;
43
+ return undefined;
44
+ }
45
+
46
+ function normalizeExpiration(expires) {
47
+ if (!expires || Number.isNaN(expires)) return undefined;
48
+ const value = Number(expires);
49
+ if (!Number.isFinite(value) || value <= 0) return undefined;
50
+ if (value > 10_000_000_000_000) return Math.round(value / 1_000_000 - 11644473600);
51
+ if (value > 10_000_000_000) return Math.round(value / 1000);
52
+ return Math.round(value);
53
+ }
54
+
55
+ function normalizeDomain(domain, fallbackHost) {
56
+ const raw = typeof domain === 'string' && domain.trim() ? domain.trim() : fallbackHost;
57
+ if (!raw) return undefined;
58
+ return raw.replace(/^\.+/, '').toLowerCase();
59
+ }
60
+
61
+ function allowedCookieHosts(chatUrl) {
62
+ const hosts = new Set(BASE_ALLOWED_COOKIE_HOSTS);
63
+ try {
64
+ hosts.add(new URL(chatUrl).hostname.toLowerCase());
65
+ } catch {
66
+ // ignore invalid URL here; caller validation happens elsewhere
67
+ }
68
+ return hosts;
69
+ }
70
+
71
+ function isAllowedCookieDomain(domain, chatUrl) {
72
+ const hosts = allowedCookieHosts(chatUrl);
73
+ return hosts.has(domain);
74
+ }
75
+
76
+ function matchesAny(patterns, value) {
77
+ return patterns.some((pattern) => pattern.test(value));
78
+ }
79
+
80
+ export function normalizeImportedCookie(cookie, fallbackHost) {
81
+ if (!cookie?.name) return undefined;
82
+ const domain = normalizeDomain(cookie.domain, fallbackHost);
83
+ if (!domain) return undefined;
84
+ return {
85
+ name: cookie.name,
86
+ value: cookie.value ?? '',
87
+ domain,
88
+ path: cookie.path || '/',
89
+ expires: normalizeExpiration(cookie.expires),
90
+ httpOnly: cookie.httpOnly ?? false,
91
+ secure: cookie.secure ?? true,
92
+ sameSite: normalizeSameSite(cookie.sameSite),
93
+ };
94
+ }
95
+
96
+ export function classifyImportedCookie(cookie, chatUrl) {
97
+ if (matchesAny(DROPPED_COOKIE_NAME_PATTERNS, cookie.name)) return 'noise';
98
+ if (!isAllowedCookieDomain(cookie.domain, chatUrl)) return 'foreign-domain';
99
+ if (!matchesAny(AUTH_COOKIE_NAME_PATTERNS, cookie.name)) return 'non-auth';
100
+ return 'keep';
101
+ }
102
+
103
+ export function filterImportableAuthCookies(cookies, chatUrl) {
104
+ const fallbackHost = (() => {
105
+ try {
106
+ return new URL(chatUrl).hostname;
107
+ } catch {
108
+ return 'chatgpt.com';
109
+ }
110
+ })();
111
+
112
+ const merged = new Map();
113
+ const dropped = [];
114
+ for (const cookie of cookies) {
115
+ const normalized = normalizeImportedCookie(cookie, fallbackHost);
116
+ if (!normalized) continue;
117
+ const disposition = classifyImportedCookie(normalized, chatUrl);
118
+ if (disposition !== 'keep') {
119
+ dropped.push({ cookie: normalized, reason: disposition });
120
+ continue;
121
+ }
122
+ const key = `${normalized.domain}:${normalized.name}`;
123
+ if (!merged.has(key)) merged.set(key, normalized);
124
+ }
125
+
126
+ return { cookies: Array.from(merged.values()), dropped };
127
+ }
128
+
129
+ export function ensureAccountCookie(cookies, chatUrl) {
130
+ const next = [...cookies];
131
+ const hasAccountCookie = next.some((cookie) => cookie.name === '_account');
132
+ if (hasAccountCookie) return { cookies: next, synthesized: false };
133
+
134
+ const fedrampCookie = next.find((cookie) => cookie.name === '_account_is_fedramp');
135
+ const isFedramp = /^(1|true|yes)$/i.test(String(fedrampCookie?.value || ''));
136
+ const fallbackAccountValue = isFedramp ? 'fedramp' : 'personal';
137
+ const domain = (() => {
138
+ try {
139
+ return new URL(chatUrl).hostname;
140
+ } catch {
141
+ return 'chatgpt.com';
142
+ }
143
+ })();
144
+
145
+ next.push({
146
+ name: '_account',
147
+ value: fallbackAccountValue,
148
+ domain,
149
+ path: '/',
150
+ secure: true,
151
+ httpOnly: false,
152
+ sameSite: 'Lax',
153
+ });
154
+ return { cookies: next, synthesized: true, value: fallbackAccountValue };
155
+ }
@@ -36,6 +36,7 @@ const ARTIFACT_CANDIDATE_STABILITY_POLLS = 2;
36
36
  const ARTIFACT_DOWNLOAD_HEARTBEAT_MS = 10_000;
37
37
  const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000;
38
38
  const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 2;
39
+ const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
39
40
 
40
41
  let currentJob;
41
42
  let browserStarted = false;
@@ -272,11 +273,33 @@ async function cloneSeedProfileToRuntime(job) {
272
273
  async function cleanupRuntime(job) {
273
274
  if (!job || cleaningUpRuntime) return;
274
275
  cleaningUpRuntime = true;
276
+ const warnings = [];
275
277
  try {
276
- await closeBrowser(job).catch(() => undefined);
277
- await releaseLease("conversation", job.conversationId).catch(() => undefined);
278
- await releaseLease("runtime", job.runtimeId).catch(() => undefined);
279
- await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
278
+ await closeBrowser(job).catch(async (error) => {
279
+ const message = `Browser close warning during cleanup: ${error instanceof Error ? error.message : String(error)}`;
280
+ warnings.push(message);
281
+ await log(message).catch(() => undefined);
282
+ });
283
+ await releaseLease("conversation", job.conversationId).catch(async (error) => {
284
+ const message = `Conversation lease cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
285
+ warnings.push(message);
286
+ await log(message).catch(() => undefined);
287
+ });
288
+ await releaseLease("runtime", job.runtimeId).catch(async (error) => {
289
+ const message = `Runtime lease cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
290
+ warnings.push(message);
291
+ await log(message).catch(() => undefined);
292
+ });
293
+ await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(async (error) => {
294
+ const message = `Runtime profile cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
295
+ warnings.push(message);
296
+ await log(message).catch(() => undefined);
297
+ });
298
+ if (warnings.length === 0) {
299
+ await log(`Cleanup summary: runtime ${job.runtimeId} released with no warnings`).catch(() => undefined);
300
+ } else {
301
+ await log(`Cleanup summary: runtime ${job.runtimeId} released with ${warnings.length} warning(s)`).catch(() => undefined);
302
+ }
280
303
  } finally {
281
304
  cleaningUpRuntime = false;
282
305
  }
@@ -298,7 +321,13 @@ async function closeBrowser(job) {
298
321
  if (cleaningUpBrowser) return;
299
322
  cleaningUpBrowser = true;
300
323
  try {
301
- await spawnCommand("agent-browser", [...browserBaseArgs(job), "close"], { allowFailure: true });
324
+ const result = await spawnCommand("agent-browser", [...browserBaseArgs(job), "close"], {
325
+ allowFailure: true,
326
+ timeoutMs: AGENT_BROWSER_CLOSE_TIMEOUT_MS,
327
+ });
328
+ if (result.code !== 0) {
329
+ throw new Error(result.stderr || result.stdout || `agent-browser close exited with code ${result.code}`);
330
+ }
302
331
  } finally {
303
332
  browserStarted = false;
304
333
  cleaningUpBrowser = false;
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
8
8
  "type": "module",
9
- "keywords": ["pi-package", "pi", "pi-extension", "extension", "chatgpt", "oracle"],
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi",
12
+ "pi-extension",
13
+ "extension",
14
+ "chatgpt",
15
+ "oracle"
16
+ ],
10
17
  "repository": {
11
18
  "type": "git",
12
19
  "url": "git+https://github.com/fitchmultz/pi-oracle.git"
@@ -24,10 +31,12 @@
24
31
  "LICENSE"
25
32
  ],
26
33
  "pi": {
27
- "extensions": ["./extensions/oracle/index.ts"]
34
+ "extensions": [
35
+ "./extensions/oracle/index.ts"
36
+ ]
28
37
  },
29
38
  "scripts": {
30
- "check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
39
+ "check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
31
40
  "sanity:oracle": "tsx scripts/oracle-sanity.ts",
32
41
  "pack:check": "npm pack --dry-run"
33
42
  },