@youdie006/prodex 0.40.41 → 0.40.43

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.
@@ -2,7 +2,7 @@ import { lstat } from "node:fs/promises";
2
2
  import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
3
3
  const required = [
4
4
  "tasks/*.json", "results/*.json", "sessions/*.json", "receipts/*.json",
5
- "artifacts/*", "config.local.json", "receipt-key.local", "last-browser-send", "!.gitignore"
5
+ "artifacts/*", "diagnostics/", "config.local.json", "receipt-key.local", "last-browser-send", "!.gitignore"
6
6
  ];
7
7
  export async function ensureBridgeGitignore(filePath, validate) {
8
8
  let current = "";
package/dist/cli-args.js CHANGED
@@ -404,3 +404,13 @@ export function printHelpIfRequested(args, command, stdout, printHelp, options =
404
404
  printHelp(stdout);
405
405
  return true;
406
406
  }
407
+ /**
408
+ * A project is found and created by its name, so a name that is only spaces
409
+ * names nothing: it used to reach the browser, where --project-new typed it
410
+ * into ChatGPT's new-project dialog.
411
+ */
412
+ export function assertProjectName(flag, value) {
413
+ if (value !== undefined && !value.trim()) {
414
+ throw new Error(`${flag} is blank; give the ChatGPT project name, or leave the option out.`);
415
+ }
416
+ }
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { assertNoExtraArgs, assertOnlyOptions, isHelpSubcommand, printHelpIfRequested, readFlag, readPositionalsWithOptions, readReceiptKindFlag, readRepeatedFlag, readSessionStatusFlag, readTaskStatusFlag, resolveCwdFlag, unknownSubcommandError } from "./cli-args.js";
2
3
  import { printReceiptsHelp, printResultsHelp, printSessionsHelp, printTasksHelp } from "./cli-help.js";
3
4
  import { sourceAwareResultError } from "./cli-shared.js";
@@ -355,7 +356,8 @@ export function receiptInspectionListSuffix(receipt) {
355
356
  return "";
356
357
  }
357
358
  export async function writeTaskCompleteArtifacts(store, values) {
358
- const artifacts = [];
359
+ const parsed = [];
360
+ const seen = new Set();
359
361
  for (const value of values) {
360
362
  const separator = value.indexOf("=");
361
363
  if (separator <= 0) {
@@ -366,6 +368,17 @@ export async function writeTaskCompleteArtifacts(store, values) {
366
368
  if (!artifactPath.trim()) {
367
369
  throw new Error("tasks complete --artifact requires path=text");
368
370
  }
371
+ // Checked before anything is written: a repeated path used to overwrite
372
+ // the earlier text silently and be recorded twice.
373
+ const key = path.posix.normalize(artifactPath.replaceAll("\\", "/"));
374
+ if (seen.has(key)) {
375
+ throw new Error(`tasks complete --artifact ${artifactPath} names a file more than once; pass each artifact path once.`);
376
+ }
377
+ seen.add(key);
378
+ parsed.push({ artifactPath, content });
379
+ }
380
+ const artifacts = [];
381
+ for (const { artifactPath, content } of parsed) {
369
382
  const storedPath = await store.writeArtifactText(artifactPath, content);
370
383
  artifacts.push({ path: storedPath, role: "result" });
371
384
  }
package/dist/cli-pro.js CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { BrowserProcessInspectionError } from "./browser-process.js";
6
6
  import { buildDryRunBundle } from "./bundle.js";
7
7
  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, chatStepNamedAsModel, normalizeChatGptThreadUrl } from "./chatgpt-browser.js";
8
- 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";
8
+ 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, assertProjectName } from "./cli-args.js";
9
9
  import { printProBrowserHelp, printProHelp } from "./cli-help.js";
10
10
  import { listRawResultsForInspection, listTasksForInspection } from "./cli-ledger.js";
11
11
  import { formatBrowserDefaults, redactServerUrl } from "./cli-server.js";
@@ -19,6 +19,7 @@ import { readBridgeRoots } from "./registry.js";
19
19
  import { ProdexRequestIdSchema, SessionKeySchema } from "./schema.js";
20
20
  import { BridgeStore, MAX_FETCHABLE_RESULT_ARTIFACT_BYTES } from "./store.js";
21
21
  import { CLI_VERSION } from "./cli-help.js";
22
+ import { assertRepoRelativePath, assertResolvedRepoPathAllowed } from "./repo.js";
22
23
  import { PRODEX_ISSUE_REPO, buildIssueReport, fileGitHubIssue } from "./issue-report.js";
23
24
  function resolveBrowserProfileDirForLaunch(profileDir, launchCwd = process.cwd()) {
24
25
  const resolved = path.resolve(launchCwd, profileDir);
@@ -1208,6 +1209,21 @@ export async function runAskProCommand(rest, io, beforeSend) {
1208
1209
  }
1209
1210
  return absolute;
1210
1211
  });
1212
+ // An upload leaves the machine, so it gets the same guard as --file and
1213
+ // repo_read_file: no .env, .git or key files, and no symlink out of the
1214
+ // repo. --attach used to check only "inside the repo and exists", so both
1215
+ // `--attach .env` and a link to ~/.ssh reached the upload (measured
1216
+ // 2026-10-26); MCP attach takes this route too.
1217
+ for (const [index, absolute] of attachments.entries()) {
1218
+ const repoPath = path.relative(targetCwd, absolute).split(path.sep).join("/");
1219
+ try {
1220
+ assertRepoRelativePath(repoPath);
1221
+ await assertResolvedRepoPathAllowed(targetCwd, absolute, repoPath);
1222
+ }
1223
+ catch (error) {
1224
+ throw new Error(`--attach "${readRepeatedFlag(parsedAskPro.optionArgs, "--attach")[index]}" refused: ${errorMessage(error)}.`, { cause: error });
1225
+ }
1226
+ }
1211
1227
  if (attachments.length > 0 && !hasSendMode) {
1212
1228
  throw new Error("--attach only applies when sending (`prodex pro browser ask`); the dry-run preview cannot upload files.");
1213
1229
  }
@@ -1254,6 +1270,8 @@ export async function runAskProCommand(rest, io, beforeSend) {
1254
1270
  const sessionKey = resolveProdexSessionKey(readFlag(parsedAskPro.optionArgs, "--session-key"));
1255
1271
  const explicitProject = readFlag(parsedAskPro.optionArgs, "--project");
1256
1272
  const explicitProjectNew = readFlag(parsedAskPro.optionArgs, "--project-new");
1273
+ assertProjectName("--project", explicitProject);
1274
+ assertProjectName("--project-new", explicitProjectNew);
1257
1275
  // Opt out of a pinned default project for one send. Without this a repo
1258
1276
  // that pins a project has no route back to the plain chat list.
1259
1277
  const suppressProject = parsedAskPro.optionArgs.includes("--no-project");
@@ -3034,9 +3052,15 @@ export function readSinceFlag(args) {
3034
3052
  if (relative) {
3035
3053
  const amount = Number(relative[1]);
3036
3054
  const hours = relative[2]?.toLowerCase() === "d" ? amount * 24 : amount;
3037
- return new Date(Date.now() - hours * 3_600_000).toISOString();
3055
+ const since = new Date(Date.now() - hours * 3_600_000);
3056
+ if (!Number.isFinite(since.getTime())) {
3057
+ throw new Error(`--since ${raw} reaches too far back to be a date; use an age like 365d or a date like 2026-01-01.`);
3058
+ }
3059
+ return since.toISOString();
3038
3060
  }
3039
- const parsed = Date.parse(raw);
3061
+ // Only a calendar date: Date.parse also reads a bare "7" as 2001-07-01, so a
3062
+ // forgotten unit silently widened the report to every consult ever recorded.
3063
+ const parsed = /^\d{4}-\d{2}-\d{2}(?:[T ]|$)/.test(raw.trim()) ? Date.parse(raw) : Number.NaN;
3040
3064
  if (!Number.isFinite(parsed)) {
3041
3065
  throw new Error(`--since expects a date or an age like 7d or 24h, not "${raw}".`);
3042
3066
  }
@@ -1,10 +1,10 @@
1
- import { lstat, mkdir } from "node:fs/promises";
1
+ import { lstat, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseProMode, parseReasoningEffort } from "./chatgpt-browser.js";
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";
4
+ import { ASK_PRO_SELECTION_CLEAR_FLAGS, ASK_PRO_SELECTION_DEFAULT_FLAGS, assertNoExtraArgs, assertOnlyOptions, isHelpSubcommand, printHelpIfRequested, readFlag, readPortFlag, readPositiveNumberFlag, resolveCwdFlag, resolveOptionalFileFlag, unknownSubcommandError, assertProjectName } 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, composeServerUrlWithToken } from "./config.js";
7
+ import { getTokenExpiryStatus, loadLocalConfig, writeLocalConfig, composeServerUrlWithToken, lowercaseFirst } 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";
@@ -245,13 +245,21 @@ export const TOKEN_BEARING_MCP_URL_AUTHORITY_WARNING = "Token-bearing MCP URL au
245
245
  export function assertTokenNotExpiredForCommand(config, sourceCli, setupHintCwd) {
246
246
  const tokenStatus = getTokenExpiryStatus(config);
247
247
  if (tokenStatus.status === "expired") {
248
- throw new Error(sourceAwareSetupMessage(tokenStatus.warning.toLowerCase(), sourceCli, { cwd: setupHintCwd }));
248
+ throw new Error(sourceAwareSetupMessage(lowercaseFirst(tokenStatus.warning), sourceCli, { cwd: setupHintCwd }));
249
249
  }
250
250
  }
251
+ /**
252
+ * Root ignores for a prodex source checkout. `.bridge/.gitignore` is written by
253
+ * BridgeStore.ensure() from the one shared list; this used to overwrite it with
254
+ * an older list that lacked last-browser-send.
255
+ *
256
+ * node_modules/ and dist/ go into the ROOT .gitignore only when the target is
257
+ * prodex itself: added to any repo, they silently stopped tracking new build
258
+ * output in projects that commit dist/ (JavaScript GitHub Actions do).
259
+ */
251
260
  export async function ensureBridgeGitignore(cwd) {
252
- const bridgeIgnorePath = path.join(cwd, ".bridge", ".gitignore");
253
- await mkdir(path.dirname(bridgeIgnorePath), { recursive: true });
254
- await writeVerifiedUtf8File(bridgeIgnorePath, ["tasks/*.json", "results/*.json", "sessions/*.json", "receipts/*.json", "artifacts/*", "config.local.json", "receipt-key.local", "!.gitignore", ""].join("\n"), () => assertGitignoreTargetSafe(bridgeIgnorePath), { create: true });
261
+ if (!(await isProdexSourceCheckout(cwd)))
262
+ return;
255
263
  const rootIgnorePath = path.join(cwd, ".gitignore");
256
264
  let current = "";
257
265
  try {
@@ -267,6 +275,15 @@ export async function ensureBridgeGitignore(cwd) {
267
275
  await writeVerifiedUtf8File(rootIgnorePath, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${additions.join("\n")}\n`, () => assertGitignoreTargetSafe(rootIgnorePath), { create: true });
268
276
  }
269
277
  }
278
+ async function isProdexSourceCheckout(cwd) {
279
+ try {
280
+ const manifest = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8"));
281
+ return manifest.name === "@youdie006/prodex";
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ }
270
287
  export function formatBrowserDefaults(defaults) {
271
288
  const parts = [];
272
289
  if (defaults.model)
@@ -324,6 +341,7 @@ export function parseBrowserDefaultFlags(args) {
324
341
  const proModeRaw = readFlag(args, "--pro-mode");
325
342
  const effortRaw = readFlag(args, "--effort");
326
343
  const project = readFlag(args, "--project");
344
+ assertProjectName("--project", project);
327
345
  if (proModeRaw !== undefined && effortRaw !== undefined) {
328
346
  throw new Error("setup cannot combine --pro-mode and --effort; Pro sub-modes and reasoning effort are different model axes.");
329
347
  }
package/dist/config.js CHANGED
@@ -280,10 +280,18 @@ export function getTokenExpiryStatus(config, now = new Date()) {
280
280
  }
281
281
  : { status: "valid", token_expires_at: config.token_expires_at };
282
282
  }
283
+ /**
284
+ * "Token expired ..." reads as "failed token expired ..." after a status
285
+ * prefix. Only the first letter: lowercasing the whole message printed the
286
+ * expiry as "2026-10-26t00:00:00.000z", which is no longer an ISO time.
287
+ */
288
+ export function lowercaseFirst(message) {
289
+ return message.charAt(0).toLowerCase() + message.slice(1);
290
+ }
283
291
  export function assertTokenNotExpired(config, now = new Date()) {
284
292
  const status = getTokenExpiryStatus(config, now);
285
293
  if (status.status === "expired") {
286
- throw new Error(status.warning.toLowerCase());
294
+ throw new Error(lowercaseFirst(status.warning));
287
295
  }
288
296
  }
289
297
  const MAX_TOKEN_EXPIRES_AT_MS = Date.parse("9999-12-31T23:59:59.999Z");
package/dist/http-mcp.js CHANGED
@@ -13,7 +13,16 @@ export async function startHttpMcpServer(options) {
13
13
  const transports = new Map();
14
14
  const server = http.createServer(async (req, res) => {
15
15
  try {
16
- const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
16
+ // Only the path and query are read, so the base is fixed: taking it from
17
+ // the Host header turned a malformed header into a 500.
18
+ let requestUrl;
19
+ try {
20
+ requestUrl = new URL(req.url ?? "/", "http://localhost");
21
+ }
22
+ catch {
23
+ writeJson(res, 400, { error: "bad_request" });
24
+ return;
25
+ }
17
26
  if (requestUrl.pathname === "/health") {
18
27
  writeJson(res, 200, { ok: true, name: "prodex" });
19
28
  return;
@@ -241,8 +250,9 @@ function isAuthorized(req, url, token, tokenExpiresAt) {
241
250
  const provided = url.searchParams.get("prodex_token");
242
251
  if (provided !== null && safeEqual(provided, token))
243
252
  return true;
244
- const authorization = headerValue(req.headers.authorization);
245
- return authorization !== undefined && safeEqual(authorization, `Bearer ${token}`);
253
+ // The scheme is case-insensitive (RFC 7235): "bearer <token>" is the same.
254
+ const bearer = /^bearer +(.+)$/i.exec(headerValue(req.headers.authorization) ?? "");
255
+ return bearer !== null && safeEqual(bearer[1], token);
246
256
  }
247
257
  function isExpired(tokenExpiresAt) {
248
258
  const expiresAtMs = Date.parse(tokenExpiresAt);
package/dist/registry.js CHANGED
@@ -46,6 +46,24 @@ async function directoryExists(dir) {
46
46
  return false;
47
47
  }
48
48
  }
49
+ /**
50
+ * Which directory a path names, independent of how it is spelled. realpath
51
+ * resolves symlinks but keeps the letter case it was given, so on a
52
+ * case-insensitive drive (WSL /mnt/d, macOS) /mnt/d/MyProject/x and
53
+ * /mnt/d/myproject/x are one directory under two names. Undefined when the
54
+ * path is not a directory, or the filesystem reports no inode to compare.
55
+ */
56
+ async function directoryIdentity(dir) {
57
+ try {
58
+ const stat = await fs.stat(dir, { bigint: true });
59
+ if (!stat.isDirectory() || stat.ino === 0n)
60
+ return undefined;
61
+ return `${stat.dev}:${stat.ino}`;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
49
67
  /**
50
68
  * Every bridge root this machine knows about. The registry has only ever been
51
69
  * written; reading it is what lets a report span the repos where the failures
@@ -67,14 +85,24 @@ export async function readBridgeRoots() {
67
85
  if (!Array.isArray(raw))
68
86
  return [];
69
87
  const roots = [];
88
+ const identities = new Set();
70
89
  for (const entry of raw.slice(0, MAX_REGISTRY_ROOTS)) {
71
90
  const value = typeof entry === "string" ? entry : entry?.path;
72
91
  if (typeof value !== "string" || value.length === 0)
73
92
  continue;
74
93
  if (!(await directoryExists(path.join(value, ".bridge"))))
75
94
  continue;
76
- if (!roots.includes(value))
77
- roots.push(value);
95
+ if (roots.includes(value))
96
+ continue;
97
+ // One bridge listed under two spellings was read twice: measured, a
98
+ // machine-wide `pro blockers` counted 1247 consults where 648 exist.
99
+ const identity = await directoryIdentity(value);
100
+ if (identity !== undefined) {
101
+ if (identities.has(identity))
102
+ continue;
103
+ identities.add(identity);
104
+ }
105
+ roots.push(value);
78
106
  }
79
107
  return roots;
80
108
  }
@@ -111,10 +139,15 @@ async function registerBridgeRootInner(root) {
111
139
  // A genuinely new root: opportunistically prune entries whose directory
112
140
  // is gone (cheap here since this path only runs on first registration,
113
141
  // not on every ensure()) and cap total growth, keeping the newest.
142
+ const identity = await directoryIdentity(abs);
114
143
  const survivors = [];
115
144
  for (const existing of roots) {
116
- if (await directoryExists(existing))
117
- survivors.push(existing);
145
+ if (!(await directoryExists(existing)))
146
+ continue;
147
+ // Already listed under another spelling (see directoryIdentity).
148
+ if (identity !== undefined && (await directoryIdentity(existing)) === identity)
149
+ return;
150
+ survivors.push(existing);
118
151
  }
119
152
  survivors.push(abs);
120
153
  roots = survivors.length > MAX_REGISTRY_ROOTS ? survivors.slice(survivors.length - MAX_REGISTRY_ROOTS) : survivors;
package/dist/safe-file.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { constants } from "node:fs";
2
+ import { constants, readFileSync } from "node:fs";
3
3
  import { chmod, link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  let testHooks = {};
6
7
  export function setSafeFileTestHooks(hooks) {
@@ -38,7 +39,7 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
38
39
  continue;
39
40
  }
40
41
  retriedMissingAtDeadline = false;
41
- const holderAlive = snapshot.holder.pid !== undefined && processIsAlive(snapshot.holder.pid);
42
+ const holderAlive = snapshot.holder.pid !== undefined && processIsAlive(snapshot.holder.pid) && !pidReusedSince(snapshot.holder);
42
43
  if (!holderAlive && (await reapFileLockSnapshot(filePath, snapshot, parentSnapshot)))
43
44
  continue;
44
45
  if (Date.now() >= deadline) {
@@ -589,6 +590,42 @@ function processIsAlive(pid) {
589
590
  return error.code === "EPERM";
590
591
  }
591
592
  }
593
+ /**
594
+ * Whether the holder's pid now names a process that started after the lock was
595
+ * written, so the writer is gone. A send that dies with the machine, the WSL VM
596
+ * or its container leaves the lock behind, and after a restart pids are handed
597
+ * out again: measured, a 2020 lock naming pid 1 held off every send for its
598
+ * whole wait budget because pid 1 was alive - as a different process.
599
+ */
600
+ function pidReusedSince(holder) {
601
+ const writtenAt = Date.parse(holder.started_at ?? "");
602
+ if (holder.pid === undefined || !Number.isFinite(writtenAt))
603
+ return false;
604
+ const processStartedAt = linuxProcessStartMs(holder.pid);
605
+ // A holder writes the lock after it starts; a few seconds absorb clock steps.
606
+ if (processStartedAt !== undefined)
607
+ return processStartedAt > writtenAt + 5_000;
608
+ // No per-process start time here: nothing alive now predates the boot.
609
+ return writtenAt < Date.now() - os.uptime() * 1000 - 60_000;
610
+ }
611
+ function linuxProcessStartMs(pid) {
612
+ if (process.platform !== "linux")
613
+ return undefined;
614
+ try {
615
+ const btime = /^btime (\d+)$/m.exec(readFileSync("/proc/stat", "utf8"))?.[1];
616
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
617
+ // Field 22 (starttime, in clock ticks since boot) counted after the
618
+ // parenthesised command name, which may itself contain spaces.
619
+ const ticks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
620
+ if (!btime || !Number.isFinite(ticks))
621
+ return undefined;
622
+ // USER_HZ is 100 on every Linux ABI Node supports.
623
+ return Number(btime) * 1000 + ticks * 10;
624
+ }
625
+ catch {
626
+ return undefined;
627
+ }
628
+ }
592
629
  function delay(ms) {
593
630
  return new Promise((resolve) => setTimeout(resolve, ms));
594
631
  }
package/dist/store.js CHANGED
@@ -233,6 +233,7 @@ export class BridgeStore {
233
233
  async completeTask(taskId, input) {
234
234
  const task = await this.getTask(taskId);
235
235
  assertFetchableResultArtifacts(input.artifacts ?? []);
236
+ assertDistinctArtifactPaths(input.artifacts ?? []);
236
237
  const retryResult = ResultSchema.parse({
237
238
  schema_version: SCHEMA_VERSION,
238
239
  task_id: taskId,
@@ -834,7 +835,7 @@ export class BridgeStore {
834
835
  }
835
836
  async ensureReceiptIntegrityKey() {
836
837
  try {
837
- const [activeKey] = await this.readReceiptIntegrityKeys();
838
+ const [activeKey] = await this.readReceiptIntegrityKeysAfterCreate();
838
839
  return activeKey;
839
840
  }
840
841
  catch (error) {
@@ -857,10 +858,27 @@ export class BridgeStore {
857
858
  catch (error) {
858
859
  if (!isErrorCode(error, "EEXIST"))
859
860
  throw error;
860
- const [activeKey] = await this.readReceiptIntegrityKeys();
861
+ const [activeKey] = await this.readReceiptIntegrityKeysAfterCreate();
861
862
  return activeKey;
862
863
  }
863
864
  }
865
+ // The key is created exclusively and THEN written, so another store opening
866
+ // the same fresh repo can read it in between and find it empty. Measured on
867
+ // CI: that read was reported as "receipt-key.local is corrupt. Move it
868
+ // aside", advice that would discard a good key. An empty file is a write in
869
+ // progress for a short while; only after that is it corrupt.
870
+ async readReceiptIntegrityKeysAfterCreate() {
871
+ for (let attempt = 0;; attempt += 1) {
872
+ try {
873
+ return await this.readReceiptIntegrityKeys();
874
+ }
875
+ catch (error) {
876
+ if (!(error instanceof ReceiptKeyFileEmptyError) || attempt >= 40)
877
+ throw error;
878
+ await new Promise((resolve) => setTimeout(resolve, 50));
879
+ }
880
+ }
881
+ }
864
882
  // The key file holds one 32-byte hex key per line: the first line signs new
865
883
  // receipts, later lines are legacy keys kept so pre-rotation receipts verify.
866
884
  async readReceiptIntegrityKeys() {
@@ -870,7 +888,9 @@ export class BridgeStore {
870
888
  .split(/\r?\n/)
871
889
  .map((line) => line.trim())
872
890
  .filter((line) => line.length > 0);
873
- if (lines.length === 0 || !lines.every((line) => /^[a-f0-9]{64}$/.test(line))) {
891
+ if (lines.length === 0)
892
+ throw new ReceiptKeyFileEmptyError();
893
+ if (!lines.every((line) => /^[a-f0-9]{64}$/.test(line))) {
874
894
  throw new Error(".bridge/receipt-key.local is corrupt. Move it aside and recreate write receipts.");
875
895
  }
876
896
  return lines;
@@ -1462,6 +1482,23 @@ function assertFetchableResultArtifacts(artifacts) {
1462
1482
  }
1463
1483
  }
1464
1484
  }
1485
+ // Two entries for one file record one content under two names: the file holds
1486
+ // whichever was written last, and a reader asking for "the" result artifact is
1487
+ // told there are several.
1488
+ function assertDistinctArtifactPaths(artifacts) {
1489
+ const seen = new Set();
1490
+ for (const artifact of artifacts) {
1491
+ if (seen.has(artifact.path)) {
1492
+ throw new Error(`Artifact ${artifact.path} is listed more than once; list each artifact once.`);
1493
+ }
1494
+ seen.add(artifact.path);
1495
+ }
1496
+ }
1497
+ class ReceiptKeyFileEmptyError extends Error {
1498
+ constructor() {
1499
+ super(".bridge/receipt-key.local is corrupt. Move it aside and recreate write receipts.");
1500
+ }
1501
+ }
1465
1502
  function parseTaskRecord(expectedId, value) {
1466
1503
  const task = TaskSchema.parse(value);
1467
1504
  assertMatchingRecordIdentity("task", expectedId, task.id);
package/docs/releasing.md CHANGED
@@ -35,6 +35,53 @@ The release adds a guarded, opt-in transition and more accurate blocker reportin
35
35
  **Open**
36
36
  - A long-running headless browser ends up behind Cloudflare's check (also noted on WSL, where the Xvfb display is used instead). On macOS there is no virtual display, and a minimized window counts as hidden, so the remaining no-headless option there is a window kept behind others. Changing the headless preference is a design decision that was not made here.
37
37
 
38
+ ## 0.40.42 Release Record
39
+
40
+ Release commit `9c97a14` on `main` (cut after PR #48, merge `cc129d8`); tag `v0.40.42`. Released on its own: before it, `--attach` and MCP `attach` uploaded `.env`, `.git` contents, key files and outward symlinks.
41
+
42
+ **Publishing**
43
+ - `publish.yml` run 40284687723 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-26 07:15 UTC start). The GitHub Release lists the one entry from `CHANGELOG.md`.
44
+ - `npm run release:verify` passed on the release commit after a build.
45
+
46
+ **Installed**
47
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.42. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
48
+ - The maintainer watchdog checkout was pulled to `9c97a14` and rebuilt.
49
+
50
+ **Verified live on installed 0.40.42 (WSL, scratch repo)**
51
+ - `--attach .env` refused: "Path .env is sensitive and cannot be read through repo tools".
52
+ - `--attach link.pdf` (symlink to a file outside the repo) refused: "escapes the repository root after resolving symlinks".
53
+ - `--attach note.txt` still uploads; ChatGPT answered with the code word from the file, `request_verified: yes`.
54
+
55
+ **Browser container**
56
+ - `container/redesign-patches` `41c34de` ports PR #48. Branch suite: 2081 passed, 3 skipped; `release:verify` ok.
57
+ - WSL image `491971a34a26` and M3 image `59747258b693`, each swapped after an idle send-lock check; in both, `--attach .env.probe` is refused and smoke returned `requestVerified: true`.
58
+
59
+ ## 0.40.41 Release Record
60
+
61
+ Release commit `e0ea707` on `main` (cut after PRs #45-#47, last merge `6911f3e`); tag `v0.40.41`. Released ahead of the usual batch for PR #46, which withheld answers from requests that had posted.
62
+
63
+ **Publishing**
64
+ - `publish.yml` run 40274938156 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-26 05:34 UTC start).
65
+ - `npm run release:verify` passed on the release commit after a build. The release script now refuses to tag when the release section has duplicate lines or an `## Unreleased` section survives the rename; the GitHub Release lists the same three entries as `CHANGELOG.md`.
66
+
67
+ **Installed**
68
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.41. Running services were not restarted: no host stdio MCP servers on WSL and 1 on M3; the Mac mini had none.
69
+ - The maintainer watchdog checkout was pulled to `e0ea707` and rebuilt.
70
+
71
+ **Verified live on installed 0.40.41 (WSL)**
72
+ - `Reply with only OK. Run npm test first, then \`npm test\` again.`: `request_verified: yes` (0.40.40 refused it as `request_mismatch`). Before the fix, the 2026-10-24 thread whose two sends had been refused also matched when re-read with the fixed extraction.
73
+ - `--model Instant` in a repo with a saved Pro effort: applied to the slider with `model_is_now_an_effort`, answered in 9 s.
74
+ - `pro browser recover --target-url "https://chatgpt.com/c/abc def"`: refused before the send lock; the shared tab did not move.
75
+
76
+ **Browser container**
77
+ - `container/redesign-patches` `60a49da` ports #45, `5154330` ports #46 (applied by hand: the container's message helpers differ from `main`), `6a9ea08` ports #47. Branch suite at `6a9ea08`: 2080 passed, 3 skipped; `release:verify` ok.
78
+ - WSL images `1ce5bcde0ace` (#45, `--model Instant` applied live), `88bd2a8f427a` (#46, the backtick repro verified), `e83a90389d02` (#47, bad URL refused, smoke `requestVerified: true`).
79
+ - M3 (colima `prodex-check`) images `c289e00e756d`, `ee6f98e1588d` (backtick repro verified), `5cd5a5642917` (bad URL refused, smoke ok). Every swap waited for an idle send lock.
80
+
81
+ **Incidents**
82
+ - 2026-10-22: the WSL disk filled to 100% from another project's session scratch data, so command output stopped being written mid-port. Nothing in prodex was involved; the #45 container port resumed after that session's data was cleaned up on its side.
83
+ - 2026-10-26: probing recovery input with `--target-url "https://chatgpt.com/c/abc def"` on 0.40.40 moved the shared ChatGPT tab to that URL (the bug PR #47 fixes); no send was in flight and the tab was moved back to the home page.
84
+
38
85
  ## 0.40.40 Release Record
39
86
 
40
87
  Release commit `3d671f5` on `main` (cut after PRs #38-#44, last merge `5609f4c`); tag `v0.40.40`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.41",
3
+ "version": "0.40.43",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",