@youdie006/prodex 0.40.6 → 0.40.9

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.
@@ -40,8 +40,8 @@ export async function runSetupCommand(rest, io) {
40
40
  ? await runBrowserDefaultsWizard(resolvePromptUser(io), io.stdout)
41
41
  : parseBrowserDefaultFlags(rest);
42
42
  const config = await writeLocalConfig(targetCwd, {
43
- host: readFlag(rest, "--host") ?? "127.0.0.1",
44
- port: readPortFlag(rest, "--port") ?? 8787,
43
+ host: readFlag(rest, "--host"),
44
+ port: readPortFlag(rest, "--port"),
45
45
  token: readFlag(rest, "--token"),
46
46
  tokenTtlHours: readPositiveNumberFlag(rest, "--token-ttl-hours"),
47
47
  browserDefaults
package/dist/cli.js CHANGED
@@ -82,10 +82,6 @@ async function runInteractiveUi(io) {
82
82
  const { listRecentChatGptConversations } = await import("./chatgpt-browser.js");
83
83
  return listRecentChatGptConversations({});
84
84
  },
85
- openThread: async (url) => {
86
- const { navigateChatGptTabTo } = await import("./chatgpt-browser.js");
87
- return navigateChatGptTabTo(url, {});
88
- },
89
85
  listProjectsWithIds: async () => {
90
86
  const { listChatGptProjectsWithIds } = await import("./chatgpt-browser.js");
91
87
  return listChatGptProjectsWithIds({});
@@ -97,6 +93,7 @@ async function runInteractiveUi(io) {
97
93
  },
98
94
  runConsult: (args, onProgress) => runCli(args, {
99
95
  ...io,
96
+ navigateInteractiveTarget: true,
100
97
  stderr: (line) => {
101
98
  if (line.startsWith("progress:"))
102
99
  onProgress(line);
package/dist/config.js CHANGED
@@ -102,9 +102,9 @@ export async function writeLocalConfig(cwd, input = {}) {
102
102
  await ensureBridgeLocalFiles(cwd);
103
103
  await assertLocalConfigTargetSafe(cwd);
104
104
  const now = new Date().toISOString();
105
- const host = normalizeLoopbackHttpHost(input.host ?? "127.0.0.1");
106
- const port = input.port ?? 8787;
107
105
  const existing = await readExistingConfig(cwd);
106
+ const host = normalizeLoopbackHttpHost(input.host ?? existing?.host ?? "127.0.0.1");
107
+ const port = input.port ?? existing?.port ?? 8787;
108
108
  // A setup re-run that only adjusts defaults/host/port must NOT rotate the
109
109
  // token - that would silently 401 every client holding the old MCP URL.
110
110
  // Rotation happens only when the caller explicitly asks for a token
@@ -275,7 +275,7 @@ export function getTokenExpiryStatus(config, now = new Date()) {
275
275
  ? {
276
276
  status: "expired",
277
277
  token_expires_at: config.token_expires_at,
278
- warning: `Token expired at ${config.token_expires_at}. Run \`prodex setup\` to create a new URL.`
278
+ warning: `Token expired at ${config.token_expires_at}. Run \`prodex setup --token-ttl-hours <hours>\` to create a new URL, then restart \`prodex start\`.`
279
279
  }
280
280
  : { status: "valid", token_expires_at: config.token_expires_at };
281
281
  }
@@ -125,8 +125,8 @@ export function projectIdFromSidebar(projects, name) {
125
125
  *
126
126
  * Naming a task wins over the search, because the caller who names one knows
127
127
  * which conversation they mean. Otherwise it is the most recent consult that
128
- * finished, in this project - fail-closed when there is none, since guessing
129
- * the conversation is the failure this exists to prevent.
128
+ * finished for this caller, in this project - fail-closed when there is none,
129
+ * since guessing the conversation is the failure this exists to prevent.
130
130
  */
131
131
  export function resolveContinuationThread(input) {
132
132
  const withThread = input.consults.filter((consult) => consult.thread && isChatGptConversationUrl(consult.thread));
@@ -147,10 +147,17 @@ export function resolveContinuationThread(input) {
147
147
  }
148
148
  return { target: { taskId: named.taskId, thread: named.thread } };
149
149
  }
150
+ if (!input.sessionKey) {
151
+ return {
152
+ error: "--continue needs a caller session key so it cannot select another client's conversation. " +
153
+ "Pass --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID), or name the intended consult with --continue-task <task_id>."
154
+ };
155
+ }
150
156
  const knownProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
151
157
  if (input.projectId)
152
158
  knownProjectIds.add(input.projectId.toLowerCase());
153
159
  const candidates = withThread
160
+ .filter((consult) => consult.sessionKey === input.sessionKey)
154
161
  .filter((consult) => consult.status === "done")
155
162
  .filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
156
163
  .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
package/dist/http-mcp.js CHANGED
@@ -27,7 +27,7 @@ export async function startHttpMcpServer(options) {
27
27
  // HOW to authorize without leaking anything token-specific.
28
28
  writeJson(res, 401, {
29
29
  error: "unauthorized",
30
- hint: "Provide a valid token via `?prodex_token=<token>` or `Authorization: Bearer <token>`. If your token expired, regenerate the profile with `prodex setup` and re-read the URL from `prodex status`."
30
+ hint: "Provide a valid token via `?prodex_token=<token>` or `Authorization: Bearer <token>`. If your token expired, run `prodex setup --token-ttl-hours <hours>`, restart `prodex start`, and read the new URL with `prodex status --show-token --url-only`."
31
31
  });
32
32
  return;
33
33
  }
@@ -24,14 +24,18 @@ export function issueAreaLabel(code) {
24
24
  /**
25
25
  * Build the report. Only the failure travels: never the prompt, the answer, or
26
26
  * the summary, because a public issue must not become where a private consult
27
- * leaks. Everything included here is either environment or blocker metadata.
27
+ * leaks. Blocker prose can contain answers, paths and thread URLs, so it stays
28
+ * local too. Public reports carry a code and a fixed diagnostic summary.
28
29
  */
29
30
  export function buildIssueReport(consult, environment) {
30
31
  if (consult.status !== "blocked" || !consult.blocker) {
31
32
  throw new Error(`${consult.task_id} is not a failure (status ${consult.status}), so there is nothing to report.`);
32
33
  }
33
- const code = (consult.blocker.code ?? "unknown").trim();
34
- const message = (consult.blocker.message ?? "").trim();
34
+ const rawCode = (consult.blocker.code ?? "unknown").trim();
35
+ const code = /^[a-z][a-z0-9_]{0,79}$/.test(rawCode) ? rawCode : "unknown";
36
+ const message = code === "smoke_token_mismatch"
37
+ ? "The browser response did not match the smoke-test token."
38
+ : "Consult blocked; detailed diagnostics are kept in the local receipt.";
35
39
  const area = issueAreaLabel(code);
36
40
  const body = [
37
41
  "A consult was blocked. Filed from its receipt, so the prompt and the answer are not included.",
@@ -45,7 +49,8 @@ export function buildIssueReport(consult, environment) {
45
49
  `| platform | ${environment.platform} |`,
46
50
  `| node | ${environment.nodeVersion} |`,
47
51
  "",
48
- ...(consult.blocker.next_step ? ["What it told the caller to do:", "", `> ${consult.blocker.next_step}`, ""] : []),
52
+ "Private error details and recovery instructions are omitted. Review the local receipt before sharing more context.",
53
+ "",
49
54
  "Receipt (local, not attached): " + consult.task_id
50
55
  ].join("\n");
51
56
  return {
package/dist/mcp-tools.js CHANGED
@@ -4,6 +4,33 @@ import { applyRepoWriteDryRun, createRepoWriteDryRun, stageReviewedPaths } from
4
4
  export const MAX_MCP_BRIDGE_TEXT_BYTES = MAX_FETCHABLE_RESULT_ARTIFACT_BYTES;
5
5
  export const MAX_MCP_SHORT_TEXT_BYTES = 10_000;
6
6
  const MAX_MCP_LIST_ITEMS = 100;
7
+ function redactContextText(text, privateValues = []) {
8
+ let redacted = text;
9
+ for (const value of privateValues) {
10
+ if (value)
11
+ redacted = redacted.replaceAll(value, "[redacted]");
12
+ }
13
+ return redacted.replace(/https?:\/\/(?:chatgpt\.com|chat\.openai\.com)(?![\w.-])(?:[/?#][^\s<>"'`)]*)?/gi, "[redacted ChatGPT URL]");
14
+ }
15
+ function redactBlockerForMcp(blocker, privateValues = []) {
16
+ if (!blocker)
17
+ return undefined;
18
+ const context = [blocker.thread, ...privateValues];
19
+ return {
20
+ code: blocker.code,
21
+ message: redactContextText(blocker.message, context),
22
+ retryable: blocker.retryable,
23
+ ...(blocker.next_step !== undefined ? { next_step: redactContextText(blocker.next_step, context) } : {})
24
+ };
25
+ }
26
+ function redactResultForMcp(result) {
27
+ return {
28
+ ...result,
29
+ summary: result.status === "blocked" ? redactContextText(result.summary, [result.blocker?.thread]) : result.summary,
30
+ blocker: redactBlockerForMcp(result.blocker),
31
+ warnings: result.warnings.map((warning) => redactContextText(warning, [result.blocker?.thread]))
32
+ };
33
+ }
7
34
  /**
8
35
  * Drop the ChatGPT project name and thread URL before a session crosses the
9
36
  * MCP boundary. Both are personal context (the same way receipts redact the
@@ -17,6 +44,8 @@ function redactSessionForMcp(session) {
17
44
  redacted.project = undefined;
18
45
  if (Object.hasOwn(redacted, "thread"))
19
46
  redacted.thread = undefined;
47
+ redacted.blocker = redactBlockerForMcp(session.blocker, [session.thread, session.project]);
48
+ redacted.warnings = session.warnings.map((warning) => redactContextText(warning, [session.thread, session.project]));
20
49
  return redacted;
21
50
  }
22
51
  /**
@@ -27,17 +56,20 @@ function redactSessionForMcp(session) {
27
56
  * raw task file keeps them for local CLI inspection.
28
57
  */
29
58
  function redactTaskForMcp(task) {
30
- if (!task || typeof task !== "object" || !("provenance" in task) || !task.provenance)
31
- return task;
32
59
  const provenance = task.provenance;
33
- if (!Object.hasOwn(provenance, "thread") && !Object.hasOwn(provenance, "project"))
34
- return task;
35
60
  const redactedProvenance = { ...provenance };
36
61
  if (Object.hasOwn(redactedProvenance, "thread"))
37
62
  redactedProvenance.thread = undefined;
38
63
  if (Object.hasOwn(redactedProvenance, "project"))
39
64
  redactedProvenance.project = undefined;
40
- return { ...task, provenance: redactedProvenance };
65
+ if (provenance?.warnings) {
66
+ redactedProvenance.warnings = provenance.warnings.map((warning) => redactContextText(warning, [provenance.thread, provenance.project]));
67
+ }
68
+ return {
69
+ ...task,
70
+ ...(provenance ? { provenance: redactedProvenance } : {}),
71
+ blocker: redactBlockerForMcp(task.blocker, [provenance?.thread, provenance?.project])
72
+ };
41
73
  }
42
74
  export function createMcpToolHandlers(context) {
43
75
  const store = new BridgeStore(context.cwd, {
@@ -71,7 +103,7 @@ export function createMcpToolHandlers(context) {
71
103
  async bridge_claim_task(input) {
72
104
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
73
105
  assertMcpTextField(input.claimed_by, "claimed_by", MAX_MCP_SHORT_TEXT_BYTES);
74
- return { task: await store.claimTask(input.task_id, input.claimed_by ?? claimedBy) };
106
+ return { task: redactTaskForMcp(await store.claimTask(input.task_id, input.claimed_by ?? claimedBy)) };
75
107
  },
76
108
  async bridge_complete_task(input) {
77
109
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
@@ -87,7 +119,7 @@ export function createMcpToolHandlers(context) {
87
119
  warnings: input.warnings,
88
120
  provenance: { adapter: "mcp" }
89
121
  });
90
- return { result };
122
+ return { result: redactResultForMcp(result) };
91
123
  },
92
124
  async bridge_block_task(input) {
93
125
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
@@ -111,14 +143,14 @@ export function createMcpToolHandlers(context) {
111
143
  },
112
144
  provenance: { adapter: "mcp" }
113
145
  });
114
- return { result };
146
+ return { result: redactResultForMcp(result) };
115
147
  },
116
148
  async bridge_list_results() {
117
- return { results: await store.listFinalizedResultsReadOnly() };
149
+ return { results: (await store.listFinalizedResultsReadOnly()).map(redactResultForMcp) };
118
150
  },
119
151
  async bridge_fetch_result(input) {
120
152
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
121
- return { result: await store.getFinalizedResultReadOnly(input.task_id) };
153
+ return { result: redactResultForMcp(await store.getFinalizedResultReadOnly(input.task_id)) };
122
154
  },
123
155
  async bridge_fetch_result_artifact(input) {
124
156
  assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
package/dist/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { createRequire } from "node:module";
4
5
  import process from "node:process";
@@ -6,7 +7,7 @@ import { serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
6
7
  import { JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
7
8
  import { z } from "zod";
8
9
  import { createMcpToolHandlers, MAX_MCP_BRIDGE_TEXT_BYTES, MAX_MCP_SHORT_TEXT_BYTES, staleServerWarning, withServerVersionNotice } from "./mcp-tools.js";
9
- import { ReceiptKindSchema } from "./schema.js";
10
+ import { ProdexRequestIdSchema, ReceiptKindSchema, SessionKeySchema } from "./schema.js";
10
11
  const McpBridgeTextSchema = z.string().max(MAX_MCP_BRIDGE_TEXT_BYTES);
11
12
  const McpShortTextSchema = z.string().max(MAX_MCP_SHORT_TEXT_BYTES);
12
13
  const BridgeFileInputSchema = z.object({
@@ -51,6 +52,7 @@ function serverVersionNotice() {
51
52
  }
52
53
  export function createServer(cwd = process.cwd(), options = {}) {
53
54
  const server = new McpServer({ name: "prodex", version: mcpPackageJson.version ?? "0.0.0" });
55
+ const mcpSessionKey = `mcp-${randomUUID()}`;
54
56
  const handlers = createMcpToolHandlers({
55
57
  cwd,
56
58
  source: options.source,
@@ -167,9 +169,10 @@ export function createServer(cwd = process.cwd(), options = {}) {
167
169
  const browserConsult = options.browserConsult;
168
170
  if (browserConsult) {
169
171
  server.registerTool("pro_consult", {
170
- description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. By default the consult continues in whatever thread the browser tab is showing - EXCEPT when a project applies (passed here, or pinned as a saved default), because entering a project starts a new chat in it. To follow up on a previous consult, pass continue_thread:true: it resolves the thread from prodex's own records - the newest finished consult of the same project - instead of trusting the shared tab, and continue_task with a task_id names one exactly. Pass new_chat:true to start a fresh thread for a genuinely new topic. If the thread is still generating a previous answer, the send automatically queues behind it (up to the timeout budget) - long 'tab busy' progress is normal, not stuck. `project` and `model` come from saved defaults (per-repo config, or PRODEX_DEFAULT_PROJECT / PRODEX_DEFAULT_MODEL env vars) when omitted - do NOT pass them per-call unless deliberately overriding. Returns task_id, thread URL, and the answer text.",
172
+ description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. Every ordinary consult starts a fresh chat, including inside a passed or saved default project; new_chat:false never opts into the shared current tab. To follow up, pass continue_thread:true: it resolves only the newest finished consult with this caller's session_key and project. Each MCP connection gets one default session_key. Logical agents sharing one connection must pass distinct explicit keys and preserve them for follow-ups; an explicit key also preserves identity across MCP restarts. continue_task deliberately names one task across session boundaries. If the thread is still generating a previous answer, the send queues behind it up to the timeout budget. `project` and `model` come from saved defaults when omitted. Returns task_id, thread URL, session_key, request correlation evidence, and the answer text.",
171
173
  inputSchema: {
172
174
  prompt: McpBridgeTextSchema.min(1),
175
+ session_key: SessionKeySchema.optional().describe("Stable logical-caller identifier for scoped continue_thread lookup. Omit to share this MCP connection's default key; logical agents sharing one connection should pass distinct keys and preserve them for follow-ups."),
173
176
  model: McpShortTextSchema.optional(),
174
177
  // pro_mode is deliberately NOT advertised: ChatGPT's 2026-07 update
175
178
  // removed Pro sub-modes, and agents that saw the field passed
@@ -183,7 +186,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
183
186
  .array(McpShortTextSchema)
184
187
  .max(4)
185
188
  .optional()
186
- .describe("ChatGPT composer tools to enable for this consult: \"deep-research\" (a browsed report; prodex presses start and waits out the run, which takes about ten minutes, so the timeout rises to 30 minutes automatically and the FULL report comes back as the answer - if the budget still runs out, the blocker carries the thread and pro_recover collects the report later), \"web-search\" (current facts, with the sources kept as links), \"create-image\". Deep research sometimes replies with a CLARIFYING QUESTION instead; answer it with a normal follow-up consult in the same thread."),
189
+ .describe("Rendered ChatGPT composer tools: \"web-search\" or \"create-image\". Only rendered answer content is returned. Automatic deep-research report retrieval is unsupported; requesting \"deep-research\" stops before sending. Use Deep research manually in the ChatGPT UI."),
187
190
  attach: z
188
191
  .array(McpShortTextSchema)
189
192
  .max(10)
@@ -192,11 +195,11 @@ export function createServer(cwd = process.cwd(), options = {}) {
192
195
  new_chat: z
193
196
  .boolean()
194
197
  .optional()
195
- .describe("Start a fresh thread for a new topic."),
198
+ .describe("Start a fresh thread. Ordinary consults already do this by default; false does not reuse the shared tab."),
196
199
  continue_thread: z
197
200
  .boolean()
198
201
  .optional()
199
- .describe("Follow up inside the conversation a previous consult is already in, resolved from prodex's records: the newest finished consult of the same project. This is the reliable way to keep a follow-up in one conversation - the tab is shared, and a project default starts a new chat on every send. Fails rather than guessing when this project has no finished consult yet."),
202
+ .describe("Follow up inside this caller session_key's newest finished consult of the same project. Fails rather than using another MCP/Codex session or the shared browser tab."),
200
203
  continue_task: z
201
204
  .string()
202
205
  .min(1)
@@ -227,15 +230,16 @@ export function createServer(cwd = process.cwd(), options = {}) {
227
230
  // Progress delivery must never break the consult.
228
231
  });
229
232
  };
230
- return asText(withServerVersionNotice(await browserConsult(input, onProgress), serverVersionNotice()));
233
+ return asText(withServerVersionNotice(await browserConsult({ ...input, session_key: input.session_key ?? mcpSessionKey }, onProgress), serverVersionNotice()));
231
234
  });
232
235
  }
233
236
  const browserRecover = options.browserRecover;
234
237
  if (browserRecover) {
235
238
  server.registerTool("pro_recover", {
236
- description: "Fetch a ChatGPT answer that finished AFTER a consult stopped waiting, and record it as a normal consult receipt. Use this whenever pro_consult came back with a timeout or a still-running blocker: those carry the thread URL, and the answer is almost always sitting in that thread. This is also how a deep research report is collected - a research run takes about ten minutes and keeps going even when the consult that started it has already returned. It sends no prompt, but it does navigate the visible tab to that thread, so it waits for any consult still streaming rather than walking the browser off it.",
239
+ description: "Read a stable, finished assistant answer rendered in the requested ChatGPT thread after a consult stopped waiting, and record a receipt. Pass the request_id returned by pro_consult to verify the answer follows that exact marked user turn. Without it, legacy recovery returns request_verified:false and an explicit warning. It sends no prompt and acquires the shared send lock before navigating. Wrong-thread, wrong-request, generating, missing, or changing answers are refused.",
237
240
  inputSchema: {
238
241
  thread: McpShortTextSchema.min(1).describe("The ChatGPT conversation URL from the blocker (its `thread` field)."),
242
+ request_id: ProdexRequestIdSchema.optional().describe("The 32-character request_id returned by the original pro_consult."),
239
243
  timeout_ms: z.number().int().positive().max(600_000).optional()
240
244
  }
241
245
  }, async (input) => asText(withServerVersionNotice(await browserRecover(input), serverVersionNotice())));
package/dist/registry.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
5
+ import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
4
6
  const SCHEMA_VERSION = 1;
5
7
  /**
6
8
  * Central registry of every bridge root on this machine, so local indexers
@@ -86,6 +88,7 @@ async function registerBridgeRootInner(root) {
86
88
  try {
87
89
  const file = bridgesRegistryPath();
88
90
  const abs = await canonicalize(root);
91
+ const parentRealPath = await preparePrivateRegistryParent(file);
89
92
  // Read-modify-write with a bounded verify-retry: rename gives torn-write
90
93
  // atomicity but not lost-update safety - two processes registering
91
94
  // DIFFERENT roots at the same instant would each read the old list and
@@ -95,7 +98,7 @@ async function registerBridgeRootInner(root) {
95
98
  for (let attempt = 0; attempt < 5; attempt++) {
96
99
  let roots = [];
97
100
  try {
98
- const parsed = JSON.parse(await fs.readFile(file, "utf8"));
101
+ const parsed = JSON.parse(await readRegistryFileForWrite(file, parentRealPath));
99
102
  if (Array.isArray(parsed?.roots)) {
100
103
  roots = parsed.roots.filter((r) => typeof r === "string");
101
104
  }
@@ -115,13 +118,19 @@ async function registerBridgeRootInner(root) {
115
118
  }
116
119
  survivors.push(abs);
117
120
  roots = survivors.length > MAX_REGISTRY_ROOTS ? survivors.slice(survivors.length - MAX_REGISTRY_ROOTS) : survivors;
118
- await fs.mkdir(path.dirname(file), { recursive: true });
119
- const tmp = `${file}.${process.pid}.${attempt}.tmp`;
120
- await fs.writeFile(tmp, `${JSON.stringify({ schema_version: SCHEMA_VERSION, roots }, null, 2)}\n`, "utf8");
121
- await fs.rename(tmp, file);
121
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
122
+ try {
123
+ await writeVerifiedUtf8File(tmp, `${JSON.stringify({ schema_version: SCHEMA_VERSION, roots }, null, 2)}\n`, () => assertRegistryWritePathsSafe(file, parentRealPath), { create: true, exclusive: true, mode: 0o600 });
124
+ await assertRegistryWritePathsSafe(file, parentRealPath);
125
+ await fs.rename(tmp, file);
126
+ }
127
+ catch (error) {
128
+ await cleanupRegistryTemp(tmp, parentRealPath);
129
+ throw error;
130
+ }
122
131
  // Verify our root survived a concurrent writer's rename.
123
132
  try {
124
- const check = JSON.parse(await fs.readFile(file, "utf8"));
133
+ const check = JSON.parse(await readRegistryFileForWrite(file, parentRealPath));
125
134
  if (Array.isArray(check?.roots) && check.roots.includes(abs))
126
135
  return;
127
136
  }
@@ -134,3 +143,42 @@ async function registerBridgeRootInner(root) {
134
143
  // Advisory registry - never let it fail a bridge operation.
135
144
  }
136
145
  }
146
+ async function preparePrivateRegistryParent(file) {
147
+ const parent = path.dirname(file);
148
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
149
+ const parentStat = await fs.lstat(parent);
150
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
151
+ throw new Error("Registry parent must be a real directory and must not be a symlink");
152
+ }
153
+ await fs.chmod(parent, 0o700);
154
+ return fs.realpath(parent);
155
+ }
156
+ async function assertRegistryWritePathsSafe(file, parentRealPath) {
157
+ const parent = path.dirname(file);
158
+ const parentStat = await fs.lstat(parent);
159
+ if (parentStat.isSymbolicLink() || !parentStat.isDirectory() || (await fs.realpath(parent)) !== parentRealPath) {
160
+ throw new Error("Registry parent changed during write");
161
+ }
162
+ try {
163
+ const targetStat = await fs.lstat(file);
164
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
165
+ throw new Error("Registry target must be a regular file and must not be a symlink");
166
+ }
167
+ }
168
+ catch (error) {
169
+ if (error.code !== "ENOENT")
170
+ throw error;
171
+ }
172
+ }
173
+ function readRegistryFileForWrite(file, parentRealPath) {
174
+ return readVerifiedUtf8File(file, () => assertRegistryWritePathsSafe(file, parentRealPath), { maxBytes: 1_000_000 });
175
+ }
176
+ async function cleanupRegistryTemp(tmp, parentRealPath) {
177
+ try {
178
+ if ((await fs.realpath(path.dirname(tmp))) === parentRealPath)
179
+ await fs.rm(tmp, { force: true });
180
+ }
181
+ catch {
182
+ // Do not follow a parent that changed while the advisory write was in flight.
183
+ }
184
+ }
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile, spawn } from "node:child_process";
3
+ import { realpath } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { assertResolvedRepoPathAllowed, resolveRepoPath } from "./repo.js";
6
- import { readVerifiedUtf8File, replaceVerifiedUtf8File } from "./safe-file.js";
7
+ import { readVerifiedUtf8File, replaceVerifiedUtf8File, withCrossProcessFileLock } from "./safe-file.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  const MAX_WRITE_BYTES = 1_000_000;
9
10
  let testHooks = {};
@@ -73,8 +74,28 @@ export async function applyRepoWriteDryRun(root, store, input) {
73
74
  if (metadata.preimage_sha256 !== input.preimage_sha256) {
74
75
  throw new Error(`Expected preimage does not match dry-run receipt`);
75
76
  }
77
+ const newContent = await readDryRunReplacementContent(store, metadata);
78
+ const resolvedPath = resolveRepoPath(root, metadata.path);
79
+ await assertResolvedRepoPathAllowed(root, resolvedPath, metadata.path);
80
+ const canonicalTarget = await realpath(resolvedPath);
81
+ const canonicalRoot = await realpath(root);
82
+ const lockFile = path.join(canonicalRoot, ".bridge", `.repo-write-${sha256(canonicalTarget)}.lock`);
83
+ // This ownership interval closes races among prodex applies. An editor that
84
+ // does not participate in the lock protocol can still replace the path.
85
+ return withCrossProcessFileLock(lockFile, {
86
+ waitMs: 30_000,
87
+ retryMs: 25,
88
+ privateParent: true,
89
+ busyError: (holder) => new Error(`Another prodex repo write for ${metadata.path} is in progress (pid ${holder.pid ?? "unknown"})`),
90
+ unavailableError: () => new Error(`The prodex repo write lock at ${lockFile} could not be recovered. Stop all writers before removing that lock and its matching .reap claim, then retry.`)
91
+ }, () => applyRepoWriteUnderLock(root, store, input, metadata, newContent, canonicalTarget));
92
+ }
93
+ async function applyRepoWriteUnderLock(root, store, input, metadata, newContent, canonicalTarget) {
76
94
  await assertGitHead(root, input.expected_head);
77
95
  const current = await readWritableExistingFile(root, metadata.path);
96
+ if ((await realpath(current.resolved)) !== canonicalTarget) {
97
+ throw new Error(`File target changed for ${metadata.path}`);
98
+ }
78
99
  const currentPreimage = sha256(current.content);
79
100
  if (currentPreimage !== input.preimage_sha256) {
80
101
  // A retry after a successful apply sees the file already holding the new
@@ -85,7 +106,6 @@ export async function applyRepoWriteDryRun(root, store, input) {
85
106
  }
86
107
  throw new Error(`File preimage changed for ${metadata.path}`);
87
108
  }
88
- const newContent = await readDryRunReplacementContent(store, metadata);
89
109
  let receipt;
90
110
  try {
91
111
  await replaceVerifiedUtf8File(current.resolved, newContent, async () => {