pi-oracle 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.15 - 2026-05-03
6
+
7
+ ### Fixed
8
+ - anchored the `oracle_submit.files[]` schema pattern so OpenAI-compatible parsers that require anchored JSON Schema regexes, including llama.cpp, can load the oracle tools
9
+ - made background poller queued-job promotion best-effort under normal global admission-lock contention, avoiding noisy scan failures when multiple pi sessions are live
10
+ - added configured Chromium-family cookie source support for `/oracle-auth`, including macOS Keychain decryption for browser cookie stores not handled by `@steipete/sweet-cookie`
11
+
12
+ ## 0.6.14 - 2026-05-02
13
+
14
+ ### Fixed
15
+ - deduped oracle completion wake-ups by marking one-time best-effort delivery in job state before the poller sends the follow-up turn, preventing repeated identical completion notifications across poller scans
16
+ - clarified completion wake-up guidance so agents treat the wake-up as a read/inspect prompt rather than an automatic auth refresh or resubmission instruction
17
+
18
+ ### Changed
19
+ - `oracle_read` and `/oracle-status` summaries for active jobs now include elapsed time, phase elapsed time, and a poll/backoff hint so manual checks waste fewer turns
20
+ - `oracle_submit` preset schema and prompt guidance now list the canonical preset ids directly for tool callers
21
+
5
22
  ## 0.6.13 - 2026-05-01
6
23
 
7
24
  ### Changed
package/README.md CHANGED
@@ -9,7 +9,7 @@ Use it when you want:
9
9
  - async background execution
10
10
  - durable saved responses/artifacts plus best-effort wake-ups back into `pi`
11
11
 
12
- Normal oracle jobs run in an isolated browser profile, not your active Chrome window.
12
+ Normal oracle jobs run in an isolated browser profile, not your active browser window.
13
13
 
14
14
  > Status: experimental public beta. Validated primarily on macOS with Google Chrome and `pi` 0.65.0+.
15
15
 
@@ -43,12 +43,12 @@ pi install https://github.com/fitchmultz/pi-oracle
43
43
  ## Quickstart
44
44
 
45
45
  1. Start a normal persisted `pi` session. Do not use `pi --no-session` for oracle.
46
- 2. Make sure ChatGPT already works in your local Chrome profile.
46
+ 2. Make sure ChatGPT already works in your configured local browser profile.
47
47
  3. Make sure these are installed: Google Chrome, `agent-browser`, `tar`, and `zstd`.
48
48
  4. Optional: create `~/.pi/agent/extensions/oracle.json` if you want non-default settings.
49
49
  5. Run `/oracle-auth`.
50
50
  6. Run `/oracle Review the current pending changes. Include the whole repo unless a narrower archive is clearly better.`
51
- 7. Wait for a best-effort wake-up, or check `/oracle-status`.
51
+ 7. Wait for the one-time best-effort wake-up, or check `/oracle-status`.
52
52
 
53
53
  The `/oracle` prompt now runs an early oracle preflight before it gathers repo context, so missing persisted-session or local auth/config blockers fail before the agent spends time reading files.
54
54
 
@@ -56,7 +56,7 @@ For explicitly narrow requests, `/oracle` should still prefer a context-rich rel
56
56
 
57
57
  If a local archive still exceeds the 250 MB limit after default exclusions and automatic whole-repo pruning, the agent should treat that as a retryable archive-selection failure: shrink the archive automatically, retry with a smaller relevant slice, and explain what it cut only if it still cannot fit after the allowed retry budget.
58
58
 
59
- If you miss the wake-up, the result is still saved durably in the oracle job directory and can be read later.
59
+ If you miss the one-time wake-up, the result is still saved durably in the oracle job directory and can be read later.
60
60
 
61
61
  ## Example requests
62
62
 
@@ -87,7 +87,7 @@ flowchart LR
87
87
  C --> D["Detached worker starts isolated ChatGPT runtime"]
88
88
  D --> E["Archive + prompt sent to ChatGPT.com"]
89
89
  E --> F["Response/artifacts saved under oracle job dir"]
