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 +9 -0
- package/extensions/oracle/index.ts +16 -0
- package/extensions/oracle/lib/commands.ts +31 -7
- package/extensions/oracle/lib/config.ts +14 -1
- package/extensions/oracle/lib/jobs.ts +77 -8
- package/extensions/oracle/lib/locks.ts +15 -0
- package/extensions/oracle/lib/runtime.ts +63 -9
- package/extensions/oracle/lib/tools.ts +4 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +64 -62
- package/extensions/oracle/worker/auth-cookie-policy.mjs +155 -0
- package/extensions/oracle/worker/run-job.mjs +34 -5
- package/package.json +13 -4
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 {
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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(
|
|
305
|
-
|
|
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
|
-
})
|
|
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<
|
|
219
|
+
}): Promise<OracleCleanupReport> {
|
|
220
|
+
const report: OracleCleanupReport = { attempted: [], warnings: [] };
|
|
221
|
+
|
|
181
222
|
if (runtime.runtimeSessionName) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
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
|
|
436
|
-
|
|
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}
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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(() =>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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"], {
|
|
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
|
+
"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": [
|
|
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": [
|
|
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
|
},
|