pi-oracle 0.6.14 → 0.6.16
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 +19 -0
- package/README.md +57 -9
- package/docs/ORACLE_DESIGN.md +30 -7
- package/extensions/oracle/index.ts +1 -1
- package/extensions/oracle/lib/commands.ts +3 -3
- package/extensions/oracle/lib/config.ts +32 -4
- package/extensions/oracle/lib/jobs.ts +1 -1
- package/extensions/oracle/lib/poller.ts +6 -2
- package/extensions/oracle/lib/queue.ts +2 -1
- package/extensions/oracle/lib/tools.ts +3 -3
- package/extensions/oracle/worker/auth-bootstrap.mjs +111 -15
- package/extensions/oracle/worker/auth-flow-helpers.mjs +4 -2
- package/extensions/oracle/worker/chromium-cookie-source.d.mts +21 -0
- package/extensions/oracle/worker/chromium-cookie-source.mjs +291 -0
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
### Changed
|
|
6
|
+
- made `/oracle-auth` success and failure output easier to scan, with compact source summaries and source-specific troubleshooting for configured Chromium cookie sources
|
|
7
|
+
|
|
8
|
+
## 0.6.16 - 2026-05-07
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- migrated the local pi development baseline and peer metadata from deprecated `@mariozechner/*` packages to maintained `@earendil-works/*` `0.74.0`
|
|
12
|
+
- regenerated the npm lockfile against the current stable dependency graph and refreshed the `basic-ftp` override to the current patched major
|
|
13
|
+
|
|
14
|
+
### Compatibility
|
|
15
|
+
- reviewed the pi `0.74.0` changelog and confirmed the oracle extension remains compatible with current extension lifecycle and package install/update guidance
|
|
16
|
+
|
|
17
|
+
## 0.6.15 - 2026-05-03
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- 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
|
|
21
|
+
- made background poller queued-job promotion best-effort under normal global admission-lock contention, avoiding noisy scan failures when multiple pi sessions are live
|
|
22
|
+
- added configured Chromium-family cookie source support for `/oracle-auth`, including macOS Keychain decryption for browser cookie stores not handled by `@steipete/sweet-cookie`
|
|
23
|
+
|
|
5
24
|
## 0.6.14 - 2026-05-02
|
|
6
25
|
|
|
7
26
|
### Fixed
|
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
|
|
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,7 +43,7 @@ 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
|
|
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`.
|
|
@@ -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
|
|
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
|
|
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 |
|
|
@@ -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
|
|
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,10 +218,22 @@ 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
|
|
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
|
|
|
226
|
+
### Custom Chromium auth says cookies synced but the session is rejected
|
|
227
|
+
|
|
228
|
+
This usually means the cookie import worked but the source cookies are not the active ChatGPT session you expected.
|
|
229
|
+
|
|
230
|
+
1. Open the configured browser profile.
|
|
231
|
+
2. Confirm ChatGPT works there without logging in again.
|
|
232
|
+
3. Quit the browser fully so its `Cookies` DB is stable.
|
|
233
|
+
4. Confirm `auth.chromeCookiePath` points at that exact profile's `Cookies` DB.
|
|
234
|
+
5. Confirm `auth.chromiumKeychain.services` names the browser's safe-storage Keychain service for that DB.
|
|
235
|
+
6. Re-run `/oracle-auth`.
|
|
236
|
+
|
|
190
237
|
### You hit a challenge / verification page
|
|
191
238
|
|
|
192
239
|
- Solve it in the auth/bootstrap browser if prompted.
|
|
@@ -214,9 +261,10 @@ Project config should only override safe, non-privileged settings.
|
|
|
214
261
|
|
|
215
262
|
- Install the missing local dependency and rerun the command.
|
|
216
263
|
|
|
217
|
-
### Auto-detection picked the wrong
|
|
264
|
+
### Auto-detection picked the wrong browser profile
|
|
218
265
|
|
|
219
266
|
- Set `auth.chromeProfile` in `~/.pi/agent/extensions/oracle.json`.
|
|
267
|
+
- For custom Chromium cookie sources, set `auth.chromeCookiePath` to the exact profile `Cookies` DB and pair it with `auth.chromiumKeychain`.
|
|
220
268
|
- Re-run `/oracle-auth`.
|
|
221
269
|
|
|
222
270
|
### You want more details about a failed run
|
|
@@ -233,7 +281,7 @@ Project config should only override safe, non-privileged settings.
|
|
|
233
281
|
## Privacy / local data
|
|
234
282
|
|
|
235
283
|
This extension is local-first, but it does read and persist local data:
|
|
236
|
-
- `/oracle-auth` reads ChatGPT cookies from
|
|
284
|
+
- `/oracle-auth` reads ChatGPT cookies from the configured local browser profile
|
|
237
285
|
- job archives are uploaded to ChatGPT.com
|
|
238
286
|
- responses and artifacts are written under the configured oracle jobs dir
|
|
239
287
|
|
package/docs/ORACLE_DESIGN.md
CHANGED
|
@@ -73,7 +73,7 @@ The extension now follows the current `pi` session lifecycle model:
|
|
|
73
73
|
### Commands
|
|
74
74
|
|
|
75
75
|
- `/oracle-auth`
|
|
76
|
-
- syncs ChatGPT cookies from the
|
|
76
|
+
- syncs ChatGPT cookies from the configured local browser profile into the isolated oracle profile and verifies them there
|
|
77
77
|
- `/oracle-read [job-id]`
|
|
78
78
|
- shows job status plus the saved response preview
|
|
79
79
|
- `/oracle-status [job-id]`
|
|
@@ -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
|
|
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
|
-
-
|
|
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
|
|
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:
|
|
@@ -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
|
|
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
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Invariants/Assumptions: Oracle only runs against persisted sessions, and startup maintenance should be best-effort without breaking session initialization.
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
|
-
import type { ExtensionAPI, ExtensionContext } from "@
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { loadOracleConfig } from "./lib/config.js";
|
|
10
10
|
import { registerOracleCommands } from "./lib/commands.js";
|
|
11
11
|
import { getSessionFile, pruneTerminalOracleJobs, reconcileStaleOracleJobs } from "./lib/jobs.js";
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Invariants/Assumptions: Commands operate on persisted project-scoped jobs and rely on shared observability formatting for detached-state clarity.
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@
|
|
8
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { formatOracleCancelOutcome, formatOracleJobSummary } from "../shared/job-observability-helpers.mjs";
|
|
10
10
|
import { runOracleAuthBootstrap } from "./auth.js";
|
|
11
11
|
import {
|
|
@@ -67,9 +67,9 @@ function readScopedJob(jobId: string, cwd: string) {
|
|
|
67
67
|
|
|
68
68
|
export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string, workerPath: string): void {
|
|
69
69
|
pi.registerCommand("oracle-auth", {
|
|
70
|
-
description: "Sync ChatGPT cookies from
|
|
70
|
+
description: "Sync ChatGPT cookies from the configured local browser profile into the oracle auth seed profile",
|
|
71
71
|
handler: async (_args, ctx) => {
|
|
72
|
-
ctx.ui.notify("Syncing ChatGPT cookies from
|
|
72
|
+
ctx.ui.notify("Syncing ChatGPT cookies from the configured local browser profile into the oracle auth seed profile…", "info");
|
|
73
73
|
try {
|
|
74
74
|
const result = await runOracleAuthBootstrap(authWorkerPath, ctx.cwd);
|
|
75
75
|
ctx.ui.notify(result, "info");
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { getAgentDir } from "@
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { isAbsolute, join, normalize } from "node:path";
|
|
11
11
|
import { getProjectId } from "./runtime.js";
|
|
12
12
|
|
|
@@ -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
|
|
296
|
+
return `Set ${authFields} in ${details.effectiveAuthConfigPath}.`;
|
|
291
297
|
}
|
|
292
298
|
return (
|
|
293
|
-
`Set
|
|
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
|
|
574
|
+
chromeCookiePath,
|
|
575
|
+
chromiumKeychain,
|
|
548
576
|
},
|
|
549
577
|
worker: {
|
|
550
578
|
pollMs: expectInteger(worker.pollMs, "worker.pollMs", 100),
|
|
@@ -7,7 +7,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
8
8
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
9
9
|
import { isAbsolute, join, relative as relativePath, resolve, sep } from "node:path";
|
|
10
|
-
import type { ExtensionContext } from "@
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import {
|
|
12
12
|
ACTIVE_ORACLE_JOB_STATUSES,
|
|
13
13
|
applyOracleJobCleanupWarnings,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Usage: Imported by the oracle extension entrypoint to start or stop per-session oracle polling.
|
|
5
5
|
// Invariants/Assumptions: Poller scans are serialized per session key, wake-up delivery is best-effort, and terminal-job notifications always re-read durable job state before send.
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
|
-
import type { ExtensionAPI, ExtensionContext } from "@
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { buildOracleStatusText, buildOracleWakeupNotificationContent } from "../shared/job-observability-helpers.mjs";
|
|
9
9
|
import { isProcessAlive, readProcessStartedAt } from "../shared/process-helpers.mjs";
|
|
10
10
|
import { isLockTimeoutError, listLeaseMetadata, releaseLease, withGlobalReconcileLock, writeLeaseMetadata } from "./locks.js";
|
|
@@ -246,7 +246,11 @@ async function scan(
|
|
|
246
246
|
}
|
|
247
247
|
if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
|
|
248
248
|
|
|
249
|
-
|
|
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
|
+
}
|
|
250
254
|
if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
|
|
251
255
|
|
|
252
256
|
const terminalJobs = listOracleJobDirs()
|
|
@@ -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(
|
|
@@ -8,7 +8,7 @@ import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/pr
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { basename, join, posix } from "node:path";
|
|
10
10
|
import { runOracleAuthBootstrap } from "./auth.js";
|
|
11
|
-
import type { ExtensionAPI, ExtensionContext } from "@
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { Type } from "typebox";
|
|
13
13
|
import { formatOracleCancelOutcome, formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
|
|
14
14
|
import { getLatestOracleJobLifecycleEvent, getLatestOracleTerminalLifecycleEvent, transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
|
|
@@ -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: "
|
|
66
|
+
pattern: "^.*\\S.*$",
|
|
67
67
|
}), {
|
|
68
68
|
description: "Exact project-relative files/directories to include in the oracle archive.",
|
|
69
69
|
minItems: 1,
|
|
@@ -1055,7 +1055,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1055
1055
|
pi.registerTool({
|
|
1056
1056
|
name: "oracle_auth",
|
|
1057
1057
|
label: "Oracle Auth",
|
|
1058
|
-
description: "Refresh the shared oracle auth seed profile by importing ChatGPT cookies from your configured
|
|
1058
|
+
description: "Refresh the shared oracle auth seed profile by importing ChatGPT cookies from your configured local browser profile.",
|
|
1059
1059
|
promptSnippet: "Refresh oracle auth before retrying a login-required oracle run.",
|
|
1060
1060
|
promptGuidelines: [
|
|
1061
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.",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Purpose: Bootstrap isolated oracle browser auth by importing real
|
|
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
|
|
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() {
|
|
@@ -462,15 +463,109 @@ function cookieSource() {
|
|
|
462
463
|
return config.auth.chromeCookiePath || config.auth.chromeProfile;
|
|
463
464
|
}
|
|
464
465
|
|
|
466
|
+
function usesConfiguredChromiumCookieSource() {
|
|
467
|
+
return Boolean(config.auth.chromeCookiePath && config.auth.chromiumKeychain);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function browserSourceName() {
|
|
471
|
+
if (config.browser.executablePath) return basename(config.browser.executablePath);
|
|
472
|
+
if (usesConfiguredChromiumCookieSource()) return "configured Chromium browser";
|
|
473
|
+
return "Google Chrome";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function keychainSummary() {
|
|
477
|
+
const keychain = config.auth.chromiumKeychain;
|
|
478
|
+
if (!keychain) return undefined;
|
|
479
|
+
const services = Array.isArray(keychain.services) && keychain.services.length > 0 ? keychain.services.join(", ") : "(none configured)";
|
|
480
|
+
const label = keychain.label || services;
|
|
481
|
+
return `${label} (account: ${keychain.account}, services: ${services})`;
|
|
482
|
+
}
|
|
483
|
+
|
|
465
484
|
function cookieSourceLabel() {
|
|
485
|
+
if (usesConfiguredChromiumCookieSource()) return `configured Chromium cookie DB ${config.auth.chromeCookiePath}`;
|
|
466
486
|
return config.auth.chromeCookiePath
|
|
467
487
|
? `Chrome cookie DB ${config.auth.chromeCookiePath}`
|
|
468
488
|
: `Chrome profile ${config.auth.chromeProfile}`;
|
|
469
489
|
}
|
|
470
490
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
491
|
+
function formatAuthSuccessMessage({ classificationMessage, appliedCount, targetDir }) {
|
|
492
|
+
const lines = [
|
|
493
|
+
"Oracle auth synced.",
|
|
494
|
+
"",
|
|
495
|
+
"Source:",
|
|
496
|
+
`- Browser: ${browserSourceName()}`,
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
if (config.auth.chromeCookiePath) lines.push(`- Cookie DB: ${config.auth.chromeCookiePath}`);
|
|
500
|
+
else lines.push(`- Profile: ${config.auth.chromeProfile}`);
|
|
501
|
+
|
|
502
|
+
const keychain = keychainSummary();
|
|
503
|
+
if (keychain) lines.push(`- Keychain: ${keychain}`);
|
|
504
|
+
lines.push(`- Cookies synced: ${appliedCount}`);
|
|
505
|
+
lines.push("", "Auth seed profile:", targetDir, "", "Diagnostics:", DIAGNOSTICS_DIR, "", "Status:", classificationMessage);
|
|
506
|
+
return lines.join("\n");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function formatAuthFailureGuidance(error) {
|
|
510
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
511
|
+
const lines = ["Oracle auth failed.", "", `Reason: ${reason}`, "", "Likely causes:"];
|
|
512
|
+
|
|
513
|
+
if (usesConfiguredChromiumCookieSource()) {
|
|
514
|
+
lines.push(
|
|
515
|
+
"- the configured cookie DB is stale or from the wrong browser profile",
|
|
516
|
+
"- ChatGPT is logged out in that browser profile",
|
|
517
|
+
"- auth.chromiumKeychain does not match the browser safe-storage item for that cookie DB",
|
|
518
|
+
"- the target browser was still running while /oracle-auth read its Cookies DB",
|
|
519
|
+
"",
|
|
520
|
+
"Next:",
|
|
521
|
+
"1. Open the configured browser profile.",
|
|
522
|
+
"2. Confirm ChatGPT works there.",
|
|
523
|
+
"3. Quit the browser fully.",
|
|
524
|
+
"4. Confirm auth.chromeCookiePath points at that profile's Cookies DB.",
|
|
525
|
+
"5. Confirm auth.chromiumKeychain.services names the browser's safe-storage Keychain service.",
|
|
526
|
+
"6. Re-run /oracle-auth.",
|
|
527
|
+
);
|
|
528
|
+
} else {
|
|
529
|
+
lines.push(
|
|
530
|
+
"- ChatGPT is logged out in the configured local browser profile",
|
|
531
|
+
"- auth.chromeProfile or auth.chromeCookiePath points at the wrong profile",
|
|
532
|
+
"- the profile cookie store is stale or unreadable",
|
|
533
|
+
"",
|
|
534
|
+
"Next:",
|
|
535
|
+
"1. Open the configured local browser profile.",
|
|
536
|
+
"2. Confirm ChatGPT works there.",
|
|
537
|
+
"3. Quit the browser fully.",
|
|
538
|
+
"4. Re-run /oracle-auth.",
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
lines.push(
|
|
543
|
+
"",
|
|
544
|
+
"Details:",
|
|
545
|
+
`- Source: ${cookieSourceLabel()}`,
|
|
546
|
+
`- Browser: ${browserSourceName()}`,
|
|
547
|
+
`- Auth seed profile: ${config.browser.authSeedProfileDir}`,
|
|
548
|
+
`- Diagnostics: ${DIAGNOSTICS_DIR || "(oracle-auth diagnostics dir unavailable)"}`,
|
|
549
|
+
`- Log: ${LOG_PATH}`,
|
|
550
|
+
"",
|
|
551
|
+
authConfigSummary(),
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
return lines.join("\n");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function readRawSourceCookies() {
|
|
558
|
+
if (config.auth.chromeCookiePath && config.auth.chromiumKeychain) {
|
|
559
|
+
return await getCookiesFromConfiguredChromiumSource({
|
|
560
|
+
dbPath: config.auth.chromeCookiePath,
|
|
561
|
+
keychain: config.auth.chromiumKeychain,
|
|
562
|
+
origins: cookieOrigins(),
|
|
563
|
+
profile: config.auth.chromeProfile,
|
|
564
|
+
timeoutMs: 5_000,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return await getCookies({
|
|
474
569
|
url: config.browser.chatUrl,
|
|
475
570
|
origins: cookieOrigins(),
|
|
476
571
|
browsers: ["chrome"],
|
|
@@ -478,9 +573,14 @@ async function readSourceCookies() {
|
|
|
478
573
|
chromeProfile: cookieSource(),
|
|
479
574
|
timeoutMs: 5_000,
|
|
480
575
|
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function readSourceCookies() {
|
|
579
|
+
await log(`Reading ChatGPT cookies from ${cookieSourceLabel()}`);
|
|
580
|
+
const { cookies, warnings } = await readRawSourceCookies();
|
|
481
581
|
|
|
482
582
|
if (warnings.length) {
|
|
483
|
-
await log(`
|
|
583
|
+
await log(`Cookie source warnings: ${warnings.join(" | ")}`);
|
|
484
584
|
}
|
|
485
585
|
|
|
486
586
|
const filtered = filterImportableAuthCookies(cookies, config.browser.chatUrl);
|
|
@@ -501,7 +601,7 @@ async function readSourceCookies() {
|
|
|
501
601
|
|
|
502
602
|
if (!hasSessionToken) {
|
|
503
603
|
throw new Error(
|
|
504
|
-
`No ChatGPT session-token cookies were found in ${cookieSourceLabel()}. Make sure ChatGPT is logged into that
|
|
604
|
+
`No ChatGPT session-token cookies were found in ${cookieSourceLabel()}. Make sure ChatGPT is logged into that browser profile. ${authConfigRemediation()}`,
|
|
505
605
|
);
|
|
506
606
|
}
|
|
507
607
|
|
|
@@ -809,9 +909,7 @@ async function run() {
|
|
|
809
909
|
const generation = new Date().toISOString();
|
|
810
910
|
await writeFile(join(profilePlan.targetDir, ".oracle-seed-generation"), `${generation}\n`, { encoding: "utf8", mode: 0o600 });
|
|
811
911
|
committedProfile = true;
|
|
812
|
-
process.stdout.write(
|
|
813
|
-
`${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}. Diagnostics: ${DIAGNOSTICS_DIR}`,
|
|
814
|
-
);
|
|
912
|
+
process.stdout.write(formatAuthSuccessMessage({ classificationMessage: classification.message, appliedCount, targetDir: profilePlan.targetDir }));
|
|
815
913
|
} catch (error) {
|
|
816
914
|
shouldPreserveBrowser = Boolean(error && typeof error === "object" && error.preserveBrowser === true);
|
|
817
915
|
await log(`Auth bootstrap failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -827,8 +925,6 @@ async function run() {
|
|
|
827
925
|
}
|
|
828
926
|
|
|
829
927
|
run().catch((error) => {
|
|
830
|
-
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.`,
|
|
832
|
-
);
|
|
928
|
+
process.stderr.write(formatAuthFailureGuidance(error));
|
|
833
929
|
process.exit(1);
|
|
834
930
|
});
|
|
@@ -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})
|
|
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})
|
|
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.
|
|
3
|
+
"version": "0.6.16",
|
|
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:@
|
|
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:@earendil-works/pi-coding-agent --external:@earendil-works/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",
|
|
@@ -55,19 +55,19 @@
|
|
|
55
55
|
"@steipete/sweet-cookie": "^0.2.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"
|
|
59
|
-
"
|
|
58
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
59
|
+
"typebox": "*"
|
|
60
60
|
},
|
|
61
61
|
"overrides": {
|
|
62
|
-
"basic-ftp": "
|
|
62
|
+
"basic-ftp": "6.0.1",
|
|
63
63
|
"protobufjs": "7.5.5"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@
|
|
67
|
-
"@types/node": "^25.6.
|
|
66
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
67
|
+
"@types/node": "^25.6.1",
|
|
68
68
|
"esbuild": "^0.28.0",
|
|
69
69
|
"tsx": "^4.21.0",
|
|
70
|
-
"typebox": "^1.1.
|
|
70
|
+
"typebox": "^1.1.38",
|
|
71
71
|
"typescript": "^6.0.3"
|
|
72
72
|
},
|
|
73
73
|
"engines": {
|
|
@@ -76,5 +76,5 @@
|
|
|
76
76
|
"os": [
|
|
77
77
|
"darwin"
|
|
78
78
|
],
|
|
79
|
-
"packageManager": "npm@
|
|
79
|
+
"packageManager": "npm@11.14.0"
|
|
80
80
|
}
|