90
- F --> G["Best-effort wake-up to matching pi session"]
90
+ F --> G["One-time best-effort wake-up to matching pi session"]
91
91
  ```
92
92
 
93
93
  If concurrency is full, the job is queued and starts automatically later.
@@ -97,7 +97,7 @@ If concurrency is full, the job is queued and starts automatically later.
97
97
  User-facing commands:
98
98
  - `/oracle <request>` — prompt template that tells the agent to gather context and dispatch an oracle job
99
99
  - `/oracle-followup <job-id> <request>` — prompt template that continues an earlier oracle job in the same ChatGPT thread
100
- - `/oracle-auth` — sync ChatGPT cookies from your real Chrome profile into the isolated oracle auth profile
100
+ - `/oracle-auth` — sync ChatGPT cookies from your configured local browser profile into the isolated oracle auth profile
101
101
  - `/oracle-read [job-id]` — inspect job status plus the saved response preview
102
102
  - `/oracle-status [job-id]` — inspect job status and list recent job ids when no explicit id is given
103
103
  - `/oracle-cancel <job-id>` — cancel a queued or active job by id
@@ -112,7 +112,7 @@ Agent-facing tools:
112
112
 
113
113
  ## Minimal config
114
114
 
115
- Most users can start with the packaged defaults and only set the Chrome profile if needed.
115
+ Most users can start with the packaged defaults and only set the browser profile if needed.
116
116
 
117
117
  `~/.pi/agent/extensions/oracle.json`
118
118
 
@@ -133,6 +133,41 @@ Notes:
133
133
  - If the packaged default is fine, you can omit `defaults.preset` entirely.
134
134
  - You usually do not need to set browser paths unless auto-detection fails.
135
135
 
136
+ ### Custom Chromium cookie sources
137
+
138
+ Most Chrome-compatible browsers should work through the default cookie importer. Use this alternate path only for a Chromium-family browser that is not one of `@steipete/sweet-cookie`'s built-in Chrome/Brave/Arc/Chromium targets or otherwise cannot import cookies without dependency patching.
139
+
140
+ Before running `/oracle-auth` with this path:
141
+
142
+ 1. Log into ChatGPT in the target browser profile.
143
+ 2. Fully quit the browser so its `Cookies` database is stable.
144
+ 3. Find the profile `Cookies` SQLite DB path.
145
+ 4. Find the browser's macOS Keychain safe-storage item account and service name.
146
+ 5. Configure all of `browser.executablePath`, `auth.chromeCookiePath`, and `auth.chromiumKeychain` in the agent-level config at `~/.pi/agent/extensions/oracle.json`.
147
+
148
+ Example Helium config:
149
+
150
+ ```json
151
+ {
152
+ "browser": {
153
+ "executablePath": "/Applications/Helium.app/Contents/MacOS/Helium"
154
+ },
155
+ "auth": {
156
+ "chromeProfile": "Default",
157
+ "chromeCookiePath": "/Users/you/Library/Application Support/net.imput.helium/Default/Cookies",
158
+ "chromiumKeychain": {
159
+ "account": "Helium",
160
+ "services": ["Helium Storage Key"],
161
+ "label": "Helium Storage Key"
162
+ }
163
+ }
164
+ }
165
+ ```
166
+
167
+ `auth.chromeCookiePath` remains the cookie database path for backward compatibility. `auth.chromiumKeychain` must be paired with `auth.chromeCookiePath`; partial config is rejected so oracle does not silently fall back to a different browser source. When both are present, `/oracle-auth` uses pi-oracle's repo-owned generic Chromium cookie reader instead of patching `@steipete/sweet-cookie` internals.
168
+
169
+ If macOS prompts for Keychain access during `/oracle-auth`, allow access for the configured browser safe-storage item. If auth still fails after cookies are synced, the cookie DB may be stale, from the wrong profile, or for an account that is logged out; reopen the configured browser profile, confirm ChatGPT works there, quit the browser, and rerun `/oracle-auth`.
170
+
136
171
  ## Available presets
137
172
 
138
173
  | Preset id | Description |
@@ -162,7 +197,7 @@ Project config should only override safe, non-privileged settings.
162
197
 
163
198
  - Jobs persist their response and any artifacts under `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` by default.
164
199
  - Jobs can queue automatically if runtime capacity is full.
165
- - Completion delivery into `pi` is best-effort wake-up based.
200
+ - Completion delivery into `pi` is one-time best-effort wake-up based; duplicate poller scans are deduped in job state.
166
201
  - If you miss the wake-up, use `/oracle-read [job-id]` to inspect the saved response preview.
167
202
  - `/oracle-status [job-id]` still shows saved job metadata and lists recent job ids when you omit the id.
168
203
  - Agent callers can use `oracle_read({ jobId })`.
@@ -173,8 +208,8 @@ Project config should only override safe, non-privileged settings.
173
208
 
174
209
  - macOS
175
210
  - Node.js 22 or newer
176
- - Google Chrome installed
177
- - ChatGPT already signed into a local Chrome profile
211
+ - Google Chrome or another Chromium-family browser installed
212
+ - ChatGPT already signed into the configured local browser profile
178
213
  - `pi` 0.65.0 or newer
179
214
  - `agent-browser` available on the machine
180
215
  - `tar` and `zstd` available
@@ -183,7 +218,8 @@ Project config should only override safe, non-privileged settings.
183
218
 
184
219
  ### `/oracle-auth` fails or says login is required
185
220
 
186
- - Make sure ChatGPT works in the same local Chrome profile you configured.
221
+ - Make sure ChatGPT works in the same local browser profile you configured.
222
+ - For custom Chromium cookie sources, confirm `auth.chromeCookiePath` points at that profile's `Cookies` DB and `auth.chromiumKeychain.services` names the browser's safe-storage Keychain service.
187
223
  - Re-run `/oracle-auth`.
188
224
  - If ChatGPT is half-logged-in or challenge flow state looks weird, finish the login/challenge in the headed auth browser and retry.
189
225
 
@@ -214,9 +250,10 @@ Project config should only override safe, non-privileged settings.
214
250
 
215
251
  - Install the missing local dependency and rerun the command.
216
252
 
217
- ### Auto-detection picked the wrong Chrome profile
253
+ ### Auto-detection picked the wrong browser profile
218
254
 
219
255
  - Set `auth.chromeProfile` in `~/.pi/agent/extensions/oracle.json`.
256
+ - For custom Chromium cookie sources, set `auth.chromeCookiePath` to the exact profile `Cookies` DB and pair it with `auth.chromiumKeychain`.
220
257
  - Re-run `/oracle-auth`.
221
258
 
222
259
  ### You want more details about a failed run
@@ -233,7 +270,7 @@ Project config should only override safe, non-privileged settings.
233
270
  ## Privacy / local data
234
271
 
235
272
  This extension is local-first, but it does read and persist local data:
236
- - `/oracle-auth` reads ChatGPT cookies from a local Chrome profile
273
+ - `/oracle-auth` reads ChatGPT cookies from the configured local browser profile
237
274
  - job archives are uploaded to ChatGPT.com
238
275
  - responses and artifacts are written under the configured oracle jobs dir
239
276
 
@@ -127,9 +127,10 @@ Auth bootstrap flow:
127
127
 
128
128
  1. load oracle config
129
129
  2. acquire the global auth-maintenance lock
130
- 3. read ChatGPT cookies directly from the user’s real Chrome cookie store in read-only mode
130
+ 3. read ChatGPT cookies directly from the configured local browser cookie store in read-only mode
131
131
  - configurable source profile / cookie DB path
132
- - no launch or mutation of the real Chrome profile
132
+ - optional configured Chromium Keychain source for browsers outside the default importer
133
+ - no launch or mutation of the real browser profile
133
134
  4. validate that `browser.authSeedProfileDir` is an absolute safe path and not inside the real Chrome user-data tree
134
135
  5. create a staged seed-profile path next to the target seed profile
135
136
  6. launch the isolated auth browser headed with:
@@ -246,15 +247,20 @@ Browser/auth settings are global-only because they control local privileged brow
246
247
  "chatUrl": "https://chatgpt.com/",
247
248
  "authUrl": "https://chatgpt.com/auth/login",
248
249
  "runMode": "headless",
249
- "executablePath": "<optional absolute path to Chrome executable>",
250
+ "executablePath": "<optional absolute path to Chrome/Chromium executable>",
250
251
  "userAgent": "<optional real-Chrome UA override>",
251
252
  "args": ["--disable-blink-features=AutomationControlled"]
252
253
  },
253
254
  "auth": {
254
255
  "pollMs": 1000,
255
256
  "bootstrapTimeoutMs": 600000,
256
- "chromeProfile": "<optional Chrome profile name>",
257
- "chromeCookiePath": "<optional absolute path to Chrome Cookies DB>"
257
+ "chromeProfile": "<optional Chrome/Chromium profile name>",
258
+ "chromeCookiePath": "<optional absolute path to Chromium Cookies DB>",
259
+ "chromiumKeychain": {
260
+ "account": "<macOS Keychain account for non-built-in Chromium browsers>",
261
+ "services": ["<safe-storage service name>"],
262
+ "label": "<optional human-readable label>"
263
+ }
258
264
  },
259
265
  "worker": {
260
266
  "pollMs": 5000,
@@ -273,6 +279,23 @@ Browser/auth settings are global-only because they control local privileged brow
273
279
  }
274
280
  ```
275
281
 
