@youdie006/prodex 0.19.0 → 0.19.2
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/dist/chatgpt-browser.js +68 -9
- package/dist/cli-server.js +6 -3
- package/dist/cli.js +5 -2
- package/dist/config.js +65 -6
- package/dist/store.js +21 -0
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -1729,7 +1729,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
1729
1729
|
// into; a --project/--project-new hop lands on a page with its own counts.
|
|
1730
1730
|
beforeSubmit = await evaluateOnPage(page, answerExpression());
|
|
1731
1731
|
dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
|
|
1732
|
-
await insertComposerTextViaCdp(cdp, options.prompt);
|
|
1732
|
+
await insertComposerTextViaCdp(cdp, options.prompt, page);
|
|
1733
1733
|
// The send button renders asynchronously after the prompt lands. Poll for it
|
|
1734
1734
|
// BEFORE submitting so (a) submitButtonFound reflects whether the control
|
|
1735
1735
|
// actually EXISTS - otherwise a successful Enter-key submit skips the fallback
|
|
@@ -2263,6 +2263,36 @@ export function prepareComposerExpression() {
|
|
|
2263
2263
|
// just that it is non-empty): a failed clear would leave stale text prepended,
|
|
2264
2264
|
// silently submitting a contaminated prompt. Whitespace is collapsed on both
|
|
2265
2265
|
// sides because ProseMirror round-trips newlines as extra blank lines.
|
|
2266
|
+
/**
|
|
2267
|
+
* Insert the whole prompt with ONE in-page execCommand("insertText"). The
|
|
2268
|
+
* editor applies it as a single input event, so - unlike a chunked
|
|
2269
|
+
* Input.insertText sequence - nothing can interleave at a boundary and the
|
|
2270
|
+
* text lands byte-for-byte. Used for prompts too large to push through
|
|
2271
|
+
* Input.insertText in one CDP command.
|
|
2272
|
+
*/
|
|
2273
|
+
export function insertComposerTextInPageExpression(text) {
|
|
2274
|
+
const textJson = JSON.stringify(text);
|
|
2275
|
+
return `(() => {
|
|
2276
|
+
${composerExpressionHelpers()}
|
|
2277
|
+
const el = findChatGptComposerCandidate();
|
|
2278
|
+
if (!el) return { ok: false, reason: "No visible composer" };
|
|
2279
|
+
el.focus();
|
|
2280
|
+
if ("value" in el) {
|
|
2281
|
+
el.value = ${textJson};
|
|
2282
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2283
|
+
return { ok: true };
|
|
2284
|
+
}
|
|
2285
|
+
const selection = window.getSelection();
|
|
2286
|
+
const all = document.createRange();
|
|
2287
|
+
all.selectNodeContents(el);
|
|
2288
|
+
selection.removeAllRanges();
|
|
2289
|
+
selection.addRange(all);
|
|
2290
|
+
document.execCommand("delete");
|
|
2291
|
+
const inserted = document.execCommand("insertText", false, ${textJson});
|
|
2292
|
+
if (!inserted) return { ok: false, reason: "The ChatGPT composer refused the prompt text" };
|
|
2293
|
+
return { ok: true };
|
|
2294
|
+
})()`;
|
|
2295
|
+
}
|
|
2266
2296
|
export function composerTextStateExpression(expectedText) {
|
|
2267
2297
|
const expectedJson = JSON.stringify(expectedText ?? null);
|
|
2268
2298
|
return `(() => {
|
|
@@ -2283,7 +2313,7 @@ export function composerTextStateExpression(expectedText) {
|
|
|
2283
2313
|
// Focus the composer, clear any leftover text submit-safely, type the prompt
|
|
2284
2314
|
// with native CDP input so ProseMirror registers it, then verify the composer
|
|
2285
2315
|
// holds exactly the prompt.
|
|
2286
|
-
async function insertComposerTextViaCdp(cdp, text) {
|
|
2316
|
+
async function insertComposerTextViaCdp(cdp, text, page) {
|
|
2287
2317
|
const prepared = await cdp.evaluate(prepareComposerExpression());
|
|
2288
2318
|
if (!prepared.ok)
|
|
2289
2319
|
throw new Error(prepared.reason ?? "Could not focus the ChatGPT composer");
|
|
@@ -2299,13 +2329,42 @@ async function insertComposerTextViaCdp(cdp, text) {
|
|
|
2299
2329
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
|
|
2300
2330
|
await sleep(100);
|
|
2301
2331
|
}
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
|
|
2308
|
-
|
|
2332
|
+
// Insertion path, chosen by size:
|
|
2333
|
+
//
|
|
2334
|
+
// Short prompts go through Input.insertText - real key-level input events,
|
|
2335
|
+
// which is what ChatGPT's composer is built for.
|
|
2336
|
+
//
|
|
2337
|
+
// Long prompts do NOT. One giant insertText stalls ProseMirror past the CDP
|
|
2338
|
+
// command timeout, and splitting it into chunks corrupts the text at the
|
|
2339
|
+
// chunk boundaries: measured live on a 95 KB prompt, the composer ended up
|
|
2340
|
+
// the right LENGTH but with content shifted from the first boundary on
|
|
2341
|
+
// (first divergence at 3,938 chars with a 4,000-char chunk size), which is
|
|
2342
|
+
// what surfaced to users as "Composer text did not match after insertion".
|
|
2343
|
+
// Both failures come from crossing the CDP boundary mid-edit, so large text
|
|
2344
|
+
// is inserted by a single in-page execCommand instead: the editor applies it
|
|
2345
|
+
// as one input event and nothing can interleave. Measured: 67 KB inserted in
|
|
2346
|
+
// ~20s, verified byte-for-byte.
|
|
2347
|
+
if (text.length <= COMPOSER_INSERT_CHUNK_CHARS) {
|
|
2348
|
+
await cdp.send("Input.insertText", { text });
|
|
2349
|
+
}
|
|
2350
|
+
else {
|
|
2351
|
+
// A 67 KB insert measured ~20s in the page, which the default 20s CDP
|
|
2352
|
+
// command budget would cut off, so this one call gets its own connection
|
|
2353
|
+
// with a size-scaled budget instead of loosening the budget for every
|
|
2354
|
+
// command on the shared connection.
|
|
2355
|
+
const budgetMs = Math.max(60_000, Math.ceil(text.length / 1_000) * 1_000);
|
|
2356
|
+
const slowCdp = page ? await connectCdp(page.webSocketDebuggerUrl, budgetMs) : undefined;
|
|
2357
|
+
try {
|
|
2358
|
+
const target = slowCdp ?? cdp;
|
|
2359
|
+
if (slowCdp)
|
|
2360
|
+
await slowCdp.send("Runtime.enable");
|
|
2361
|
+
const inserted = await target.evaluate(insertComposerTextInPageExpression(text));
|
|
2362
|
+
if (!inserted?.ok)
|
|
2363
|
+
throw new Error(inserted?.reason ?? "Could not insert the prompt into the ChatGPT composer");
|
|
2364
|
+
}
|
|
2365
|
+
finally {
|
|
2366
|
+
slowCdp?.close();
|
|
2367
|
+
}
|
|
2309
2368
|
}
|
|
2310
2369
|
await sleep(200);
|
|
2311
2370
|
const state = await cdp.evaluate(composerTextStateExpression(text));
|
package/dist/cli-server.js
CHANGED
|
@@ -4,7 +4,7 @@ import { parseProMode, parseReasoningEffort } from "./chatgpt-browser.js";
|
|
|
4
4
|
import { ASK_PRO_SELECTION_CLEAR_FLAGS, ASK_PRO_SELECTION_DEFAULT_FLAGS, assertNoExtraArgs, assertOnlyOptions, isHelpSubcommand, printHelpIfRequested, readFlag, readPortFlag, readPositiveNumberFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError } from "./cli-args.js";
|
|
5
5
|
import { printInitHelp, printSetupHelp, printStartHelp, printStatusHelp, printTunnelHelp, printTunnelUrlHelp } from "./cli-help.js";
|
|
6
6
|
import { errorMessage, isLoopbackHost, isMissingFileError, sourceAwareSetupMessage } from "./cli-shared.js";
|
|
7
|
-
import { getTokenExpiryStatus, loadLocalConfig, writeLocalConfig } from "./config.js";
|
|
7
|
+
import { getTokenExpiryStatus, loadLocalConfig, writeLocalConfig, composeServerUrlWithToken } from "./config.js";
|
|
8
8
|
import { startHttpMcpServer } from "./http-mcp.js";
|
|
9
9
|
import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
|
|
10
10
|
import { BridgeStore } from "./store.js";
|
|
@@ -49,7 +49,7 @@ export async function runSetupCommand(rest, io) {
|
|
|
49
49
|
io.stdout("Saved local ChatGPT Developer Mode MCP profile.");
|
|
50
50
|
io.stdout(`Server URL: ${redactServerUrl(config.server_url)}`);
|
|
51
51
|
io.stdout(formatTokenExpiryLine(config));
|
|
52
|
-
io.stdout("
|
|
52
|
+
io.stdout("The token is stored (once) in .bridge/config.local.json; print the full URL with `prodex status --show-token --url-only`.");
|
|
53
53
|
if (config.browser_defaults) {
|
|
54
54
|
io.stdout(`Browser send defaults: ${formatBrowserDefaults(config.browser_defaults)}`);
|
|
55
55
|
}
|
|
@@ -102,7 +102,10 @@ export async function runStatusCommand(rest, io) {
|
|
|
102
102
|
const nonExpiringRevealWarning = showToken && allowNonExpiringTokenReveal && tokenStatus.status === "non_expiring"
|
|
103
103
|
? sourceAwareSetupMessage("Showing a non-expiring token. Keep this local-only and rotate it with `prodex setup --token-ttl-hours <hours>` before any tunnel or ChatGPT Project use.", sourceCli, { cwd: setupHintCwd })
|
|
104
104
|
: undefined;
|
|
105
|
-
|
|
105
|
+
// The token is no longer persisted inside server_url, so compose the
|
|
106
|
+
// usable URL here; masking still applies when the caller did not ask for
|
|
107
|
+
// the token.
|
|
108
|
+
const serverUrl = formatServerUrlForOutput(composeServerUrlWithToken(config), { showToken });
|
|
106
109
|
if (rest.includes("--url-only")) {
|
|
107
110
|
if (showToken)
|
|
108
111
|
io.stderr(TOKEN_BEARING_MCP_URL_AUTHORITY_WARNING);
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
10
10
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
11
11
|
import { renderBanner, shouldColorize } from "./banner.js";
|
|
12
12
|
import { getChatGptBrowserStatus, resolveCdpPort } from "./chatgpt-browser.js";
|
|
13
|
-
import { getTokenExpiryStatus, loadLocalConfig } from "./config.js";
|
|
13
|
+
import { getTokenExpiryStatus, loadLocalConfig, resolveProdexCwd } from "./config.js";
|
|
14
14
|
import { startHttpMcpServer } from "./http-mcp.js";
|
|
15
15
|
import { createMcpToolHandlers } from "./mcp-tools.js";
|
|
16
16
|
import { runMcpServer } from "./mcp.js";
|
|
@@ -216,7 +216,10 @@ export async function runCli(args, io = defaultIo()) {
|
|
|
216
216
|
}
|
|
217
217
|
function defaultIo() {
|
|
218
218
|
return {
|
|
219
|
-
|
|
219
|
+
// PRODEX_CWD wins over a working directory prodex cannot use (a /dev/fd
|
|
220
|
+
// pipe path from an agent harness, a deleted directory); --cwd still wins
|
|
221
|
+
// over both where a command accepts it.
|
|
222
|
+
cwd: resolveProdexCwd(),
|
|
220
223
|
stdout: (line) => console.log(line),
|
|
221
224
|
stderr: (line) => console.error(line),
|
|
222
225
|
isInteractive: process.stdout.isTTY === true,
|
package/dist/config.js
CHANGED
|
@@ -59,6 +59,33 @@ export function localConfigPath(cwd) {
|
|
|
59
59
|
export function makeServerUrl(host, port, token) {
|
|
60
60
|
return `http://${host}:${port}/mcp?prodex_token=${encodeURIComponent(token)}`;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The endpoint WITHOUT the token, which is what gets persisted. The token used
|
|
64
|
+
* to be stored twice - as `token` and inside `server_url` - so an operator's
|
|
65
|
+
* redaction that masked the key still leaked the secret from the URL (field
|
|
66
|
+
* report). One field is the only shape where masking the token masks it.
|
|
67
|
+
*/
|
|
68
|
+
export function makeServerUrlBase(host, port) {
|
|
69
|
+
return `http://${host}:${port}/mcp`;
|
|
70
|
+
}
|
|
71
|
+
/** The token-bearing URL, composed on demand for clients that need it. */
|
|
72
|
+
export function composeServerUrlWithToken(config) {
|
|
73
|
+
const url = new URL(config.server_url);
|
|
74
|
+
url.searchParams.set("prodex_token", config.token);
|
|
75
|
+
return url.toString();
|
|
76
|
+
}
|
|
77
|
+
/** Strip a token that an older config (or a hand edit) left in the URL. */
|
|
78
|
+
export function stripTokenFromServerUrl(serverUrl) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(serverUrl);
|
|
81
|
+
url.searchParams.delete("prodex_token");
|
|
82
|
+
url.search = url.searchParams.toString();
|
|
83
|
+
return url.toString();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return serverUrl;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
62
89
|
export function normalizeLoopbackHttpHost(host) {
|
|
63
90
|
const normalized = host.trim().toLowerCase();
|
|
64
91
|
const isLocalhost = normalized === "localhost";
|
|
@@ -90,7 +117,7 @@ export async function writeLocalConfig(cwd, input = {}) {
|
|
|
90
117
|
host,
|
|
91
118
|
port,
|
|
92
119
|
token,
|
|
93
|
-
server_url:
|
|
120
|
+
server_url: makeServerUrlBase(host, port),
|
|
94
121
|
...(tokenExpiresAt ? { token_expires_at: tokenExpiresAt } : {}),
|
|
95
122
|
...(browserDefaults ? { browser_defaults: browserDefaults } : {}),
|
|
96
123
|
created_at: existing?.created_at ?? now,
|
|
@@ -126,7 +153,26 @@ export async function loadLocalConfig(cwd) {
|
|
|
126
153
|
}
|
|
127
154
|
assertLoopbackHttpHost(config.host);
|
|
128
155
|
assertLoopbackHttpHost(new URL(config.server_url).hostname);
|
|
156
|
+
// Configs written before the split still carry the token in the URL. Drop it
|
|
157
|
+
// in memory AND rewrite the file: leaving it on disk is the whole problem
|
|
158
|
+
// being fixed - an operator reading config.local.json would still find the
|
|
159
|
+
// secret twice, and any redaction keyed to `token` would miss one copy.
|
|
160
|
+
const strippedServerUrl = stripTokenFromServerUrl(config.server_url);
|
|
161
|
+
const carriedDuplicate = strippedServerUrl !== config.server_url;
|
|
162
|
+
config = { ...config, server_url: strippedServerUrl };
|
|
129
163
|
assertServerUrlMatchesConfig(config);
|
|
164
|
+
if (carriedDuplicate) {
|
|
165
|
+
// Best effort: a read-only checkout or a concurrent writer must not turn
|
|
166
|
+
// a working config into a failed command.
|
|
167
|
+
try {
|
|
168
|
+
await writeVerifiedUtf8File(localConfigPath(cwd), `${JSON.stringify(config, null, 2)}\n`, () => assertLocalConfigTargetSafe(cwd), {
|
|
169
|
+
mode: 0o600
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// The in-memory strip above already keeps prodex from printing it.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
130
176
|
return config;
|
|
131
177
|
}
|
|
132
178
|
// Global browser-selection defaults from the environment, used when a repo has
|
|
@@ -217,13 +263,13 @@ function isTokenExpired(tokenExpiresAt, now) {
|
|
|
217
263
|
}
|
|
218
264
|
function assertServerUrlMatchesConfig(config) {
|
|
219
265
|
const serverUrl = new URL(config.server_url);
|
|
220
|
-
const tokenParams = serverUrl.searchParams.getAll("prodex_token");
|
|
221
266
|
const hostMatches = normalizeLoopbackHttpHost(serverUrl.hostname) === normalizeLoopbackHttpHost(config.host);
|
|
222
267
|
const portMatches = effectiveUrlPort(serverUrl) === config.port;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
268
|
+
// The persisted URL must carry NO query at all: the token lives in `token`
|
|
269
|
+
// and nowhere else.
|
|
270
|
+
const shapeMatches = serverUrl.protocol === "http:" && serverUrl.pathname === "/mcp" && Array.from(serverUrl.searchParams.keys()).length === 0;
|
|
271
|
+
if (!hostMatches || !portMatches || !shapeMatches) {
|
|
272
|
+
throw new Error(".bridge/config.local.json server_url must be the token-free endpoint for host and port. Run `prodex setup` to replace it.");
|
|
227
273
|
}
|
|
228
274
|
}
|
|
229
275
|
function effectiveUrlPort(url) {
|
|
@@ -359,3 +405,16 @@ async function assertDirectoryHandle(handle, label) {
|
|
|
359
405
|
throw new Error(`${label} must be a real directory`);
|
|
360
406
|
}
|
|
361
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* The repo prodex should operate on. Defaults to the process working
|
|
410
|
+
* directory, but PRODEX_CWD (absolute paths only) wins: the MCP server takes
|
|
411
|
+
* no flags, so when an agent harness starts it from a pipe path or a deleted
|
|
412
|
+
* directory, the env var is the only way an operator can pin the repo without
|
|
413
|
+
* changing how that harness spawns the server.
|
|
414
|
+
*/
|
|
415
|
+
export function resolveProdexCwd(fallback = process.cwd(), env = process.env) {
|
|
416
|
+
const raw = (env.PRODEX_CWD ?? "").trim();
|
|
417
|
+
if (raw.length === 0 || !path.isAbsolute(raw))
|
|
418
|
+
return fallback;
|
|
419
|
+
return raw;
|
|
420
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -35,6 +35,26 @@ async function claimLockIsStale(lockPath) {
|
|
|
35
35
|
const FETCHABLE_RESULT_ARTIFACT_PREFIXES = [".bridge/artifacts/pro-consults/", ".bridge/artifacts/results/"];
|
|
36
36
|
export const MAX_FETCHABLE_RESULT_ARTIFACT_BYTES = 100_000;
|
|
37
37
|
const MAX_BRIDGE_ARTIFACT_READ_BYTES = 1_000_000;
|
|
38
|
+
// A bridge root has to be a real directory in a real filesystem. Field report
|
|
39
|
+
// (macOS): an agent harness started `prodex mcp` with a working directory of
|
|
40
|
+
// /dev/fd/<n> - a file-descriptor path - so every call failed with a raw
|
|
41
|
+
// ENOENT/ENOTDIR on <root>/tasks, /sessions, /receipts, with a different
|
|
42
|
+
// number each time, and the operator had no way to tell what prodex had
|
|
43
|
+
// resolved or how to override it.
|
|
44
|
+
const NON_REPO_ROOT_PREFIXES = ["/dev/", "/proc/", "/sys/"];
|
|
45
|
+
export async function assertUsableBridgeRoot(root) {
|
|
46
|
+
const looksLikeDevicePath = NON_REPO_ROOT_PREFIXES.some((prefix) => root.startsWith(prefix));
|
|
47
|
+
let isDirectory = false;
|
|
48
|
+
try {
|
|
49
|
+
isDirectory = (await stat(root)).isDirectory();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
isDirectory = false;
|
|
53
|
+
}
|
|
54
|
+
if (isDirectory && !looksLikeDevicePath)
|
|
55
|
+
return;
|
|
56
|
+
throw new Error(`Bridge root is not a usable repo directory: ${root}${looksLikeDevicePath ? " (that is a file-descriptor/device path, not a repo)" : ""}. prodex uses the process working directory when no --cwd is given, so a server started from a pipe or a deleted directory lands here. Pass --cwd /absolute/path/to/repo, or set PRODEX_CWD=/absolute/path/to/repo (works for the MCP server, which takes no flags).`);
|
|
57
|
+
}
|
|
38
58
|
export class BridgeStore {
|
|
39
59
|
root;
|
|
40
60
|
bridgeDir;
|
|
@@ -43,6 +63,7 @@ export class BridgeStore {
|
|
|
43
63
|
this.bridgeDir = path.join(root, ".bridge");
|
|
44
64
|
}
|
|
45
65
|
async ensure() {
|
|
66
|
+
await assertUsableBridgeRoot(this.root);
|
|
46
67
|
await ensurePrivateDirectory(this.bridgeDir, "Bridge directory");
|
|
47
68
|
await Promise.all([
|
|
48
69
|
ensurePrivateDirectory(this.dir("tasks"), "Bridge storage directory .bridge/tasks"),
|