@youdie006/prodex 0.40.17 → 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 +4 -2
- package/SECURITY.md +45 -0
- package/dist/bridge-gitignore.js +47 -0
- package/dist/browser-handoff.js +29 -16
- package/dist/browser-process.js +196 -0
- package/dist/chatgpt-browser.js +51 -63
- package/dist/cli-args.js +8 -2
- package/dist/cli-pro.js +19 -4
- package/dist/cli.js +2 -4
- package/dist/config.js +13 -25
- package/dist/repo.js +9 -2
- package/dist/safe-file.js +57 -13
- package/dist/store-writer.js +20 -5
- package/dist/store.js +19 -26
- package/dist/tui-run.js +1 -1
- package/docs/cli-reference.md +3 -1
- package/docs/platform-verification.md +164 -0
- package/package.json +7 -2
- package/prodex.mjs +11 -0
- package/scripts/npm-command.mjs +71 -0
- package/scripts/release-check.mjs +18 -11
- package/scripts/release-pack.mjs +13 -4
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
|
|
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?**
|
|
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
|
+
}
|
package/dist/browser-handoff.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -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: "
|
|
1593
|
-
message: `ChatGPT did not
|
|
1594
|
-
retryable:
|
|
1595
|
-
next_step: "
|
|
1596
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
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
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
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
|
-
|
|
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";
|
package/dist/cli-pro.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { BrowserProcessInspectionError } from "./browser-process.js";
|
|
4
5
|
import { buildDryRunBundle } from "./bundle.js";
|
|
5
6
|
import { ChatGptBrowserBlockerError, DEFAULT_CDP_PORT, resolveCdpPort, resolveConversationToDelete, resolveProjectToDelete, chatGptVisibilityBlocker, defaultChatGptProfileDir, formatDurationMs, getChatGptBrowserStatus, formatModelMenuOption, listChatGptModelOptions, deleteChatGptConversation, endWedgedBrowser, browserRecoveryPlan, findWedgedBrowser, wedgedBrowserBlocker, deleteChatGptProject, listChatGptProjectsWithIds, listRecentChatGptConversations, listChatGptSidebarProjects, normalizeChatGptTargetUrl, openChatGptBrowser, openChatGptTab, parseProMode, parseReasoningEffort, defaultTimeoutForTools, ensureVirtualDisplay, minimizeChatGptWindow, readLastBrowserLoginLaunch, resolveBrowserWindowMode, recordBrowserLoginLaunch, recoverChatGptAnswerFromThread, sendChatGptPrompt, statusMeansBrowserDead, namesPro, sendWarningsFromError, destinationVerification, chatGptProjectIdFromUrl } from "./chatgpt-browser.js";
|
|
6
7
|
import { ASK_PRO_BOOLEAN_FLAGS, ASK_PRO_PREVIEW_VALUE_FLAGS, ASK_PRO_VALUE_FLAGS, assertHelpRequestArgs, assertNoExtraArgs, assertOnlyOptions, findHelpFlagIndexBeforePromptDelimiter, formatCliCommand, hasAskProDryRunMode, hasAskProMode, hasAskProSendMode, isHelpSubcommand, parseAskProArgs, printHelpIfRequested, readFlag, readPortFlag, readPositionalsWithOptions, readNonNegativeIntegerFlag, readPositiveIntegerFlag, readRepeatedFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
|
|
@@ -3225,10 +3226,24 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
|
|
|
3225
3226
|
// report both as "not running": the browser really is gone, or it is still
|
|
3226
3227
|
// there and has stopped answering. The second keeps burning CPU until
|
|
3227
3228
|
// somebody notices, and nobody notices a message that says it is absent.
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3229
|
+
let blocker = browserStatus.blocker;
|
|
3230
|
+
if (statusMeansBrowserDead(browserStatus)) {
|
|
3231
|
+
try {
|
|
3232
|
+
const wedged = findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) });
|
|
3233
|
+
if (wedged.length > 0)
|
|
3234
|
+
blocker = wedgedBrowserBlocker(wedged, browserCommandOptions.port ?? DEFAULT_CDP_PORT);
|
|
3235
|
+
}
|
|
3236
|
+
catch (error) {
|
|
3237
|
+
if (!(error instanceof BrowserProcessInspectionError))
|
|
3238
|
+
throw error;
|
|
3239
|
+
blocker = {
|
|
3240
|
+
code: "browser_process_unverified",
|
|
3241
|
+
message: "The operating system could not verify browser process identity.",
|
|
3242
|
+
retryable: false,
|
|
3243
|
+
next_step: `Leave the existing browser unchanged. Check OS process-inspection permissions, then run \`${formatBrowserCheckCommand(sourceCli, browserCommandOptions)}\`.`
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3232
3247
|
io.stdout(`chatgpt: ${blocker?.code ?? "unreachable"} - ${blocker?.message ?? "browser is not reachable"}`);
|
|
3233
3248
|
const nextStep = productCheckBrowserNextStep(blocker?.next_step, sourceCli, browserCommandOptions);
|
|
3234
3249
|
if (nextStep)
|