282
+ `auth.chromiumKeychain` is an opt-in alternate cookie source for Chromium-family browsers that are not handled by the default `@steipete/sweet-cookie` Chrome-compatible importer. It must be configured with `auth.chromeCookiePath`; partial config is rejected so `/oracle-auth` cannot silently fall back to a different browser profile.
283
+
284
+ When both `auth.chromeCookiePath` and `auth.chromiumKeychain` are present, auth bootstrap:
285
+
286
+ 1. reads the configured macOS Keychain safe-storage password using `account` and the ordered `services` list
287
+ 2. snapshots the Chromium `Cookies` DB plus `Cookies-wal` / `Cookies-shm` sidecars, tolerating sidecars that disappear while the browser is closing
288
+ 3. decrypts Chromium AES-CBC cookie values, including Chromium v24+ host-hash-prefixed values
289
+ 4. dedupes duplicate cookie rows by keeping the first row after newest-expiry ordering
290
+ 5. filters importable ChatGPT auth cookies and seeds the isolated oracle auth profile
291
+
292
+ Operational requirements for this path:
293
+
294
+ - ChatGPT must already be logged in in the configured browser profile.
295
+ - The target browser should be fully quit before `/oracle-auth` so the cookie DB snapshot is stable.
296
+ - The configured Keychain item must be accessible to the current macOS user; allow Keychain access if prompted.
297
+ - `browser.executablePath` should point at the same Chromium-family browser so the headed auth/bootstrap browser uses the intended app.
298
+
276
299
  ## Cleanup maintenance model
277
300
 
278
301
  Long-run hygiene is intentionally conservative:
@@ -484,12 +507,12 @@ The extension still uses the same general `pi`-native background completion patt
484
507
  - detached worker writes `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-*` state
485
508
  - poller scans jobs on an interval
486
509
  - completed job durability lives in oracle job state plus saved response/artifact files, not in synthetic session-history assistant messages
487
- - when a matching job reaches `complete`, `failed`, or `cancelled`, the poller issues bounded best-effort wake-up reminders to whichever matching session is currently live
510
+ - when a matching job reaches `complete`, `failed`, or `cancelled`, the poller issues one best-effort wake-up to whichever matching session is currently live, then records `notifiedAt` so later scans do not duplicate the completion message
488
511
  - those wake-ups direct the receiver to `/oracle-read [job-id]` as the primary completion-consumption path, while still surfacing saved response/artifact paths as secondary context; `/oracle-status` remains useful for metadata and job-id discovery, and agent callers can still use `oracle_read` when they need tool output in-turn
489
- - manual `oracle_read`, `/oracle-read`, or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened and persists provenance about which path/session settled the wake-up
490
- - manual inspection before the first wake-up attempt is recorded separately as observation metadata and does not suppress the first reminder send
512
+ - wake-up content explicitly tells agents not to treat completion as an automatic `oracle_auth`, `oracle_submit`, or `oracle_cancel` retry instruction
513
+ - manual `oracle_read`, `/oracle-read`, or `/oracle-status` inspection after a wake-up persists provenance about which path/session settled the wake-up
491
514
  - if no wake-up lands, the job remains available via `/oracle-read`, `/oracle-status`, `oracle_read`, and the saved `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` response/artifact files
492
- - because completion delivery is best-effort, pruning uses explicit terminal-job age policy instead of pretending a durable session notification happened
515
+ - because completion delivery is best-effort, pruning uses explicit terminal-job age policy plus `notifiedAt`/wakeup state instead of pretending a durable session notification was appended
493
516
  - recently sent wake-ups keep response/artifact files retained briefly so follow-up turns do not point at deleted paths if cleanup or pruning races with delivery
494
517
 
495
518
  ## What was removed by this pivot
@@ -565,7 +588,7 @@ Live-validated after the concurrency redesign:
565
588
  - the poller no longer needs the worker to stay alive just to observe completion for artifact-producing runs
566
589
  - expired/missing auth now fails as a clean auth-related error instead of generic UI/config drift
567
590
  - `/oracle-auth` repairs the seed profile and a post-repair probe succeeds again
568
- - live auth recovery also exposed and corrected a real source-profile misconfiguration during validation; the configured Chrome profile must actually contain the active ChatGPT session cookies
591
+ - live auth recovery also exposed and corrected a real source-profile misconfiguration during validation; the configured browser profile must actually contain the active ChatGPT session cookies
569
592
 
570
593
  ## Known remaining work
571
594
 
@@ -209,6 +209,11 @@ export interface OracleConfig {
209
209
  bootstrapTimeoutMs: number;
210
210
  chromeProfile: string;
211
211
  chromeCookiePath?: string;
212
+ chromiumKeychain?: {
213
+ account: string;
214
+ services: string[];
215
+ label?: string;
216
+ };
212
217
  };
213
218
  worker: {
214
219
  pollMs: number;
@@ -286,11 +291,12 @@ export function getOracleConfigLoadDetails(cwd: string): OracleConfigLoadDetails
286
291
  }
287
292
 
288
293
  export function formatOracleAuthConfigRemediation(details: OracleConfigLoadDetails): string {
294
+ const authFields = "auth.chromeProfile / auth.chromeCookiePath / auth.chromiumKeychain";
289
295
  if (!details.projectConfigExists) {
290
- return `Set auth.chromeProfile / auth.chromeCookiePath in ${details.effectiveAuthConfigPath}.`;
296
+ return `Set ${authFields} in ${details.effectiveAuthConfigPath}.`;
291
297
  }
292
298
  return (
293
- `Set auth.chromeProfile / auth.chromeCookiePath in ${details.effectiveAuthConfigPath}. ` +
299
+ `Set ${authFields} in ${details.effectiveAuthConfigPath}. ` +
294
300
  `Project overrides are also read from ${details.projectConfigPath}, but auth.* is loaded from ${details.effectiveAuthConfigPath}.`
295
301
  );
296
302
  }
@@ -330,6 +336,7 @@ export const DEFAULT_CONFIG: OracleConfig = {
330
336
  bootstrapTimeoutMs: 10 * 60 * 1000,
331
337
  chromeProfile: detectedChromeProfileName,
332
338
  chromeCookiePath: undefined,
339
+ chromiumKeychain: undefined,
333
340
  },
