@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.
@@ -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
- // Insert in bounded chunks: a single multi-KB Input.insertText makes
2303
- // ProseMirror do one huge transaction, which on a heavy thread stalls past
2304
- // the 20s CDP command timeout and kills the send with "Chrome DevTools
2305
- // command timed out: Input.insertText" (field failure on long prompts,
2306
- // twice in one session). Each chunk gets its own command budget.
2307
- for (const chunk of chunkComposerText(text)) {
2308
- await cdp.send("Input.insertText", { text: chunk });
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));
@@ -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("Full URL is stored in .bridge/config.local.json.");
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
- const serverUrl = formatServerUrlForOutput(config.server_url, { showToken });
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
- cwd: process.cwd(),
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: makeServerUrl(host, port, token),
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
- const tokenMatches = tokenParams.length === 1 && tokenParams[0] === config.token;
224
- const shapeMatches = serverUrl.protocol === "http:" && serverUrl.pathname === "/mcp" && Array.from(serverUrl.searchParams.keys()).length === 1;
225
- if (!hostMatches || !portMatches || !tokenMatches || !shapeMatches) {
226
- throw new Error(".bridge/config.local.json server_url must match host, port, and token. Run `prodex setup` to replace it.");
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"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",