@youdie006/prodex 0.40.16 → 0.40.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,7 +49,9 @@ That is a real run, timings included. Every consult lands as a task, a result an
49
49
 
50
50
  ## Install
51
51
 
52
- Node 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. A Chromium-family browser for the visible adapter: Chrome, Chromium, Edge or Brave on PATH, in the standard macOS and Windows locations, or on the Windows host under WSL are all found automatically; anything else via `PRODEX_CHROME=/path/to/browser`.
52
+ Node 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. A Chromium-family browser for the browser adapter: Chrome, Chromium, Edge or Brave on PATH or in the standard native macOS and Windows locations. Set `PRODEX_CHROME` for another executable. WSL uses a Linux browser; Windows-host Chrome is not automatically discovered or interchangeable with a Linux profile.
53
+
54
+ On native Windows, generated command examples target PowerShell (PowerShell 7 for commands joined with `&&`), not `cmd.exe`. CLI/MCP subprocesses pass arguments directly without a shell.
53
55
 
54
56
  ```sh
55
57
  npm install -g @youdie006/prodex
@@ -277,7 +279,7 @@ Reports are deduplicated by blocker code, so something that stays broken adds to
277
279
 
278
280
  **Does it read my cookies or tokens?** No. It talks to the browser only over the loopback DevTools port, and only while that browser is open.
279
281
 
280
- **Windows and macOS?** All three platforms are targeted; the visible-browser adapter is exercised most on Linux and WSL. Open an issue with details if a browser step misbehaves elsewhere.
282
+ **Windows and macOS?** The CI matrix targets Linux, native Windows, and macOS ARM/Intel. See [platform verification](docs/platform-verification.md) for actual results and limits. Virtual display is Linux/WSL-only; a passing OS test does not prove authenticated headless ChatGPT access. On Windows, keep the repository and browser profile in a private user directory with suitable ACLs; Unix permission bits do not make Windows storage private.
281
283
 
282
284
  ## Development
283
285
 
package/SECURITY.md ADDED
@@ -0,0 +1,45 @@
1
+ # Security Policy
2
+
3
+ `prodex` automates a **logged-in ChatGPT Pro browser session** and brokers tasks between
4
+ coding agents, so security reports are taken seriously.
5
+
6
+ ## Reporting a vulnerability
7
+
8
+ **Please do not open a public issue for security vulnerabilities.**
9
+
10
+ Report privately instead:
11
+
12
+ - Preferred: GitHub [private vulnerability reporting](https://github.com/youdie006/prodex/security/advisories/new)
13
+ - Include: affected version/commit, reproduction steps, and impact.
14
+
15
+ We aim to acknowledge within 5 business days and to coordinate a fix and disclosure timeline with you.
16
+
17
+ ## Design intent (what we consider in scope)
18
+
19
+ `prodex` is local-first and is designed never to expose your ChatGPT account, browser
20
+ session, or bridge endpoints to other users. Findings of particular interest:
21
+
22
+ - Credential, cookie, or session-token leakage from the visible-browser adapter
23
+ - Token-bearing MCP URLs being logged, printed, or otherwise exposed
24
+ - Bypass of the receipt-gated repo-write/apply/stage tools
25
+ - Any default that would let a third party reach your logged-in session or `.bridge` ledger
26
+ - Path traversal or arbitrary file access through the repo/file tools
27
+
28
+ Explicitly out of scope (these are intentional non-features, see the README): hidden ChatGPT
29
+ endpoints, cookie/token extraction, stealth automation, and public tunnel auto-setup.
30
+
31
+ ## Local storage permissions
32
+
33
+ On POSIX filesystems ProDex applies owner-only modes to bridge directories and
34
+ private files. Native Windows uses the directory's inherited Windows ACLs;
35
+ Unix `0700`/`0600` mode bits do not provide owner-only access there. ProDex does
36
+ not rewrite Windows ACLs. Keep the repository, `.bridge`, and browser profile
37
+ under a private user-owned directory, not a shared drive or a directory writable
38
+ by other users. Review the Windows Security permissions before storing private
39
+ prompts or credentials. The same caution applies to mounted filesystems that
40
+ ignore POSIX modes. Symlink/junction, file identity, and hard-link checks remain
41
+ enabled independently of permission modes.
42
+
43
+ ## Supported versions
44
+
45
+ `prodex` is pre-release (`0.x`). Security fixes target the latest `main`.
@@ -0,0 +1,47 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
3
+ const required = [
4
+ "tasks/*.json", "results/*.json", "sessions/*.json", "receipts/*.json",
5
+ "artifacts/*", "config.local.json", "receipt-key.local", "last-browser-send", "!.gitignore"
6
+ ];
7
+ export async function ensureBridgeGitignore(filePath, validate) {
8
+ let current = "";
9
+ try {
10
+ current = await readVerifiedUtf8File(filePath, validate);
11
+ }
12
+ catch (error) {
13
+ if (!hasCode(error, "ENOENT"))
14
+ throw error;
15
+ }
16
+ const updated = `${Array.from(new Set([...current.split(/\r?\n/).filter(Boolean), ...required])).join("\n")}\n`;
17
+ if (updated === current) {
18
+ try {
19
+ await validate();
20
+ const stat = await lstat(filePath);
21
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink > 1) {
22
+ throw new Error("Bridge gitignore must be a regular file without links");
23
+ }
24
+ return;
25
+ }
26
+ catch (error) {
27
+ if (!hasCode(error, "ENOENT"))
28
+ throw error;
29
+ }
30
+ }
31
+ try {
32
+ await writeVerifiedUtf8File(filePath, updated, validate, { create: true });
33
+ }
34
+ catch (error) {
35
+ // Windows may refuse replacement while another initializer holds the file.
36
+ // A verified, identical completed write already satisfies this operation.
37
+ if (process.platform === "win32" && hasCode(error, "EPERM")) {
38
+ const installed = await readVerifiedUtf8File(filePath, validate).catch(() => undefined);
39
+ if (installed === updated)
40
+ return;
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ function hasCode(error, code) {
46
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
47
+ }
@@ -1,8 +1,8 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { realpathSync } from "node:fs";
3
2
  import path from "node:path";
4
3
  import WebSocket from "ws";
5
- import { ChatGptBrowserBlockerError, attachmentPresenceExpression, composerTextStateExpression, detectChatGptPageBlocker, findLaunchedBrowserProcesses, inferChatGptPageLoggedInLikely, statusExpression } from "./chatgpt-browser.js";
4
+ import { BrowserProcessInspectionError, browserProcessFlagValue, browserProcessHasFlag, findMatchingBrowserProcesses, inspectBrowserProcesses, isMainBrowserProcess } from "./browser-process.js";
5
+ import { ChatGptBrowserBlockerError, attachmentPresenceExpression, composerTextStateExpression, detectChatGptPageBlocker, inferChatGptPageLoggedInLikely, statusExpression } from "./chatgpt-browser.js";
6
6
  const VISIBLE_AUTH_BLOCKERS = new Set(["login_required", "cloudflare_check", "captcha_required", "permission_required"]);
7
7
  function blocked(message) {
8
8
  throw new ChatGptBrowserBlockerError({
@@ -11,21 +11,25 @@ function blocked(message) {
11
11
  });
12
12
  }
13
13
  function browserIdentity(port, profileDir) {
14
- const listed = spawnSync("ps", ["-Ao", "user,pid,command"], { encoding: "utf8", timeout: 5_000 });
15
- if (listed.status !== 0 || typeof listed.stdout !== "string")
14
+ let processes;
15
+ try {
16
+ processes = inspectBrowserProcesses();
17
+ }
18
+ catch (error) {
19
+ if (!(error instanceof BrowserProcessInspectionError))
20
+ throw error;
16
21
  blocked("Could not verify the dedicated browser process.");
17
- const pids = findLaunchedBrowserProcesses(listed.stdout, { port, profileDir });
18
- const mains = listed.stdout.split(/\r?\n/).filter((line) => {
19
- const pid = Number(/^\s*\S+\s+(\d+)\s/.exec(line)?.[1]);
20
- return pids.includes(pid) && !/\s--type=/.test(line) && new RegExp(`--remote-debugging-port=${port}(?!\\d)`).test(line);
21
- });
22
+ }
23
+ const matching = findMatchingBrowserProcesses(processes, { port, profileDir });
24
+ const mains = matching.filter(isMainBrowserProcess);
22
25
  if (mains.length !== 1)
23
26
  blocked("Could not identify exactly one dedicated browser for this port.");
24
- if (/\s--(?:incognito|guest)(?:\s|=|$)/.test(mains[0]))
27
+ const main = mains[0];
28
+ if (browserProcessHasFlag(main, "incognito") || browserProcessHasFlag(main, "guest"))
25
29
  blocked("An incognito or guest browser cannot preserve its login through a restart.");
26
- if (/\s--profile-directory(?:\s|=|$)/.test(mains[0]))
30
+ if (browserProcessHasFlag(main, "profile-directory"))
27
31
  blocked("An explicitly selected Chrome sub-profile cannot be preserved by this handoff; no browser was closed.");
28
- const actualProfile = /--user-data-dir=(.*?)(?=\s--|$)/.exec(mains[0])?.[1];
32
+ const actualProfile = browserProcessFlagValue(main, "user-data-dir");
29
33
  if (!actualProfile || !path.isAbsolute(actualProfile))
30
34
  blocked("The browser profile could not be verified.");
31
35
  try {
@@ -37,7 +41,11 @@ function browserIdentity(port, profileDir) {
37
41
  throw error;
38
42
  blocked("The browser profile path could not be verified.");
39
43
  }
40
- return { main: Number(/^\s*\S+\s+(\d+)\s/.exec(mains[0])[1]), pids, headless: /\s--headless(?:\s|=|$)/.test(mains[0]) };
44
+ return {
45
+ main: main.processId,
46
+ pids: matching.map((processInfo) => processInfo.processId),
47
+ headless: browserProcessHasFlag(main, "headless")
48
+ };
41
49
  }
42
50
  export function getDedicatedBrowserHeadlessMode(options) {
43
51
  return browserIdentity(options.port, options.profileDir).headless;
@@ -209,10 +217,15 @@ function alive(pid) {
209
217
  }
210
218
  }
211
219
  function matchingBrowserPids(port, profileDir) {
212
- const listed = spawnSync("ps", ["-Ao", "user,pid,command"], { encoding: "utf8", timeout: 5_000 });
213
- if (listed.status !== 0 || typeof listed.stdout !== "string")
220
+ try {
221
+ return findMatchingBrowserProcesses(inspectBrowserProcesses(), { port, profileDir })
222
+ .map((processInfo) => processInfo.processId);
223
+ }
224
+ catch (error) {
225
+ if (!(error instanceof BrowserProcessInspectionError))
226
+ throw error;
214
227
  blocked("Could not verify that the dedicated browser stayed closed.");
215
- return findLaunchedBrowserProcesses(listed.stdout, { port, profileDir });
228
+ }
216
229
  }
217
230
  async function waitForQuietShutdown(identity, port, profileDir) {
218
231
  const deadline = Date.now() + 12_000;
@@ -0,0 +1,196 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ export class BrowserProcessInspectionError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "BrowserProcessInspectionError";
7
+ }
8
+ }
9
+ const WINDOWS_CIM_SCRIPT = [
10
+ "$ErrorActionPreference = 'Stop'",
11
+ "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
12
+ "$items = @(Get-CimInstance -ClassName Win32_Process -Filter \"Name = 'chrome.exe' OR Name = 'chromium.exe' OR Name = 'msedge.exe' OR Name = 'brave.exe'\" -Property ExecutablePath,ProcessId,CommandLine | Select-Object ExecutablePath,ProcessId,CommandLine)",
13
+ "ConvertTo-Json -Compress -InputObject $items"
14
+ ].join("; ");
15
+ const WINDOWS_PROCESS_ARGS = [
16
+ "-NoLogo",
17
+ "-NoProfile",
18
+ "-NonInteractive",
19
+ "-Command",
20
+ WINDOWS_CIM_SCRIPT
21
+ ];
22
+ const POSIX_MAIN_EXECUTABLES = [
23
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
24
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
25
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
26
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
27
+ ];
28
+ const MAC_HELPER_EXECUTABLE = /^\/Applications\/(?:Google Chrome|Chromium|Microsoft Edge|Brave Browser)\.app\/Contents\/Frameworks\/.*?\/Helpers\/(?:Google Chrome|Chromium|Microsoft Edge|Brave Browser) Helper(?: \([^)]*\))?/i;
29
+ const BROWSER_BASENAME = /^(?:google[ -]?chrome|chromium(?:-browser)?|chrome|microsoft[ -]edge|msedge|brave[ -]browser|brave)(?:\.exe)?$/i;
30
+ function resultText(value) {
31
+ if (typeof value === "string")
32
+ return value;
33
+ if (Buffer.isBuffer(value))
34
+ return value.toString("utf8");
35
+ return undefined;
36
+ }
37
+ function defaultProcessListRunner(command, args, options) {
38
+ return spawnSync(command, args, options);
39
+ }
40
+ /** Read process identity without passing a port, profile, or other user data to a shell. */
41
+ export function inspectBrowserProcesses(options = {}) {
42
+ const platform = options.platform ?? process.platform;
43
+ const run = options.run ?? defaultProcessListRunner;
44
+ const command = platform === "win32" ? "powershell.exe" : "ps";
45
+ const args = platform === "win32" ? WINDOWS_PROCESS_ARGS : ["-Ao", "user,pid,command"];
46
+ const listed = run(command, [...args], {
47
+ encoding: "utf8",
48
+ timeout: 10_000,
49
+ maxBuffer: 8 * 1024 * 1024,
50
+ windowsHide: true
51
+ });
52
+ const stdout = resultText(listed.stdout);
53
+ if (listed.error || listed.status !== 0 || stdout === undefined) {
54
+ throw new BrowserProcessInspectionError("Could not inspect browser processes.");
55
+ }
56
+ return platform === "win32" ? parseWindowsCimProcessJson(stdout) : parsePosixProcessList(stdout);
57
+ }
58
+ export function parseWindowsCimProcessJson(raw) {
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(raw.trim() || "[]");
62
+ }
63
+ catch {
64
+ throw new BrowserProcessInspectionError("Windows browser process inspection returned invalid JSON.");
65
+ }
66
+ const entries = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" ? [parsed] : undefined;
67
+ if (!entries)
68
+ throw new BrowserProcessInspectionError("Windows browser process inspection returned an invalid result.");
69
+ return entries.map((entry) => {
70
+ if (!entry || typeof entry !== "object") {
71
+ throw new BrowserProcessInspectionError("Windows could not provide complete browser process identity.");
72
+ }
73
+ const record = entry;
74
+ if (typeof record.ExecutablePath !== "string" || record.ExecutablePath.length === 0 ||
75
+ !Number.isInteger(record.ProcessId) || Number(record.ProcessId) <= 0 ||
76
+ typeof record.CommandLine !== "string" || record.CommandLine.length === 0) {
77
+ throw new BrowserProcessInspectionError("Windows could not provide complete browser process identity.");
78
+ }
79
+ return {
80
+ executablePath: record.ExecutablePath,
81
+ processId: Number(record.ProcessId),
82
+ commandLine: record.CommandLine
83
+ };
84
+ });
85
+ }
86
+ export function parsePosixProcessList(raw) {
87
+ const processes = [];
88
+ for (const line of raw.split(/\r?\n/)) {
89
+ const match = /^\s*\S+\s+(\d+)\s+(.+?)\s*$/.exec(line);
90
+ if (!match)
91
+ continue;
92
+ const processId = Number(match[1]);
93
+ const commandLine = match[2];
94
+ const executablePath = posixBrowserExecutable(commandLine);
95
+ if (!executablePath || !Number.isSafeInteger(processId) || processId <= 0)
96
+ continue;
97
+ processes.push({ executablePath, processId, commandLine });
98
+ }
99
+ return processes;
100
+ }
101
+ function posixBrowserExecutable(commandLine) {
102
+ for (const executable of POSIX_MAIN_EXECUTABLES) {
103
+ if (commandLine === executable || (commandLine.startsWith(executable) && /^\s--/.test(commandLine.slice(executable.length)))) {
104
+ return executable;
105
+ }
106
+ }
107
+ const helper = MAC_HELPER_EXECUTABLE.exec(commandLine)?.[0];
108
+ if (helper && (helper.length === commandLine.length || /^\s--/.test(commandLine.slice(helper.length))))
109
+ return helper;
110
+ const firstToken = commandLine.split(/\s+/, 1)[0];
111
+ return BROWSER_BASENAME.test(path.posix.basename(firstToken)) ? firstToken : undefined;
112
+ }
113
+ function flagValue(commandLine, flag) {
114
+ const escapedFlag = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
+ const wholeArgumentQuoted = new RegExp(`(?:^|\\s)\"--${escapedFlag}=([^\"]*)\"(?=\\s|$)`, "i").exec(commandLine);
116
+ if (wholeArgumentQuoted)
117
+ return wholeArgumentQuoted[1].trim();
118
+ const match = new RegExp(`(?:^|\\s)--${escapedFlag}=(?:\"([^\"]*)\"|'([^']*)'|(.+?))(?=\\s+\"?--[A-Za-z0-9-]+(?:=|\\s|$)|\\s*$)`, "i").exec(commandLine);
119
+ return match ? (match[1] ?? match[2] ?? match[3])?.trim() : undefined;
120
+ }
121
+ export function browserProcessFlagValue(processInfo, flag) {
122
+ return flagValue(processInfo.commandLine, flag);
123
+ }
124
+ export function browserProcessHasFlag(processInfo, flag) {
125
+ const escapedFlag = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ return new RegExp(`(?:^|\\s)(?:\"--${escapedFlag}(?:=[^\"]*)?\"|--${escapedFlag})(?==|\\s|$)`, "i").test(processInfo.commandLine);
127
+ }
128
+ function isMainBrowserExecutable(processInfo) {
129
+ const normalized = processInfo.executablePath.replaceAll("\\", "/");
130
+ if (/\/Helpers\//i.test(normalized))
131
+ return false;
132
+ return BROWSER_BASENAME.test(path.posix.basename(normalized)) || POSIX_MAIN_EXECUTABLES.includes(normalized);
133
+ }
134
+ export function isMainBrowserProcess(processInfo) {
135
+ return isMainBrowserExecutable(processInfo) && !browserProcessHasFlag(processInfo, "type");
136
+ }
137
+ export function assertLaunchedBrowserMainProcess(processes, launchedProcessId) {
138
+ const mains = processes.filter(isMainBrowserProcess);
139
+ if (!Number.isSafeInteger(launchedProcessId) || launchedProcessId <= 0 ||
140
+ mains.length !== 1 || mains[0].processId !== launchedProcessId) {
141
+ throw new BrowserProcessInspectionError("The inspected browser main process does not match the process launched by prodex.");
142
+ }
143
+ return mains[0];
144
+ }
145
+ function isBrowserProcess(processInfo) {
146
+ const normalized = processInfo.executablePath.replaceAll("\\", "/");
147
+ return BROWSER_BASENAME.test(path.posix.basename(normalized)) || MAC_HELPER_EXECUTABLE.test(normalized);
148
+ }
149
+ function normalizedProfile(value, platform) {
150
+ if (platform === "win32")
151
+ return path.win32.normalize(value).toLocaleLowerCase("en-US");
152
+ return path.posix.normalize(value);
153
+ }
154
+ export function findMatchingBrowserProcesses(processes, input) {
155
+ const platform = input.platform ?? process.platform;
156
+ const expectedProfile = normalizedProfile(input.profileDir, platform);
157
+ const sameProfile = (processInfo) => {
158
+ const profile = browserProcessFlagValue(processInfo, "user-data-dir");
159
+ return profile !== undefined && normalizedProfile(profile, platform) === expectedProfile;
160
+ };
161
+ const mains = processes.filter((processInfo) => {
162
+ if (!isMainBrowserProcess(processInfo) || !sameProfile(processInfo))
163
+ return false;
164
+ const port = browserProcessFlagValue(processInfo, "remote-debugging-port");
165
+ return port !== undefined && Number(port) === input.port && /^\d+$/.test(port);
166
+ });
167
+ if (mains.length === 0)
168
+ return [];
169
+ const mainIds = new Set(mains.map((processInfo) => processInfo.processId));
170
+ const children = processes.filter((processInfo) => {
171
+ return !mainIds.has(processInfo.processId) && isBrowserProcess(processInfo) && sameProfile(processInfo);
172
+ });
173
+ return [...mains, ...children];
174
+ }
175
+ export function findBrowserProcessesByPort(processes, input) {
176
+ const mains = processes.filter((processInfo) => {
177
+ if (!isMainBrowserProcess(processInfo))
178
+ return false;
179
+ const port = browserProcessFlagValue(processInfo, "remote-debugging-port");
180
+ return port !== undefined && /^\d+$/.test(port) && Number(port) === input.port;
181
+ });
182
+ if (mains.length === 0)
183
+ return [];
184
+ if (mains.length !== 1) {
185
+ throw new BrowserProcessInspectionError("More than one browser main process claims the requested debugging port.");
186
+ }
187
+ const profileDir = browserProcessFlagValue(mains[0], "user-data-dir") ?? input.fallbackProfileDir;
188
+ if (!profileDir) {
189
+ throw new BrowserProcessInspectionError("The browser on the requested debugging port did not expose its profile identity.");
190
+ }
191
+ return findMatchingBrowserProcesses(processes, {
192
+ platform: input.platform,
193
+ port: input.port,
194
+ profileDir
195
+ });
196
+ }
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import WsWebSocket from "ws";
8
8
  import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
9
9
  import { withCrossProcessFileLock, writeVerifiedUtf8File } from "./safe-file.js";
10
+ import { findBrowserProcessesByPort, findMatchingBrowserProcesses, inspectBrowserProcesses, parsePosixProcessList } from "./browser-process.js";
10
11
  import os from "node:os";
11
12
  import { answeredDialogWarning, chatSurfaceState, effortNeedsWorkSurface, javascriptDialogResponse, menuKeyboardStep, readPowerSliderSelection, sliderRestoreStep, surfaceFromProbe, sliderPressOutcome, sliderDidNotRespond } from "./picker-interaction.js";
12
13
  import { projectsWithIdsExpression, recentConversationTitlesExpression } from "./tui.js";
@@ -996,6 +997,20 @@ export function detectChatGptBlocker(text, visibleButtonLabels = []) {
996
997
  }
997
998
  return undefined;
998
999
  }
