@youdie006/prodex 0.19.0 → 0.19.1

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.
@@ -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.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",