334
341
  worker: {
335
342
  pollMs: 5000,
@@ -439,6 +446,20 @@ function expectStringArray(value: unknown, path: string): string[] {
439
446
  return value;
440
447
  }
441
448
 
449
+ function expectOptionalChromiumKeychain(value: unknown, path: string): OracleConfig["auth"]["chromiumKeychain"] {
450
+ if (value === undefined) return undefined;
451
+ const keychain = expectObject(value, path);
452
+ const services = expectStringArray(keychain.services, `${path}.services`);
453
+ if (services.length === 0) {
454
+ throw new Error(`Invalid oracle config: ${path}.services must include at least one service name`);
455
+ }
456
+ return {
457
+ account: expectString(keychain.account, `${path}.account`),
458
+ services,
459
+ label: expectOptionalString(keychain.label, `${path}.label`),
460
+ };
461
+ }
462
+
442
463
  function expectInteger(value: unknown, path: string, minimum: number, maximum?: number): number {
443
464
  if (typeof value !== "number" || !Number.isInteger(value) || value < minimum || (maximum !== undefined && value > maximum)) {
444
465
  const range = maximum === undefined ? `>= ${minimum}` : `between ${minimum} and ${maximum}`;
@@ -523,6 +544,12 @@ function validateOracleConfig(value: unknown): OracleConfig {
523
544
  throw new Error("Invalid oracle config: browser.runtimeProfilesDir must be separate from browser.authSeedProfileDir");
524
545
  }
525
546
 
547
+ const chromeCookiePath = expectOptionalAbsoluteNormalizedPath(auth.chromeCookiePath, "auth.chromeCookiePath");
548
+ const chromiumKeychain = expectOptionalChromiumKeychain(auth.chromiumKeychain, "auth.chromiumKeychain");
549
+ if (chromiumKeychain !== undefined && chromeCookiePath === undefined) {
550
+ throw new Error("Invalid oracle config: auth.chromiumKeychain requires auth.chromeCookiePath");
551
+ }
552
+
526
553
  return {
527
554
  defaults: {
528
555
  preset,
@@ -544,7 +571,8 @@ function validateOracleConfig(value: unknown): OracleConfig {
544
571
  pollMs: expectInteger(auth.pollMs, "auth.pollMs", 100),
545
572
  bootstrapTimeoutMs: expectInteger(auth.bootstrapTimeoutMs, "auth.bootstrapTimeoutMs", 1000),
546
573
  chromeProfile: expectString(auth.chromeProfile, "auth.chromeProfile"),
547
- chromeCookiePath: expectOptionalAbsoluteNormalizedPath(auth.chromeCookiePath, "auth.chromeCookiePath"),
574
+ chromeCookiePath,
575
+ chromiumKeychain,
548
576
  },
549
577
  worker: {
550
578
  pollMs: expectInteger(worker.pollMs, "worker.pollMs", 100),
@@ -15,6 +15,7 @@ import {
15
15
  hasPersistedOriginSession,
16
16
  isActiveOracleJob,
17
17
  listOracleJobDirs,
18
+ markJobNotified,
18
19
  noteWakeupRequested,
19
20
  readJob,
20
21
  recordNotificationTarget,
@@ -245,7 +246,11 @@ async function scan(
245
246
  }
246
247
  if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
247
248
 
248
- await promoteQueuedJobs({ workerPath, source: "poller" });
249
+ try {
250
+ await promoteQueuedJobs({ workerPath, source: "poller", lockTimeoutMs: POLLER_LOCK_TIMEOUT_MS });
251
+ } catch (error) {
252
+ if (!isLockTimeoutError(error, "admission", "global")) throw error;
253
+ }
249
254
  if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
250
255
 
251
256
  const terminalJobs = listOracleJobDirs()
@@ -324,7 +329,6 @@ async function scan(
324
329
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
325
330
  return;
326
331
  }
327
- requestWakeupTurn(pi, deliverable);
328
332
  const notedWakeup = await noteWakeupRequested(jobId);
329
333
  if (!notedWakeup) {
330
334
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
@@ -334,6 +338,13 @@ async function scan(
334
338
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
335
339
  return;
336
340
  }
341
+ await hooks.beforeMarkJobNotified?.(deliverable);
342
+ await markJobNotified(jobId, notificationClaimant, {
343
+ notificationSessionKey: pollerKey,
344
+ notificationSessionFile: currentSessionFile,
345
+ });
346
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
347
+ requestWakeupTurn(pi, deliverable);
337
348
  if (snapshot.hasUI) {
338
349
  snapshot.ui.notify(`Oracle job ${claimed.id} is ${claimed.status}.`, "info");
339
350
  }
@@ -25,6 +25,7 @@ export interface OracleQueuePosition {
25
25
  export interface PromoteQueuedJobsOptions {
26
26
  workerPath: string;
27
27
  source: string;
28
+ lockTimeoutMs?: number;
28
29
  spawnWorkerFn?: typeof spawnWorker;
29
30
  loadConfigFn?: typeof loadOracleConfig;
30
31
  }
@@ -141,7 +142,7 @@ export async function promoteQueuedJobsWithinAdmissionLock(options: PromoteQueue
141
142
  export async function promoteQueuedJobs(options: PromoteQueuedJobsOptions): Promise<{ promotedJobIds: string[] }> {
142
143
  return withLock("admission", "global", { processPid: process.pid, source: options.source }, async () => {
143
144
  return promoteQueuedJobsWithinAdmissionLock(options);
144
- });
145
+ }, { timeoutMs: options.lockTimeoutMs });
145
146
  }
146
147
 
147
148
  export async function createQueuedJob(
@@ -63,7 +63,7 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
63
63
  files: Type.Array(Type.String({
64
64
  description: "Project-relative file or directory path to include in the archive.",
65
65
  minLength: 1,
66
- pattern: ".*\\S.*",
66
+ pattern: "^.*\\S.*$",
67
67
  }), {
68
68
  description: "Exact project-relative files/directories to include in the oracle archive.",
69
69
  minItems: 1,
@@ -71,7 +71,8 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
71
71
  preset: Type.Optional(
72
72
  Type.String({
73
73
  description:
74
- "ChatGPT model preset. Omit to use the configured default preset. Canonical ids are preferred; matching human-readable preset labels and common hyphen/space variants are normalized automatically.",
74
+ `ChatGPT model preset. Omit to use the configured default preset. Canonical ids: ${ORACLE_SUBMIT_PRESET_IDS.join(", ")}. ` +
75
+ "Matching human-readable preset labels and common hyphen/space variants are normalized automatically.",
75
76
  }),
76
77
  ),
77
78
  followUpJobId: Type.Optional(Type.String({ description: "Earlier oracle job id whose chat thread should be continued." })),
@@ -1054,7 +1055,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
1054
1055
  pi.registerTool({
1055
1056
  name: "oracle_auth",
1056
1057
  label: "Oracle Auth",
1057
- description: "Refresh the shared oracle auth seed profile by importing ChatGPT cookies from your configured real Chrome profile.",
1058
+ description: "Refresh the shared oracle auth seed profile by importing ChatGPT cookies from your configured local browser profile.",
1058
1059
  promptSnippet: "Refresh oracle auth before retrying a login-required oracle run.",
1059
1060
  promptGuidelines: [
1060
1061
  "Call oracle_auth when an oracle run failed because ChatGPT login is required, the worker said to rerun /oracle-auth, or stale auth appears to be blocking submission execution.",
@@ -1101,7 +1102,9 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
1101
1102
  "For any other submit-time error, stop and report the error instead of retrying automatically.",
1102
1103
  "If oracle_submit returns a queued job instead of an immediately dispatched one, treat that as success and stop exactly the same way.",
1103
1104
  "After a successful or queued oracle_submit, stop; do not continue the task while the oracle job is running. If oracle_submit failed with retryable archive_too_large, narrow the archive and retry first.",
1104
- "Use `preset` as the only model-selection parameter on oracle_submit. Canonical ids are preferred, and matching human-readable preset labels are normalized automatically. Omit preset to use the configured default.",
1105
+ "Use `preset` as the only model-selection parameter on oracle_submit. " +
1106
+ `Canonical ids: ${ORACLE_SUBMIT_PRESET_IDS.join(", ")}. ` +
1107
+ "matching human-readable preset labels are normalized automatically. Omit preset to use the configured default.",
1105
1108
  ],
1106
1109
  parameters: ORACLE_SUBMIT_PARAMS,
1107
1110
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -287,9 +287,6 @@ export function markOracleJobNotified(job, options = {}) {
287
287
  notificationEntryId: options.notificationEntryId ?? job.notificationEntryId,
288
288
  notificationSessionKey: options.notificationSessionKey ?? job.notificationSessionKey,
289
289
  notificationSessionFile: options.notificationSessionFile ?? job.notificationSessionFile,
290
- wakeupAttemptCount: 0,
291
- wakeupLastRequestedAt: undefined,
292
- wakeupSettledAt: undefined,
293
290
  notifyClaimedAt: undefined,
294
291
  notifyClaimedBy: undefined,
295
292
  });
@@ -4,6 +4,7 @@ export interface OracleJobSummaryLike {
4
4
  id: string;
5
5
  status: string;
6
6
  phase: string;
7
+ phaseAt?: string;
7
8
  createdAt: string;
8
9
  queuedAt?: string;
9
10
  submittedAt?: string;
@@ -38,6 +39,7 @@ export interface OracleJobSummaryOptions {
38
39
  includeWorkerLogPath?: boolean;
39
40
  nowMs?: number;
40
41
  heartbeatStaleMs?: number;
42
+ suggestedPollAfterSeconds?: number;
41
43
  }
42
44
 
43
45
  export interface OracleSubmitResponseOptions {
@@ -48,6 +48,7 @@ function formatAutoPrunedArchiveMessage(autoPrunedPrefixes) {
48
48
 
49
49
  const ACTIVE_SUMMARY_STATUSES = new Set(["preparing", "submitted", "waiting"]);
50
50
  const DEFAULT_ORACLE_HEARTBEAT_STALE_MS = 3 * 60 * 1000;
51
+ const DEFAULT_ACTIVE_JOB_POLL_HINT_SECONDS = 15;
51
52
 
52
53
  /**
53
54
  * @param {string | undefined} value
@@ -99,6 +100,31 @@ function formatHeartbeatFreshness(job, options = {}) {
99
100
  return `heartbeat: ${freshness} (${formatElapsed(elapsedMs)} ${submittedMs !== undefined ? "since submit" : "since create"})`;
100
101
  }
101
102
 
103
+ /**
104
+ * @param {OracleJobSummaryLike} job
105
+ * @param {OracleJobSummaryOptions} [options]
106
+ * @returns {string[]}
107
+ */
108
+ function formatActiveProgressLines(job, options = {}) {
109
+ if (!ACTIVE_SUMMARY_STATUSES.has(job.status)) return [];
110
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
111
+ const submittedMs = parseTimestamp(job.submittedAt);
112
+ const createdMs = parseTimestamp(job.createdAt);
113
+ const phaseMs = parseTimestamp(job.phaseAt);
114
+ const baselineMs = submittedMs ?? createdMs;
115
+ const elapsedLine = baselineMs === undefined
116
+ ? undefined
117
+ : `elapsed: ${formatElapsed(nowMs - baselineMs)} ${submittedMs !== undefined ? "since submit" : "since create"}`;
118
+ const phaseElapsedLine = phaseMs === undefined
119
+ ? undefined
120
+ : `phase-elapsed: ${formatElapsed(nowMs - phaseMs)} in ${job.phase}`;
121
+ const pollHintSeconds = Number.isFinite(options.suggestedPollAfterSeconds)
122
+ ? Math.max(1, Math.round(options.suggestedPollAfterSeconds))
123
+ : DEFAULT_ACTIVE_JOB_POLL_HINT_SECONDS;
124
+ const pollHint = `poll-hint: wait about ${pollHintSeconds}s before checking again, or stop and wait for the one-time completion wake-up`;
125
+ return [elapsedLine, phaseElapsedLine, pollHint].filter(Boolean);
126
+ }
127
+
102
128
  /**
103
129
  * @param {{ id: string; status: string }} job
104
130
  * @returns {string}
@@ -142,6 +168,7 @@ export function formatOracleJobSummary(job, options = {}) {
142
168
  `project: ${job.projectId}`,
143
169
  `session: ${job.sessionId}`,
144
170
  formatHeartbeatFreshness(job, options),
171
+ ...formatActiveProgressLines(job, options),
145
172
  job.completedAt ? `completed: ${job.completedAt}` : undefined,
146
173
  job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
147
174
  job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
@@ -175,7 +202,8 @@ export function buildOracleWakeupNotificationContent(job, options = {}) {
175
202
  const artifactsPath = options.artifactsPath ?? `artifacts unavailable for ${job.id}`;
176
203
  return [
177
204
  `Oracle job ${job.id} is ${job.status}.`,
178
- `Use /oracle-read ${job.id} to inspect the saved response preview. /oracle-status ${job.id} still shows saved job metadata. Agent callers can use oracle_read({ jobId: "${job.id}" }) if they need tool output in the current turn.`,
205
+ "This is a one-time completion wake-up, not a retry instruction. Do not call oracle_auth, oracle_submit, or oracle_cancel automatically from this wake-up.",
206
+ `Use /oracle-read ${job.id} to inspect the saved response preview. /oracle-status ${job.id} still shows saved job metadata. Agent callers can use oracle_read({ jobId: "${job.id}" }) once if they need tool output in the current turn.`,
179
207
  responseLine,
180
208
  `Artifacts: ${artifactsPath}`,
181
209
  formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job)) ? `Last event: ${formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job))}` : undefined,
@@ -199,7 +227,7 @@ export function formatOracleSubmitResponse(job, options) {
199
227
  `Response will be written to: ${job.responsePath}`,
200
228
  formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job)) ? `Last event: ${formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job))}` : undefined,
201
229
  options.queued ? "The job will start automatically when capacity is available." : undefined,
202
- "Stop now and wait for the oracle completion wake-up.",
230
+ "Do not poll now; wait for the one-time oracle completion wake-up.",
203
231
  ]
204
232
  .filter(Boolean)
205
233
  .join("\n");
@@ -1,8 +1,8 @@
1
- // Purpose: Bootstrap isolated oracle browser auth by importing real Chrome cookies and validating ChatGPT session readiness.
1
+ // Purpose: Bootstrap isolated oracle browser auth by importing real Chromium-family cookies and validating ChatGPT session readiness.
2
2
  // Responsibilities: Copy/import cookies, classify auth pages, drive lightweight account-selection flows, and persist diagnostics for auth failures.
3
3
  // Scope: Auth bootstrap worker only; long-running oracle job execution stays in run-job.mjs and shared lifecycle/state helpers stay elsewhere.
4
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.
5
+ // Invariants/Assumptions: Runs against a local macOS Chromium-family profile, preserves private diagnostics, and must fail clearly when auth state cannot be verified.
6
6
  import { withLock } from "./state-locks.mjs";
7
7
  import { spawn } from "node:child_process";
8
8
  import { existsSync } from "node:fs";
@@ -11,6 +11,7 @@ import { homedir, tmpdir } from "node:os";
11
11
  import { basename, dirname, join, resolve } from "node:path";
12
12
  import { getCookies } from "@steipete/sweet-cookie";
13
13
  import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
14
+ import { getCookiesFromConfiguredChromiumSource } from "./chromium-cookie-source.mjs";
14
15
  import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
15
16
  import { buildAccountChooserCandidateLabels, classifyChatAuthPage, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
16
17
 
@@ -91,11 +92,11 @@ function authConfigRemediation() {
91
92
  if (typeof configLoad?.remediation === "string" && configLoad.remediation) return configLoad.remediation;
92
93
  if (typeof configLoad?.projectConfigPath === "string" && configLoad.projectConfigPath && configLoad.projectConfigExists) {
93
94
  return (
94
- `Set auth.chromeProfile / auth.chromeCookiePath in ${effectiveAuthConfigPath()}. ` +
95
+ `Set auth.chromeProfile / auth.chromeCookiePath / auth.chromiumKeychain in ${effectiveAuthConfigPath()}. ` +
95
96
  `Project overrides are also read from ${configLoad.projectConfigPath}, but auth.* is loaded from ${effectiveAuthConfigPath()}.`
96
97
  );
97
98
  }
98
- return `Set auth.chromeProfile / auth.chromeCookiePath in ${effectiveAuthConfigPath()}.`;
99
+ return `Set auth.chromeProfile / auth.chromeCookiePath / auth.chromiumKeychain in ${effectiveAuthConfigPath()}.`;
99
100
  }
100
101
 
101
102
  function authConfigSummary() {
@@ -463,14 +464,24 @@ function cookieSource() {
463
464
  }
464
465
 
465
466
  function cookieSourceLabel() {
467
+ if (config.auth.chromeCookiePath && config.auth.chromiumKeychain) return `Chromium cookie DB ${config.auth.chromeCookiePath}`;
466
468
  return config.auth.chromeCookiePath
467
469
  ? `Chrome cookie DB ${config.auth.chromeCookiePath}`
468
470
  : `Chrome profile ${config.auth.chromeProfile}`;
469
471
  }
470
472
 
471
- async function readSourceCookies() {
472
- await log(`Reading ChatGPT cookies from ${cookieSourceLabel()}`);
473
- const { cookies, warnings } = await getCookies({
473
+ async function readRawSourceCookies() {
474
+ if (config.auth.chromeCookiePath && config.auth.chromiumKeychain) {
475
+ return await getCookiesFromConfiguredChromiumSource({
476
+ dbPath: config.auth.chromeCookiePath,
477
+ keychain: config.auth.chromiumKeychain,
478
+ origins: cookieOrigins(),
479
+ profile: config.auth.chromeProfile,
480
+ timeoutMs: 5_000,
481
+ });
482
+ }
483
+
484
+ return await getCookies({
474
485
  url: config.browser.chatUrl,
475
486
  origins: cookieOrigins(),
476
487
  browsers: ["chrome"],
@@ -478,9 +489,14 @@ async function readSourceCookies() {
478
489
  chromeProfile: cookieSource(),
479
490
  timeoutMs: 5_000,
480
491
  });
492
+ }
493
+
494
+ async function readSourceCookies() {
495
+ await log(`Reading ChatGPT cookies from ${cookieSourceLabel()}`);
496
+ const { cookies, warnings } = await readRawSourceCookies();
481
497
 
482
498
  if (warnings.length) {
483
- await log(`sweet-cookie warnings: ${warnings.join(" | ")}`);
499
+ await log(`Cookie source warnings: ${warnings.join(" | ")}`);
484
500
  }
485
501
 
486
502
  const filtered = filterImportableAuthCookies(cookies, config.browser.chatUrl);
@@ -501,7 +517,7 @@ async function readSourceCookies() {
501
517
 
502
518
  if (!hasSessionToken) {
503
519
  throw new Error(
504
- `No ChatGPT session-token cookies were found in ${cookieSourceLabel()}. Make sure ChatGPT is logged into that Chrome profile. ${authConfigRemediation()}`,
520
+ `No ChatGPT session-token cookies were found in ${cookieSourceLabel()}. Make sure ChatGPT is logged into that browser profile. ${authConfigRemediation()}`,
505
521
  );
506
522
  }
507
523
 
@@ -828,7 +844,7 @@ async function run() {
828
844
 
829
845
  run().catch((error) => {
830
846
  process.stderr.write(
831
- `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in ${DIAGNOSTICS_DIR || "(oracle-auth diagnostics dir unavailable)"}\n${authConfigSummary()}\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
847
+ `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in ${DIAGNOSTICS_DIR || "(oracle-auth diagnostics dir unavailable)"}\n${authConfigSummary()}\nIf needed, ensure the configured browser profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
832
848
  );
833
849
  process.exit(1);
834
850
  });
@@ -114,7 +114,8 @@ export function classifyChatAuthPage(args) {
114
114
  state: "login_required",
115
115
  message:
116
116
  `Synced cookies from ${args.cookieSourceLabel}, but ChatGPT still rejected the session ` +
117
- `(status=${args.probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${args.logPath}.`,
117
+ `(status=${args.probe?.status ?? 0}); the cookie DB may be stale, from the wrong browser profile, or for an account that is logged out. ` +
118
+ `Check auth.chromeProfile/auth.chromeCookiePath/auth.chromiumKeychain and inspect ${args.logPath}.`,
118
119
  };
119
120
  }
120
121
 
@@ -131,7 +132,8 @@ export function classifyChatAuthPage(args) {
131
132
  state: "login_required",
132
133
  message:
133
134
  `Synced cookies from ${args.cookieSourceLabel}, but ChatGPT still rejected the session ` +
134
- `(status=${args.probe?.status ?? 0}). Check auth.chromeProfile/auth.chromeCookiePath and inspect ${args.logPath}.`,
135
+ `(status=${args.probe?.status ?? 0}); the cookie DB may be stale, from the wrong browser profile, or for an account that is logged out. ` +
136
+ `Check auth.chromeProfile/auth.chromeCookiePath/auth.chromiumKeychain and inspect ${args.logPath}.`,
135
137
  };
136
138
  }
137
139
 
@@ -0,0 +1,21 @@
1
+ import type { ImportedAuthCookie } from "./auth-cookie-policy.mjs";
2
+
3
+ export interface ChromiumKeychainConfig {
4
+ account: string;
5
+ services: string[];
6
+ label?: string;
7
+ service?: string;
8
+ }
9
+
10
+ export interface ConfiguredChromiumSourceOptions {
11
+ dbPath: string;
12
+ keychain: ChromiumKeychainConfig;
13
+ origins: string[];
14
+ profile: string;
15
+ timeoutMs?: number;
16
+ includeExpired?: boolean;
17
+ }
18
+
19
+ export function getCookiesFromConfiguredChromiumSource(
20
+ options: ConfiguredChromiumSourceOptions,
21
+ ): Promise<{ cookies: ImportedAuthCookie[]; warnings: string[] }>;
@@ -0,0 +1,291 @@
1
+ // Purpose: Read ChatGPT cookies from arbitrary macOS Chromium-family cookie stores when sweet-cookie's built-in browser list is too narrow.
2
+ // Responsibilities: Snapshot a Chromium Cookies SQLite DB, decrypt AES-CBC cookie values with a configured Keychain item, and return sweet-cookie-shaped cookie objects.
3
+ // Scope: macOS Chromium cookie extraction only; auth policy filtering and browser seeding stay in auth-bootstrap.mjs.
4
+ // Usage: auth-bootstrap.mjs uses this when auth.chromiumKeychain is configured alongside auth.chromeCookiePath.
5
+ // Invariants/Assumptions: The configured cookie path points at a Chromium Cookies DB and the configured Keychain item is the browser's safe-storage secret.
6
+ import { spawn } from "node:child_process";
7
+ import { createDecipheriv, pbkdf2Sync } from "node:crypto";
8
+ import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { DatabaseSync } from "node:sqlite";
12
+
13
+ const CHROMIUM_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
14
+ const COOKIE_VALUE_DECODER = new TextDecoder("utf-8", { fatal: true });
15
+ const MACOS_CHROMIUM_KEY_ITERATIONS = 1003;
16
+
17
+ function spawnCapture(command, args, options = {}) {
18
+ return new Promise((resolve) => {
19
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
20
+ let stdout = "";
21
+ let stderr = "";
22
+ const timeoutMs = options.timeoutMs ?? 5_000;
23
+ let timedOut = false;
24
+ const timer = setTimeout(() => {
25
+ timedOut = true;
26
+ child.kill("SIGTERM");
27
+ }, timeoutMs);
28
+ timer.unref?.();
29
+
30
+ child.stdout.on("data", (data) => { stdout += String(data); });
31
+ child.stderr.on("data", (data) => { stderr += String(data); });
32
+ child.on("error", (error) => {
33
+ clearTimeout(timer);
34
+ resolve({ ok: false, stdout, stderr, error: error.message });
35
+ });
36
+ child.on("close", (code) => {
37
+ clearTimeout(timer);
38
+ resolve({
39
+ ok: code === 0 && !timedOut,
40
+ stdout,
41
+ stderr,
42
+ error: timedOut ? `Timed out after ${timeoutMs}ms` : stderr.trim(),
43
+ });
44
+ });
45
+ });
46
+ }
47
+
48
+ async function readKeychainPassword(keychain, timeoutMs) {
49
+ const services = Array.isArray(keychain.services) && keychain.services.length > 0 ? keychain.services : [keychain.service];
50
+ for (const service of services.filter(Boolean)) {
51
+ const result = await spawnCapture("security", ["find-generic-password", "-w", "-a", keychain.account, "-s", service], { timeoutMs });
52
+ if (result.ok) {
53
+ const password = result.stdout.trim();
54
+ if (password) return { ok: true, password, service };
55
+ return { ok: false, error: `macOS Keychain returned an empty ${keychain.label || service} password.` };
56
+ }
57
+ }
58
+ return { ok: false, error: `Failed to read macOS Keychain (${keychain.label || keychain.account}): no configured service returned a password.` };
59
+ }
60
+
61
+ function snapshotCookieDb(dbPath) {
62
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-oracle-chromium-cookies-"));
63
+ const tempDbPath = join(tempDir, "Cookies");
64
+ try {
65
+ copyFileSync(dbPath, tempDbPath);
66
+ copySidecar(dbPath, `${tempDbPath}-wal`, "-wal");
67
+ copySidecar(dbPath, `${tempDbPath}-shm`, "-shm");
68
+ return { tempDir, tempDbPath };
69
+ } catch (error) {
70
+ rmSync(tempDir, { recursive: true, force: true });
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ function copySidecar(sourceDbPath, targetPath, suffix) {
76
+ const sidecarPath = `${sourceDbPath}${suffix}`;
77
+ try {
78
+ copyFileSync(sidecarPath, targetPath);
79
+ } catch (error) {
80
+ if (isMissingFileError(error)) return;
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ function isMissingFileError(error) {
86
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
87
+ }
88
+
89
+ function readMetaVersion(db) {
90
+ try {
91
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
92
+ const parsed = Number.parseInt(String(row?.value ?? "0"), 10);
93
+ return Number.isFinite(parsed) ? parsed : 0;
94
+ } catch {
95
+ return 0;
96
+ }
97
+ }
98
+
99
+ function parentCookieDomains(host) {
100
+ const labels = host.split(".").filter(Boolean);
101
+ const domains = new Set([host, `.${host}`]);
102
+ for (let index = 1; index < labels.length - 1; index += 1) {
103
+ const parent = labels.slice(index).join(".");
104
+ domains.add(parent);
105
+ domains.add(`.${parent}`);
106
+ }
107
+ return [...domains];
108
+ }
109
+
110
+ function sqlStringLiteral(value) {
111
+ return `'${value.replaceAll("'", "''")}'`;
112
+ }
113
+
114
+ function buildHostWhereClause(origins) {
115
+ const domains = new Set();
116
+ for (const origin of origins) {
117
+ try {
118
+ for (const domain of parentCookieDomains(new URL(origin).hostname)) domains.add(domain);
119
+ } catch {
120
+ // Ignore malformed origins; validated ChatGPT config supplies the real set.
121
+ }
122
+ }
123
+ if (domains.size === 0) return "0";
124
+ return `host_key IN (${[...domains].map(sqlStringLiteral).join(", ")})`;
125
+ }
126
+
127
+ function hostMatchesAny(originHosts, hostKey) {
128
+ const cookieDomain = hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;
129
+ return originHosts.some((host) => host === cookieDomain || host.endsWith(`.${cookieDomain}`));
130
+ }
131
+
132
+ function chromiumExpirationToUnixSeconds(value) {
133
+ if (value === undefined || value === null || String(value) === "0") return undefined;
134
+ try {
135
+ const raw = BigInt(String(value));
136
+ const seconds = raw / 1_000_000n - CHROMIUM_EPOCH_OFFSET_SECONDS;
137
+ if (seconds <= 0n) return undefined;
138
+ return Number(seconds);
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ function normalizeSameSite(value) {
145
+ const normalized = String(value ?? "").toLowerCase();
146
+ if (normalized === "2" || normalized === "strict") return "Strict";
147
+ if (normalized === "1" || normalized === "lax") return "Lax";
148
+ if (normalized === "0" || normalized === "none" || normalized === "no_restriction") return "None";
149
+ return undefined;
150
+ }
151
+
152
+ function deriveMacosChromiumKey(password) {
153
+ return pbkdf2Sync(password, "saltysalt", MACOS_CHROMIUM_KEY_ITERATIONS, 16, "sha1");
154
+ }
155
+
156
+ function decryptCookieValue(encryptedValue, key, options) {
157
+ const buffer = Buffer.from(encryptedValue);
158
+ if (buffer.length < 3) return null;
159
+ const prefix = buffer.subarray(0, 3).toString("utf8");
160
+ if (!/^v\d\d$/.test(prefix)) return decodeCookieBytes(buffer, false);
161
+
162
+ try {
163
+ const iv = Buffer.alloc(16, 0x20);
164
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
165
+ decipher.setAutoPadding(false);
166
+ const padded = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]);
167
+ const unpadded = removePkcs7Padding(padded);
168
+ return decodeCookieBytes(unpadded, options.stripHashPrefix);
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function removePkcs7Padding(value) {
175
+ if (!value.length) return value;
176
+ const padding = value[value.length - 1];
177
+ if (!padding || padding > 16) return value;
178
+ return value.subarray(0, value.length - padding);
179
+ }
180
+
181
+ function decodeCookieBytes(value, stripHashPrefix) {
182
+ const bytes = stripHashPrefix && value.length >= 32 ? value.subarray(32) : value;
183
+ try {
184
+ return stripLeadingControlChars(COOKIE_VALUE_DECODER.decode(bytes));
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function stripLeadingControlChars(value) {
191
+ let index = 0;
192
+ while (index < value.length && value.charCodeAt(index) < 0x20) index += 1;
193
+ return value.slice(index);
194
+ }
195
+
196
+ function collectCookies(rows, options, key, warnings) {
197
+ const cookies = [];
198
+ const now = Math.floor(Date.now() / 1000);
199
+ const hosts = options.origins.map((origin) => new URL(origin).hostname);
200
+ let warnedEncryptedType = false;
201
+
202
+ for (const row of rows) {
203
+ const name = typeof row.name === "string" ? row.name : "";
204
+ const hostKey = typeof row.host_key === "string" ? row.host_key : "";
205
+ if (!name || !hostKey || !hostMatchesAny(hosts, hostKey)) continue;
206
+
207
+ let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null;
208
+ if (value === null) {
209
+ if (!(row.encrypted_value instanceof Uint8Array)) {
210
+ if (!warnedEncryptedType && row.encrypted_value !== undefined) {
211
+ warnings.push("Chromium cookie encrypted_value is in an unsupported type.");
212
+ warnedEncryptedType = true;
213
+ }
214
+ continue;
215
+ }
216
+ value = decryptCookieValue(row.encrypted_value, key, { stripHashPrefix: options.stripHashPrefix });
217
+ }
218
+ if (value === null) continue;
219
+
220
+ const expires = chromiumExpirationToUnixSeconds(row.expires_utc);
221
+ if (!options.includeExpired && expires !== undefined && expires < now) continue;
222
+
223
+ const cookie = {
224
+ name,
225
+ value,
226
+ domain: hostKey.startsWith(".") ? hostKey.slice(1) : hostKey,
227
+ path: typeof row.path === "string" && row.path ? row.path : "/",
228
+ secure: row.is_secure === 1 || row.is_secure === "1" || row.is_secure === true,
229
+ httpOnly: row.is_httponly === 1 || row.is_httponly === "1" || row.is_httponly === true,
230
+ source: { browser: "chromium", profile: options.profile },
231
+ };
232
+ if (expires !== undefined) cookie.expires = expires;
233
+ const sameSite = normalizeSameSite(row.samesite);
234
+ if (sameSite !== undefined) cookie.sameSite = sameSite;
235
+ cookies.push(cookie);
236
+ }
237
+
238
+ return dedupeCookies(cookies);
239
+ }
240
+
241
+ function dedupeCookies(cookies) {
242
+ const seen = new Map();
243
+ for (const cookie of cookies) {
244
+ const key = `${cookie.domain}\t${cookie.path}\t${cookie.name}`;
245
+ if (!seen.has(key)) seen.set(key, cookie);
246
+ }
247
+ return [...seen.values()];
248
+ }
249
+
250
+ export async function getCookiesFromConfiguredChromiumSource(options) {
251
+ const warnings = [];
252
+ if (!options.dbPath || !existsSync(options.dbPath)) {
253
+ return { cookies: [], warnings: [`Chromium cookies database not found: ${options.dbPath || "(missing path)"}`] };
254
+ }
255
+
256
+ const passwordResult = await readKeychainPassword(options.keychain, options.timeoutMs ?? 5_000);
257
+ if (!passwordResult.ok) return { cookies: [], warnings: [passwordResult.error] };
258
+
259
+ let snapshot;
260
+ try {
261
+ snapshot = snapshotCookieDb(options.dbPath);
262
+ } catch (error) {
263
+ return { cookies: [], warnings: [`Failed to copy Chromium cookie DB: ${error instanceof Error ? error.message : String(error)}`] };
264
+ }
265
+
266
+ try {
267
+ const db = new DatabaseSync(snapshot.tempDbPath, { readOnly: true });
268
+ try {
269
+ const metaVersion = readMetaVersion(db);
270
+ const where = buildHostWhereClause(options.origins);
271
+ const sql =
272
+ `SELECT name, value, host_key, path, CAST(expires_utc AS TEXT) AS expires_utc, samesite, encrypted_value, ` +
273
+ `is_secure AS is_secure, is_httponly AS is_httponly FROM cookies WHERE (${where}) ORDER BY cookies.expires_utc DESC;`;
274
+ const rows = db.prepare(sql).all();
275
+ const key = deriveMacosChromiumKey(passwordResult.password);
276
+ const cookies = collectCookies(rows, {
277
+ origins: options.origins,
278
+ profile: options.profile,
279
+ includeExpired: options.includeExpired === true,
280
+ stripHashPrefix: metaVersion >= 24,
281
+ }, key, warnings);
282
+ return { cookies, warnings };
283
+ } finally {
284
+ db.close();
285
+ }
286
+ } catch (error) {
287
+ return { cookies: [], warnings: [`Failed to read Chromium cookies (requires a modern Chromium cookie DB): ${error instanceof Error ? error.message : String(error)}`] };
288
+ } finally {
289
+ rmSync(snapshot.tempDir, { recursive: true, force: true });
290
+ }
291
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
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",
@@ -42,7 +42,7 @@
42
42
  ]
43
43
  },
44
44
  "scripts": {
45
- "check:oracle-extension": "node --check extensions/oracle/shared/process-helpers.mjs && node --check extensions/oracle/shared/state-coordination-helpers.mjs && node --check extensions/oracle/shared/job-coordination-helpers.mjs && node --check extensions/oracle/shared/job-lifecycle-helpers.mjs && node --check extensions/oracle/shared/job-observability-helpers.mjs && node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/chatgpt-ui-helpers.mjs && node --check extensions/oracle/worker/chatgpt-flow-helpers.mjs && node --check extensions/oracle/worker/auth-flow-helpers.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:typebox --outfile=/tmp/pi-oracle-extension-check.js",
45
+ "check:oracle-extension": "node --check extensions/oracle/shared/process-helpers.mjs && node --check extensions/oracle/shared/state-coordination-helpers.mjs && node --check extensions/oracle/shared/job-coordination-helpers.mjs && node --check extensions/oracle/shared/job-lifecycle-helpers.mjs && node --check extensions/oracle/shared/job-observability-helpers.mjs && node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/chatgpt-ui-helpers.mjs && node --check extensions/oracle/worker/chatgpt-flow-helpers.mjs && node --check extensions/oracle/worker/auth-flow-helpers.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/chromium-cookie-source.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:typebox --outfile=/tmp/pi-oracle-extension-check.js",
46
46
  "typecheck": "tsc --noEmit -p tsconfig.json",
47
47
  "typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
48
48
  "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",