1000
+ function looksLikeObservedCloudflare502(text) {
1001
+ const normalized = text.replace(/\s+/g, " ").trim();
1002
+ return (/\bbad gateway\b/i.test(normalized) &&
1003
+ /\berror code 502\b/i.test(normalized) &&
1004
+ /\bvisit cloudflare\.com for more information\b/i.test(normalized));
1005
+ }
1006
+ function chatGptServiceErrorBlocker() {
1007
+ return {
1008
+ code: "chatgpt_service_error",
1009
+ message: "ChatGPT rendered a Cloudflare 502 Bad Gateway service error. This is not evidence that the ChatGPT session expired.",
1010
+ retryable: true,
1011
+ next_step: "Wait for the ChatGPT service to recover, then check the original conversation before retrying."
1012
+ };
1013
+ }
999
1014
  export function detectChatGptPageBlocker(state) {
1000
1015
  // Blocker scan uses the nav-excluded sample so a sidebar chat title cannot
1001
1016
  // fake a blocker; fall back to the nav-included sample / full text when the
@@ -1003,6 +1018,15 @@ export function detectChatGptPageBlocker(state) {
1003
1018
  const rendered = detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
1004
1019
  if (rendered)
1005
1020
  return rendered;
1021
+ // Cloudflare's 502 page is an upstream service failure, not a challenge or
1022
+ // evidence that the ChatGPT session expired. Match only the measured
1023
+ // template in message-excluded page text. A status read proves the composer
1024
+ // is absent; answer polling has no composer field, so its equally narrow
1025
+ // evidence is the dedicated nav-and-message-excluded blocker scan.
1026
+ const messageExcluded = state.blockerScanTextSample ?? state.blockerTextSample;
1027
+ if (state.hasComposer !== true && messageExcluded !== undefined && looksLikeObservedCloudflare502(messageExcluded)) {
1028
+ return chatGptServiceErrorBlocker();
1029
+ }
1006
1030
  // The interstitial can have an empty body. Never use a conversation title
1007
1031
  // alone when the composer exists or its state was not actually checked.
1008
1032
  if (state.hasComposer === false && /^(?:just a moment|잠시만 기다리십시오)(?:\.{0,3}|…)$/i.test(state.title?.trim() ?? "")) {
@@ -1375,6 +1399,7 @@ export function openChatGptBrowser(options = {}) {
1375
1399
  return {
1376
1400
  command,
1377
1401
  args,
1402
+ processId: child.pid,
1378
1403
  profileDir,
1379
1404
  port,
1380
1405
  waitForEarlyExit: (timeoutMs = 1000) => {
@@ -1544,6 +1569,9 @@ async function openChatGptThread(cdp, url) {
1544
1569
  const conversationId = conversationIdFromThreadUrl(url);
1545
1570
  if (!conversationId)
1546
1571
  throw new Error(`Not a ChatGPT conversation URL: ${url}`);
1572
+ // A same-thread follow-up must not discard a ready composer and start a new load.
1573
+ if (await cdp.evaluate(chatGptThreadReadyExpression(conversationId)))
1574
+ return;
1547
1575
  await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
1548
1576
  const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
1549
1577
  while (Date.now() < deadline) {
@@ -1559,17 +1587,12 @@ async function openChatGptThread(cdp, url) {
1559
1587
  throw error;
1560
1588
  }
1561
1589
  }
1590
+ const state = await cdp.evaluate(statusExpression());
1591
+ const blocker = detectChatGptPageBlocker(state);
1592
+ if (blocker)
1593
+ throw new ChatGptBrowserBlockerError({ ...blocker, thread: url });
1562
1594
  throw new ChatGptBrowserBlockerError(chatGptThreadUnavailableBlocker(url));
1563
1595
  }
1564
- /**
1565
- * The conversation a follow-up names cannot be opened.
1566
- *
1567
- * Retrying cannot undelete a thread, and the generic "resolve the visible
1568
- * browser issue manually" this used to fall back to describes a browser that
1569
- * is working fine - measured on a thread whose project had been deleted: the
1570
- * cause was named in the message and then thrown away by the catch-all next
1571
- * step underneath it.
1572
- */
1573
1596
  /**
1574
1597
  * The composer's model selector never rendered.
1575
1598
  *
@@ -1589,11 +1612,11 @@ export function chatGptComposerNotReadyBlocker(reason) {
1589
1612
  }
1590
1613
  export function chatGptThreadUnavailableBlocker(url) {
1591
1614
  return {
1592
- code: "thread_unavailable",
1593
- message: `ChatGPT did not open the conversation to continue (${url}). It may have been deleted, or its project was.`,
1594
- retryable: false,
1595
- next_step: "That conversation cannot be reached, and retrying will not bring it back. Send without --continue to start a new one, " +
1596
- "or name a different consult with --continue-task <task_id> (`prodex pro list` shows them).",
1615
+ code: "thread_not_ready",
1616
+ message: `ChatGPT did not finish opening the conversation to continue (${url}) within the readiness wait. Nothing was sent.`,
1617
+ retryable: true,
1618
+ next_step: "Wait for that conversation to finish loading and check its readiness before retrying the same --continue-task. " +
1619
+ "If it remains unavailable, inspect its access or project state; a loading timeout alone does not prove deletion.",
1597
1620
  thread: url
1598
1621
  };
1599
1622
  }
@@ -1859,7 +1882,9 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
1859
1882
  */
1860
1883
  export function chatGptThreadReadyExpression(conversationId) {
1861
1884
  return `(() => {${composerExpressionHelpers()}
1862
- if (!location.href.includes(${JSON.stringify(conversationId)})) return false;
1885
+ const current = new URL(location.href);
1886
+ const match = /\\/c\\/([0-9a-f-]{16,})\\/?$/i.exec(current.pathname);
1887
+ if (current.origin !== "https://chatgpt.com" || match?.[1].toLowerCase() !== ${JSON.stringify(conversationId.toLowerCase())}) return false;
1863
1888
  return Boolean(findChatGptComposerCandidate());
1864
1889
  })()`;
1865
1890
  }
@@ -4031,47 +4056,11 @@ export function resolveConversationToDelete(conversations, request) {
4031
4056
  * The port cannot tell a dead browser from an absent one; the process list can.
4032
4057
  */
4033
4058
  export function findLaunchedBrowserProcesses(psOutput, input) {
4034
- // `ps -Ao user,pid,command` leads with a user NAME, not a uid.
4035
- const pidOf = (line) => {
4036
- const match = /^\s*\S+\s+(\d+)\s/.exec(line);
4037
- return match ? Number(match[1]) : undefined;
4038
- };
4039
- // Mentioning the flag is not being the browser: a shell, an editor, or the
4040
- // very tool running this scan can carry it on its command line, and this list
4041
- // is what gets SIGTERM. Caught live - the probe matched its own node process.
4042
- const isBrowserCommand = (line) => {
4043
- const command = line.replace(/^\s*\S+\s+\d+\s+/, "").trim();
4044
- // Linux/PATH executables cannot contain spaces, so only the first token is
4045
- // eligible. This keeps a node/shell argument that names a browser from
4046
- // becoming a process prodex may terminate.
4047
- const firstToken = command.split(/\s+/, 1)[0];
4048
- if (/(^|[/\\])(google[ -]?chrome(?:\.exe)?|chromium(?:-browser)?|chrome(?:\.exe)?|microsoft[ -]edge|msedge\.exe|brave[ -]browser)$/i.test(firstToken)) {
4049
- return true;
4050
- }
4051
- // macOS app executables and helpers have spaces in their absolute path.
4052
- // Match only anchored, known bundle layouts and require the next token to
4053
- // be a flag (or end-of-line), never arbitrary argument text.
4054
- return /^\/Applications\/(?:Google Chrome\.app\/Contents\/MacOS\/Google Chrome|Chromium\.app\/Contents\/MacOS\/Chromium|Microsoft Edge\.app\/Contents\/MacOS\/Microsoft Edge|Brave Browser\.app\/Contents\/MacOS\/Brave Browser|(?:Google Chrome|Chromium|Microsoft Edge|Brave Browser)\.app\/Contents\/Frameworks\/.*?\/Helpers\/(?:Google Chrome|Chromium|Microsoft Edge|Brave Browser) Helper(?: \([^)]*\))?)(?=\s--|$)/i.test(command);
4055
- };
4056
- const lines = psOutput.split(/\r?\n/).filter((line) => !/\bgrep\b/.test(line) && isBrowserCommand(line));
4057
- // Exactly this port: a plain substring test let port 9 match 9333.
4058
- const portFlag = new RegExp(`--remote-debugging-port=${input.port}(?!\\d)`);
4059
- const mains = lines.filter((line) => portFlag.test(line));
4060
- // The port is the instance's identity. A browser sharing the profile while
4061
- // listening on another port belongs to someone else, and treating it as ours
4062
- // made a check against an unused port report a healthy Chrome as wedged.
4063
- if (mains.length === 0)
4064
- return [];
4065
- // Which profile the helpers belong to is the browser's answer, not the
4066
- // caller's: `check` has only a port, and matching against the profile it
4067
- // assumed both missed this browser's renderers and collected a stranger's.
4068
- // Read it off the process that answered to the port; fall back to what the
4069
- // caller passed only when the command line does not say.
4070
- // Stop at the next flag, so a profile path containing spaces survives.
4071
- const profileOf = (line) => /--user-data-dir=(.*?)(?=\s+-{1,2}\w|\s*$)/.exec(line)?.[1];
4072
- const profileDir = profileOf(mains[0]) ?? input.profileDir;
4073
- const helpers = profileDir.length > 0 ? lines.filter((line) => profileOf(line) === profileDir && !mains.includes(line)) : [];
4074
- return [...mains, ...helpers].map(pidOf).filter((pid) => pid !== undefined);
4059
+ return findBrowserProcessesByPort(parsePosixProcessList(psOutput), {
4060
+ platform: "linux",
4061
+ port: input.port,
4062
+ fallbackProfileDir: input.profileDir
4063
+ }).map((processInfo) => processInfo.processId);
4075
4064
  }
4076
4065
  /**
4077
4066
  * A browser that is running but deaf is a different problem from one that is
@@ -4084,13 +4073,12 @@ export function findLaunchedBrowserProcesses(psOutput, input) {
4084
4073
  */
4085
4074
  export function findWedgedBrowser(input = {}) {
4086
4075
  const port = resolveCdpPort(input.port);
4087
- const profileDir = input.profileDir ?? defaultChatGptProfileDir();
4088
- // -A over every user's processes is deliberate: the browser may have been
4089
- // launched by another shell session than the one asking.
4090
- const listed = spawnSync("ps", ["-Ao", "user,pid,command"], { encoding: "utf8", timeout: 10_000 });
4091
- if (listed.status !== 0 || typeof listed.stdout !== "string")
4092
- return [];
4093
- return findLaunchedBrowserProcesses(listed.stdout, { port, profileDir });
4076
+ const processes = inspectBrowserProcesses();
4077
+ const matching = input.profileDir === undefined
4078
+ ? findBrowserProcessesByPort(processes, { port, fallbackProfileDir: defaultChatGptProfileDir() })
4079
+ : findMatchingBrowserProcesses(processes, { port, profileDir: input.profileDir });
4080
+ return matching
4081
+ .map((processInfo) => processInfo.processId);
4094
4082
  }
4095
4083
  const realSignals = {
4096
4084
  kill: (pid, signal) => process.kill(pid, signal),
package/dist/cli-args.js CHANGED
@@ -104,8 +104,14 @@ export function editDistance(left, right) {
104
104
  }
105
105
  return previous[right.length];
106
106
  }
107
- export function shellQuote(value) {
108
- return /^[A-Za-z0-9_./:@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
107
+ export function shellQuote(value, platform = process.platform) {
108
+ const shellSafe = /^[A-Za-z0-9_./:@=-]+$/.test(value);
109
+ if (shellSafe && (platform !== "win32" || !value.startsWith("@")))
110
+ return value;
111
+ const escaped = platform === "win32"
112
+ ? value.replaceAll("'", "''")
113
+ : value.replaceAll("'", "'\\''");
114
+ return `'${escaped}'`;
109
115
  }
110
116
  export function formatCliCommand(sourceCli) {
111
117
  return sourceCli ? `node ${shellQuote(sourceCli)}` : "prodex";