@youdie006/prodex 0.40.16 → 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/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";
@@ -3075,6 +3076,21 @@ export function printBrowserLoginGuide(stdout, input) {
3075
3076
  : input.opened
3076
3077
  ? "Opened the dedicated Chrome window for ChatGPT."
3077
3078
  : "Dry run: no browser was opened.");
3079
+ if (noInteractiveWindow && !input.opened && !input.reused) {
3080
+ const previewCommand = formatBrowserLoginCommand(input.sourceCli, {
3081
+ ...input.commandOptions,
3082
+ profileDir: input.profileDir,
3083
+ port: input.port
3084
+ });
3085
+ const modeFlag = input.headless ? "--headless" : "--virtual-display";
3086
+ stdout("");
3087
+ stdout(`Next: run \`${previewCommand} ${modeFlag}\` without \`--dry-run\` to start or reuse Chrome with no visible window.`);
3088
+ stdout("Readiness was not checked. A real launch reuses this profile and reports any login or protection blocker; it does not switch to a visible window automatically.");
3089
+ stdout("");
3090
+ stdout(`Profile: ${input.profileDir}`);
3091
+ stdout(`Debug: http://127.0.0.1:${input.port}`);
3092
+ return;
3093
+ }
3078
3094
  if (noInteractiveWindow && (input.opened || input.reused)) {
3079
3095
  stdout("");
3080
3096
  stdout(input.headless
@@ -3210,10 +3226,24 @@ export async function printProductCheck(store, io, args, configCwd = io.cwd) {
3210
3226
  // report both as "not running": the browser really is gone, or it is still
3211
3227
  // there and has stopped answering. The second keeps burning CPU until
3212
3228
  // somebody notices, and nobody notices a message that says it is absent.
3213
- const wedged = statusMeansBrowserDead(browserStatus)
3214
- ? findWedgedBrowser({ ...(browserCommandOptions.port !== undefined ? { port: browserCommandOptions.port } : {}) })
3215
- : [];
3216
- const blocker = wedged.length > 0 ? wedgedBrowserBlocker(wedged, browserCommandOptions.port ?? DEFAULT_CDP_PORT) : browserStatus.blocker;
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
+ }
3217
3247
  io.stdout(`chatgpt: ${blocker?.code ?? "unreachable"} - ${blocker?.message ?? "browser is not reachable"}`);
3218
3248
  const nextStep = productCheckBrowserNextStep(blocker?.next_step, sourceCli, browserCommandOptions);
3219
3249
  if (nextStep)
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { getTokenExpiryStatus, loadLocalConfig, resolveProdexCwd } from "./confi
14
14
  import { startHttpMcpServer } from "./http-mcp.js";
15
15
  import { createMcpToolHandlers } from "./mcp-tools.js";
16
16
  import { runMcpServer } from "./mcp.js";
17
+ import { execNpm } from "../scripts/npm-command.mjs";
17
18
  import { BridgeStore } from "./store.js";
18
19
  import { printHelpIfRequested, assertNoExtraArgs, assertOnlyOptions, formatCliCommand, formatSourceCliOption, isHelpSubcommand, readFlag, resolveCwdFlag, resolveExistingPathFlag, resolveOptionalFileFlag, shellQuote, unknownSubcommandError, unknownTopLevelCommandError } from "./cli-args.js";
19
20
  import { listRawResultsForInspection, listTasksForInspection, runReceiptsCommand, runResultsCommand, runSessionsCommand, runTasksCommand } from "./cli-ledger.js";
@@ -711,7 +712,7 @@ async function readReleasePackageJson(packageJsonPath) {
711
712
  }
712
713
  async function readReleasePackStatus(cwd, packageJson, sourceCli, releaseHintCwd) {
713
714
  try {
714
- const { stdout } = await execFileAsync(commandForPlatform("npm"), ["pack", "--json", "--dry-run", "--ignore-scripts"], {
715
+ const { stdout } = await execNpm(["pack", "--json", "--dry-run", "--ignore-scripts"], {
715
716
  cwd,
716
717
  timeout: 120_000,
717
718
  maxBuffer: 20 * 1024 * 1024
@@ -872,9 +873,6 @@ function writeCommandOutput(output, write) {
872
873
  for (const line of trimmed.split(/\r?\n/))
873
874
  write(line);
874
875
  }
875
- function commandForPlatform(command) {
876
- return process.platform === "win32" && command === "npm" ? "npm.cmd" : command;
877
- }
878
876
  function firstErrorLine(error) {
879
877
  const failed = typeof error === "object" && error !== null ? error : {};
880
878
  const stderr = firstOutputLine(failed.stderr);
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { isIP } from "node:net";
5
5
  import path from "node:path";
6
6
  import { z } from "zod";
7
7
  import { SCHEMA_VERSION } from "./schema.js";
8
+ import { ensureBridgeGitignore } from "./bridge-gitignore.js";
8
9
  import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
9
10
  const BRIDGE_DIRECTORY_MODE = 0o700;
10
11
  const BrowserDefaultsSchema = z.object({
@@ -329,30 +330,7 @@ async function ensureBridgeLocalFiles(cwd) {
329
330
  const bridgeDir = path.join(cwd, ".bridge");
330
331
  await ensurePrivateBridgeDirectory(cwd);
331
332
  const ignorePath = path.join(bridgeDir, ".gitignore");
332
- let current = "";
333
- try {
334
- current = await readVerifiedUtf8File(ignorePath, () => assertBridgeGitignoreTargetSafe(cwd));
335
- }
336
- catch (error) {
337
- if (!isMissingFileError(error))
338
- throw error;
339
- }
340
- const required = [
341
- "tasks/*.json",
342
- "results/*.json",
343
- "sessions/*.json",
344
- "receipts/*.json",
345
- "artifacts/*",
346
- "config.local.json",
347
- "receipt-key.local",
348
- "!.gitignore"
349
- ];
350
- const lines = new Set(current.split(/\r?\n/).filter(Boolean));
351
- for (const line of required)
352
- lines.add(line);
353
- await writeVerifiedUtf8File(ignorePath, `${Array.from(lines).join("\n")}\n`, () => assertBridgeGitignoreTargetSafe(cwd), {
354
- create: true
355
- });
333
+ await ensureBridgeGitignore(ignorePath, () => assertBridgeGitignoreTargetSafe(cwd));
356
334
  }
357
335
  async function ensurePrivateBridgeDirectory(cwd) {
358
336
  await mkdir(path.join(cwd, ".bridge"), { recursive: true, mode: BRIDGE_DIRECTORY_MODE });
@@ -362,7 +340,9 @@ async function chmodPrivateBridgeDirectory(cwd) {
362
340
  const bridgeDir = path.join(cwd, ".bridge");
363
341
  const handle = await openNoFollowDirectory(bridgeDir, ".bridge");
364
342
  try {
365
- await handle.chmod(BRIDGE_DIRECTORY_MODE);
343
+ // Windows uses inherited ACLs; POSIX modes do not restrict Windows access.
344
+ if (process.platform !== "win32")
345
+ await handle.chmod(BRIDGE_DIRECTORY_MODE);
366
346
  await assertDirectoryHandle(handle, ".bridge");
367
347
  }
368
348
  finally {
@@ -421,9 +401,17 @@ async function openNoFollowDirectory(dirPath, label) {
421
401
  const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
422
402
  const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
423
403
  try {
404
+ const before = await lstat(dirPath, { bigint: true });
405
+ if (before.isSymbolicLink() || !before.isDirectory()) {
406
+ throw new Error(`${label} must be a real directory and must not be a symlink`);
407
+ }
424
408
  const handle = await open(dirPath, constants.O_RDONLY | directoryFlag | noFollowFlag);
425
409
  try {
426
410
  await assertDirectoryHandle(handle, label);
411
+ const opened = await handle.stat({ bigint: true });
412
+ if (opened.dev !== before.dev || opened.ino !== before.ino) {
413
+ throw new Error(`${label} changed while opening the directory`);
414
+ }
427
415
  return handle;
428
416
  }
429
417
  catch (error) {
package/dist/repo.js CHANGED
@@ -10,6 +10,7 @@ const execFileAsync = promisify(execFile);
10
10
  export function findRipgrep(env = process.env, exists = existsSync) {
11
11
  const pathDirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
12
12
  const home = env.HOME ?? env.USERPROFILE ?? "";
13
+ const executable = process.platform === "win32" ? "rg.exe" : "rg";
13
14
  const fallbackDirs = [
14
15
  "/usr/bin",
15
16
  "/usr/local/bin",
@@ -20,7 +21,7 @@ export function findRipgrep(env = process.env, exists = existsSync) {
20
21
  ...(home ? [path.join(home, ".cargo", "bin"), path.join(home, ".local", "bin")] : [])
21
22
  ];
22
23
  for (const dir of [...pathDirs, ...fallbackDirs]) {
23
- const candidate = path.join(dir, "rg");
24
+ const candidate = path.join(dir, executable);
24
25
  try {
25
26
  if (exists(candidate))
26
27
  return candidate;
@@ -47,6 +48,9 @@ export function assertRepoRelativePath(repoPath) {
47
48
  if (path.isAbsolute(repoPath)) {
48
49
  throw new Error("Path must be repo-relative, not absolute");
49
50
  }
51
+ if (process.platform === "win32" && repoPath.includes(":")) {
52
+ throw new Error("Path must not use a Windows alternate data stream");
53
+ }
50
54
  const normalized = path.posix.normalize(repoPath.replaceAll("\\", "/"));
51
55
  if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
52
56
  throw new Error("Path must stay inside the repo-relative root");
@@ -191,11 +195,14 @@ function parseRipgrepJsonMatch(line) {
191
195
  if (!isRipgrepJsonMatch(event))
192
196
  return undefined;
193
197
  return {
194
- path: event.data.path.text.replace(/^\.\//, ""),
198
+ path: normalizeRipgrepMatchPath(event.data.path.text),
195
199
  line: event.data.line_number,
196
200
  text: stripOneTrailingLineEnding(event.data.lines.text)
197
201
  };
198
202
  }
203
+ function normalizeRipgrepMatchPath(matchPath) {
204
+ return matchPath.split(path.sep).join("/").replace(/^\.\//, "");
205
+ }
199
206
  function isRipgrepJsonMatch(value) {
200
207
  if (!isRecord(value) || value.type !== "match" || !isRecord(value.data))
201
208
  return false;
package/dist/safe-file.js CHANGED
@@ -74,6 +74,7 @@ export async function readVerifiedUtf8File(filePath, validate, options = {}) {
74
74
  const content = await readHandleUtf8(handle, filePath, options.maxBytes);
75
75
  if (options.mode !== undefined) {
76
76
  await testHooks.beforeChmod?.(filePath, "read");
77
+ await assertSafeOpenFile(handle, filePath, "read");
77
78
  await handle.chmod(options.mode);
78
79
  }
79
80
  await validate();
@@ -108,6 +109,7 @@ export async function writeVerifiedUtf8File(filePath, content, validate, options
108
109
  await testHooks.afterWrite?.(filePath, "write");
109
110
  if (options.mode !== undefined) {
110
111
  await testHooks.beforeChmod?.(filePath, "write");
112
+ await assertSafeOpenFile(handle, filePath, "write");
111
113
  await handle.chmod(options.mode);
112
114
  }
113
115
  await validate();
@@ -131,16 +133,17 @@ export async function replaceVerifiedUtf8File(filePath, content, validate, verif
131
133
  const latestContent = await readHandleUtf8(handle, filePath, options.maxBytes);
132
134
  await verifyCurrentContent(latestContent);
133
135
  await testHooks.beforeWrite?.(filePath, "write");
134
- await replaceByVerifiedTempFile(filePath, content, validate, options, {
135
- beforeOpenAlreadyRan: true,
136
- beforeWriteAlreadyRan: true,
137
- parentSnapshot,
138
- skipExistingTargetCheck: true
139
- });
140
136
  }
141
137
  finally {
142
138
  await handle.close();
143
139
  }
140
+ // Windows cannot replace some files while their verification handle is open.
141
+ await replaceByVerifiedTempFile(filePath, content, validate, options, {
142
+ beforeOpenAlreadyRan: true,
143
+ beforeWriteAlreadyRan: true,
144
+ parentSnapshot,
145
+ skipExistingTargetCheck: true
146
+ });
144
147
  }
145
148
  async function replaceByVerifiedTempFile(filePath, content, validate, options = {}, hookState = {}) {
146
149
  await validate();
@@ -159,6 +162,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
159
162
  await writeHandleUtf8(tmpHandle, tmpPath, content);
160
163
  if (options.mode !== undefined) {
161
164
  await testHooks.beforeChmod?.(filePath, "write");
165
+ await assertSafeOpenFile(tmpHandle, tmpPath, "write");
162
166
  await tmpHandle.chmod(options.mode);
163
167
  }
164
168
  }
@@ -182,6 +186,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
182
186
  }
183
187
  }
184
188
  async function readHandleUtf8(handle, filePath, maxBytes) {
189
+ await assertOpenFileMatchesPath(handle, filePath, "read");
185
190
  const stat = await handle.stat();
186
191
  if (!stat.isFile()) {
187
192
  throw new Error("Target path is not a regular file");
@@ -205,7 +210,7 @@ async function readHandleUtf8(handle, filePath, maxBytes) {
205
210
  }
206
211
  async function writeHandleUtf8(handle, filePath, content) {
207
212
  const replacement = Buffer.from(content, "utf8");
208
- await assertSafeOpenFile(handle, filePath);
213
+ await assertSafeOpenFile(handle, filePath, "write");
209
214
  await handle.truncate(0);
210
215
  let offset = 0;
211
216
  while (offset < replacement.length) {
@@ -216,11 +221,36 @@ async function writeHandleUtf8(handle, filePath, content) {
216
221
  offset += bytesWritten;
217
222
  }
218
223
  }
219
- async function assertSafeOpenFile(handle, filePath) {
220
- const stat = await handle.stat();
221
- if (!stat.isFile()) {
224
+ async function assertOpenFileMatchesPath(handle, filePath, operation) {
225
+ const handleStat = await handle.stat({ bigint: true });
226
+ if (!handleStat.isFile()) {
227
+ throw new Error("Target path is not a regular file");
228
+ }
229
+ let pathStat;
230
+ try {
231
+ pathStat = await lstat(filePath, { bigint: true });
232
+ }
233
+ catch (error) {
234
+ if (isMissingFileError(error)) {
235
+ throw new Error(`Target path changed during ${operation} file operation`);
236
+ }
237
+ throw error;
238
+ }
239
+ if (pathStat.isSymbolicLink()) {
240
+ throw new Error(`Target path is a symlink or changed during ${operation} file operation`);
241
+ }
242
+ if (!pathStat.isFile()) {
222
243
  throw new Error("Target path is not a regular file");
223
244
  }
245
+ const handleIdentity = { dev: BigInt(handleStat.dev), ino: BigInt(handleStat.ino) };
246
+ const pathIdentity = { dev: BigInt(pathStat.dev), ino: BigInt(pathStat.ino) };
247
+ if (!sameFileIdentity(handleIdentity, pathIdentity)) {
248
+ throw new Error(`Target path changed during ${operation} file operation`);
249
+ }
250
+ }
251
+ async function assertSafeOpenFile(handle, filePath, operation) {
252
+ await assertOpenFileMatchesPath(handle, filePath, operation);
253
+ const stat = await handle.stat();
224
254
  assertNotHardLinked(filePath, stat.nlink);
225
255
  }
226
256
  function assertNotHardLinked(filePath, linkCount) {
@@ -271,8 +301,10 @@ async function captureParentSnapshot(filePath) {
271
301
  };
272
302
  }
273
303
  async function openStableNoFollow(filePath, flags, operation, parentSnapshot, mode) {
274
- if (!parentSnapshot)
275
- return openNoFollow(filePath, flags, operation, mode);
304
+ if (!parentSnapshot) {
305
+ const handle = await openNoFollow(filePath, flags, operation, mode);
306
+ return verifyOpenedHandle(handle, filePath, operation);
307
+ }
276
308
  if (process.platform !== "linux") {
277
309
  const actualParentBeforeOpen = await realpath(parentSnapshot.path);
278
310
  if (actualParentBeforeOpen !== parentSnapshot.realPath) {
@@ -284,6 +316,7 @@ async function openStableNoFollow(filePath, flags, operation, parentSnapshot, mo
284
316
  if (actualParentAfterOpen !== parentSnapshot.realPath) {
285
317
  throw new Error(`Parent directory changed during ${operation} file operation`);
286
318
  }
319
+ await assertOpenFileMatchesPath(handle, filePath, operation);
287
320
  return handle;
288
321
  }
289
322
  catch (error) {
@@ -298,12 +331,23 @@ async function openStableNoFollow(filePath, flags, operation, parentSnapshot, mo
298
331
  if (actualParent !== parentSnapshot.realPath) {
299
332
  throw new Error(`Parent directory changed during ${operation} file operation`);
300
333
  }
301
- return await openNoFollow(path.join(parentFdPath, path.basename(filePath)), flags, operation, mode);
334
+ const handle = await openNoFollow(path.join(parentFdPath, path.basename(filePath)), flags, operation, mode);
335
+ return await verifyOpenedHandle(handle, filePath, operation);
302
336
  }
303
337
  finally {
304
338
  await parentHandle.close();
305
339
  }
306
340
  }
341
+ async function verifyOpenedHandle(handle, filePath, operation) {
342
+ try {
343
+ await assertOpenFileMatchesPath(handle, filePath, operation);
344
+ return handle;
345
+ }
346
+ catch (error) {
347
+ await handle.close().catch(() => undefined);
348
+ throw error;
349
+ }
350
+ }
307
351
  async function openNoFollowDirectory(dirPath, operation) {
308
352
  const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
309
353
  const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // Everything after the anchoring uses the same helpers the in-process path uses,
18
18
  // on relative names.
19
- import { closeSync, constants, fstatSync, openSync } from "node:fs";
19
+ import { closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { link, lstat, readdir, rename, rm } from "node:fs/promises";
22
22
  import { randomUUID } from "node:crypto";
@@ -34,7 +34,22 @@ function sameIdentity(a, b) {
34
34
  function openDirectoryHere(name) {
35
35
  const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
36
36
  const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
37
- return openSync(name, constants.O_RDONLY | directoryFlag | noFollowFlag);
37
+ const before = lstatSync(name, { bigint: true });
38
+ if (before.isSymbolicLink() || !before.isDirectory()) {
39
+ throw new Error("Anchored writer expected a real directory, not a symlink");
40
+ }
41
+ const fd = openSync(name, constants.O_RDONLY | directoryFlag | noFollowFlag);
42
+ try {
43
+ const opened = fstatSync(fd, { bigint: true });
44
+ if (opened.dev !== before.dev || opened.ino !== before.ino || !opened.isDirectory()) {
45
+ throw new Error("Anchored writer directory changed while opening");
46
+ }
47
+ return fd;
48
+ }
49
+ catch (error) {
50
+ closeSync(fd);
51
+ throw error;
52
+ }
38
53
  }
39
54
  function currentDirectoryIdentity() {
40
55
  const fd = openDirectoryHere(".");
@@ -55,10 +70,10 @@ export function anchorCurrentDirectory(anchor, segments) {
55
70
  throw new Error("Anchored writer was not started in the directory the caller validated");
56
71
  }
57
72
  for (const segment of segments) {
58
- if (segment.length === 0 || segment === "." || segment === ".." || segment.includes("/")) {
73
+ if (segment.length === 0 || segment === "." || segment === ".." || /[\\/]/.test(segment) || (process.platform === "win32" && segment.includes(":"))) {
59
74
  throw new Error(`Anchored writer refuses to descend into ${JSON.stringify(segment)}`);
60
75
  }
61
- // O_NOFOLLOW proves the name is a real directory rather than a symlink, and
76
+ // The no-follow/identity checks reject symlinks and Windows junctions, and
62
77
  // the identity check after chdir proves we landed on that same directory
63
78
  // and not on something swapped in between the two calls.
64
79
  const fd = openDirectoryHere(segment);
@@ -100,7 +115,7 @@ export async function runAnchoredJob(job) {
100
115
  }
101
116
  };
102
117
  const { fileName } = job;
103
- if (fileName.length === 0 || fileName.includes("/") || fileName === "." || fileName === "..") {
118
+ if (fileName.length === 0 || /[\\/]/.test(fileName) || (process.platform === "win32" && fileName.includes(":")) || fileName === "." || fileName === "..") {
104
119
  throw new Error(`Anchored writer refuses the file name ${JSON.stringify(fileName)}`);
105
120
  }
106
121
  if (job.op === "deleteIfPresent") {
package/dist/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { createRequire } from "node:module";
3
3
  import { registerBridgeRoot } from "./registry.js";
4
+ import { ensureBridgeGitignore } from "./bridge-gitignore.js";
4
5
  import { closeSync, constants, existsSync, openSync, readdirSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
6
7
  import { link, lstat, mkdir, open, readdir, realpath, rename, rm, stat } from "node:fs/promises";
@@ -841,31 +842,7 @@ export class BridgeStore {
841
842
  }
842
843
  async ensureBridgeGitignore() {
843
844
  const ignorePath = path.join(this.bridgeDir, ".gitignore");
844
- let current = "";
845
- try {
846
- current = await readVerifiedUtf8File(ignorePath, () => this.assertBridgeGitignoreTargetSafe());
847
- }
848
- catch (error) {
849
- if (!isErrorCode(error, "ENOENT"))
850
- throw error;
851
- }
852
- const required = [
853
- "tasks/*.json",
854
- "results/*.json",
855
- "sessions/*.json",
856
- "receipts/*.json",
857
- "artifacts/*",
858
- "config.local.json",
859
- "receipt-key.local",
860
- "last-browser-send",
861
- "!.gitignore"
862
- ];
863
- const lines = new Set(current.split(/\r?\n/).filter(Boolean));
864
- for (const line of required)
865
- lines.add(line);
866
- await writeVerifiedUtf8File(ignorePath, `${Array.from(lines).join("\n")}\n`, () => this.assertBridgeGitignoreTargetSafe(), {
867
- create: true
868
- });
845
+ await ensureBridgeGitignore(ignorePath, () => this.assertBridgeGitignoreTargetSafe());
869
846
  }
870
847
  async hasReadyBridgeStorageReadOnly() {
871
848
  try {
@@ -1025,6 +1002,12 @@ export class BridgeStore {
1025
1002
  throw new Error(`Bridge record path must stay under .bridge/${kind}`);
1026
1003
  }
1027
1004
  await this.assertStorageDirIsRealDirectory(kind);
1005
+ if (job.op === "cleanupTempHardLinks") {
1006
+ await storeTestHooks.beforeRecordTempCleanup?.(kind, filePath);
1007
+ }
1008
+ else if (job.op === "writeByRename" || job.op === "linkIfAbsent") {
1009
+ await storeTestHooks.beforeRecordRename?.(kind, filePath);
1010
+ }
1028
1011
  const outcome = await runAnchoredWrite(this.bridgeDir, [kind], {
1029
1012
  ...job,
1030
1013
  fileName: path.basename(filePath),
@@ -1549,9 +1532,17 @@ async function openNoFollowDirectory(dirPath, label) {
1549
1532
  const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
1550
1533
  const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
1551
1534
  try {
1535
+ const before = await lstat(dirPath, { bigint: true });
1536
+ if (before.isSymbolicLink() || !before.isDirectory()) {
1537
+ throw new Error(`${label} must be a real directory and must not be a symlink`);
1538
+ }
1552
1539
  const handle = await open(dirPath, constants.O_RDONLY | directoryFlag | noFollowFlag);
1553
1540
  try {
1554
1541
  await assertOpenDirectoryHandle(handle);
1542
+ const opened = await handle.stat({ bigint: true });
1543
+ if (opened.dev !== before.dev || opened.ino !== before.ino) {
1544
+ throw new Error(`${label} changed while opening the directory`);
1545
+ }
1555
1546
  return handle;
1556
1547
  }
1557
1548
  catch (error) {
@@ -1574,7 +1565,9 @@ async function ensurePrivateDirectory(dirPath, label) {
1574
1565
  async function chmodPrivateDirectory(dirPath, label) {
1575
1566
  const handle = await openNoFollowDirectory(dirPath, label);
1576
1567
  try {
1577
- await handle.chmod(BRIDGE_DIRECTORY_MODE);
1568
+ // Windows uses inherited ACLs; fchmod on a read-only directory is EPERM.
1569
+ if (process.platform !== "win32")
1570
+ await handle.chmod(BRIDGE_DIRECTORY_MODE);
1578
1571
  await assertOpenDirectoryHandle(handle);
1579
1572
  }
1580
1573
  finally {
package/dist/tui-run.js CHANGED
@@ -382,5 +382,5 @@ function cancel(io) {
382
382
  }
383
383
  /** Quote only what a shell would need quoted, so the echo can be pasted. */
384
384
  export function formatCommand(args) {
385
- return args.map(shellQuote).join(" ");
385
+ return args.map((arg) => shellQuote(arg)).join(" ");
386
386
  }
@@ -33,7 +33,9 @@ Not implemented:
33
33
 
34
34
  This section connects coding agents (Claude, Codex, ChatGPT Projects) to the bridge over MCP. It is not required for the standalone terminal flow above — if you only want Pro answers in your terminal, the [Quickstart](#quickstart-a-pro-second-opinion-from-your-terminal) is complete on its own.
35
35
 
36
- Requires Node.js 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. The optional visible-browser adapter needs a Chromium-family browser: PATH binaries (`google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge`, `brave-browser`), standard macOS app bundles, Windows Program Files/LOCALAPPDATA installs, and Windows-host browsers under WSL are all probed automatically; anything else via `PRODEX_CHROME=/path/to/browser`.
36
+ Requires Node.js 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. The optional browser adapter probes PATH binaries (`google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge`, `brave-browser`), native macOS app bundles, and native Windows Program Files/LOCALAPPDATA installs. Set `PRODEX_CHROME` for another executable. WSL uses Linux browser paths, not automatic Windows-host browser discovery. Do not share one profile between native Windows Chrome and Linux Chrome. See [platform verification](platform-verification.md) and [local storage permissions](../SECURITY.md#local-storage-permissions).
37
+
38
+ Native Windows command examples use PowerShell quoting, including doubled apostrophes inside single-quoted arguments. Use PowerShell 7 for compound examples containing `&&`; these examples are not `cmd.exe` commands. Internal CLI/MCP child processes receive literal argument arrays without a shell.
37
39
 
38
40
  Install from npm — **note the scope**. The unscoped `prodex` on npm is an unrelated third-party package; do **not** install it. Use the scoped name:
39
41