@youdie006/prodex 0.40.42 → 0.40.44
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/bridge-gitignore.js +1 -1
- package/dist/cli-args.js +10 -0
- package/dist/cli-ledger.js +14 -1
- package/dist/cli-pro.js +11 -3
- package/dist/cli-server.js +25 -7
- package/dist/config.js +9 -1
- package/dist/http-mcp.js +13 -3
- package/dist/registry.js +37 -4
- package/dist/safe-file.js +56 -4
- package/dist/store.js +40 -3
- package/dist/tui.js +7 -2
- package/docs/releasing.md +43 -0
- package/package.json +1 -1
package/dist/bridge-gitignore.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/cli-ledger.js
CHANGED
|
@@ -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
|
|
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";
|
|
@@ -1270,6 +1270,8 @@ export async function runAskProCommand(rest, io, beforeSend) {
|
|
|
1270
1270
|
const sessionKey = resolveProdexSessionKey(readFlag(parsedAskPro.optionArgs, "--session-key"));
|
|
1271
1271
|
const explicitProject = readFlag(parsedAskPro.optionArgs, "--project");
|
|
1272
1272
|
const explicitProjectNew = readFlag(parsedAskPro.optionArgs, "--project-new");
|
|
1273
|
+
assertProjectName("--project", explicitProject);
|
|
1274
|
+
assertProjectName("--project-new", explicitProjectNew);
|
|
1273
1275
|
// Opt out of a pinned default project for one send. Without this a repo
|
|
1274
1276
|
// that pins a project has no route back to the plain chat list.
|
|
1275
1277
|
const suppressProject = parsedAskPro.optionArgs.includes("--no-project");
|
|
@@ -3050,9 +3052,15 @@ export function readSinceFlag(args) {
|
|
|
3050
3052
|
if (relative) {
|
|
3051
3053
|
const amount = Number(relative[1]);
|
|
3052
3054
|
const hours = relative[2]?.toLowerCase() === "d" ? amount * 24 : amount;
|
|
3053
|
-
|
|
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();
|
|
3054
3060
|
}
|
|
3055
|
-
|
|
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;
|
|
3056
3064
|
if (!Number.isFinite(parsed)) {
|
|
3057
3065
|
throw new Error(`--since expects a date or an age like 7d or 24h, not "${raw}".`);
|
|
3058
3066
|
}
|
package/dist/cli-server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { lstat,
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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 (
|
|
77
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -419,6 +420,9 @@ async function tryAcquireFileLock(filePath, parentSnapshot) {
|
|
|
419
420
|
nonce: randomUUID(),
|
|
420
421
|
started_at: new Date().toISOString()
|
|
421
422
|
};
|
|
423
|
+
const pidStart = linuxProcessStartTicks(process.pid);
|
|
424
|
+
if (pidStart !== undefined)
|
|
425
|
+
owner.pid_start = pidStart;
|
|
422
426
|
const tokenPath = fileLockTokenPath(filePath, owner);
|
|
423
427
|
const handle = await openStableNoFollow(tokenPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, "write", parentSnapshot, 0o600);
|
|
424
428
|
try {
|
|
@@ -487,7 +491,8 @@ function parseFileLockHolder(content) {
|
|
|
487
491
|
return {
|
|
488
492
|
pid: Number.isSafeInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : undefined,
|
|
489
493
|
nonce: typeof parsed.nonce === "string" && /^[A-Za-z0-9-]{1,128}$/.test(parsed.nonce) ? parsed.nonce : undefined,
|
|
490
|
-
started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined
|
|
494
|
+
started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined,
|
|
495
|
+
pid_start: typeof parsed.pid_start === "string" && /^\d{1,20}$/.test(parsed.pid_start) ? parsed.pid_start : undefined
|
|
491
496
|
};
|
|
492
497
|
}
|
|
493
498
|
catch {
|
|
@@ -497,7 +502,12 @@ function parseFileLockHolder(content) {
|
|
|
497
502
|
function parseFileLockOwner(holder) {
|
|
498
503
|
if (holder.pid === undefined || holder.nonce === undefined)
|
|
499
504
|
return undefined;
|
|
500
|
-
return {
|
|
505
|
+
return {
|
|
506
|
+
pid: holder.pid,
|
|
507
|
+
nonce: holder.nonce,
|
|
508
|
+
started_at: holder.started_at,
|
|
509
|
+
...(holder.pid_start !== undefined ? { pid_start: holder.pid_start } : {})
|
|
510
|
+
};
|
|
501
511
|
}
|
|
502
512
|
async function releaseFileLockLease(filePath, lease, parentSnapshot) {
|
|
503
513
|
try {
|
|
@@ -589,6 +599,48 @@ function processIsAlive(pid) {
|
|
|
589
599
|
return error.code === "EPERM";
|
|
590
600
|
}
|
|
591
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Whether the holder's pid now names a different process than the one that
|
|
604
|
+
* wrote the lock, so the writer is gone. A send that dies with the machine,
|
|
605
|
+
* the WSL VM or its container leaves the lock behind, and after a restart pids
|
|
606
|
+
* are handed out again: measured, a 2020 lock naming pid 1 held off every send
|
|
607
|
+
* for its whole wait budget because pid 1 was alive - as a different process.
|
|
608
|
+
*
|
|
609
|
+
* The lock records its writer's start time in clock ticks since boot, and that
|
|
610
|
+
* is compared with the pid's start ticks now: boot clock against boot clock.
|
|
611
|
+
* 0.40.43 converted the start to wall time through /proc/stat btime instead,
|
|
612
|
+
* and on WSL2 btime moves (measured 30 minutes over one day: the VM's boot
|
|
613
|
+
* clock stops while the host sleeps, then the wall clock is resynced), so a
|
|
614
|
+
* live holder looked newer than its own lock and its lock was reaped mid-send.
|
|
615
|
+
*/
|
|
616
|
+
function pidReusedSince(holder) {
|
|
617
|
+
if (holder.pid === undefined)
|
|
618
|
+
return false;
|
|
619
|
+
if (holder.pid_start !== undefined) {
|
|
620
|
+
const current = linuxProcessStartTicks(holder.pid);
|
|
621
|
+
return current !== undefined && current !== holder.pid_start;
|
|
622
|
+
}
|
|
623
|
+
// A lock from a prodex that did not record start ticks: the only safe call is
|
|
624
|
+
// that nothing alive now was running before the last boot.
|
|
625
|
+
const writtenAt = Date.parse(holder.started_at ?? "");
|
|
626
|
+
if (!Number.isFinite(writtenAt))
|
|
627
|
+
return false;
|
|
628
|
+
return writtenAt < Date.now() - os.uptime() * 1000 - 60_000;
|
|
629
|
+
}
|
|
630
|
+
function linuxProcessStartTicks(pid) {
|
|
631
|
+
if (process.platform !== "linux")
|
|
632
|
+
return undefined;
|
|
633
|
+
try {
|
|
634
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
635
|
+
// Field 22 (starttime) counted after the parenthesised command name, which
|
|
636
|
+
// may itself contain spaces.
|
|
637
|
+
const ticks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
638
|
+
return ticks !== undefined && /^\d+$/.test(ticks) ? ticks : undefined;
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
592
644
|
function delay(ms) {
|
|
593
645
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
594
646
|
}
|
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.
|
|
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.
|
|
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
|
|
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/dist/tui.js
CHANGED
|
@@ -63,10 +63,15 @@ export function consultArgsFromChoices(choices) {
|
|
|
63
63
|
*/
|
|
64
64
|
export function parseAttachmentLine(line) {
|
|
65
65
|
const out = [];
|
|
66
|
-
|
|
66
|
+
// An unquoted word may carry backslash escapes: dropping a file onto a macOS
|
|
67
|
+
// terminal types `My\ File.pdf`, which used to split into two paths that do
|
|
68
|
+
// not exist. Only a space or shell punctuation counts as escaped, so a
|
|
69
|
+
// Windows separator (a backslash before a letter or another backslash) stays.
|
|
70
|
+
const pattern = /"([^"]*)"|'([^']*)'|((?:\\[ ()\[\]{}'"&;!$`*?#~<>|]|\S)+)/g;
|
|
67
71
|
let match;
|
|
68
72
|
while ((match = pattern.exec(line)) !== null) {
|
|
69
|
-
const
|
|
73
|
+
const unquoted = match[3]?.replace(/\\([ ()\[\]{}'"&;!$`*?#~<>|])/g, "$1");
|
|
74
|
+
const value = (match[1] ?? match[2] ?? unquoted ?? "").trim();
|
|
70
75
|
if (value.length > 0)
|
|
71
76
|
out.push(value);
|
|
72
77
|
}
|
package/docs/releasing.md
CHANGED
|
@@ -35,6 +35,49 @@ 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.43 Release Record
|
|
39
|
+
|
|
40
|
+
Release commit `834e3e2` on `main` (cut after PRs #49-#58, last merge `a6bbf85`); tag `v0.40.43`. Ten fixes, including two that block or corrupt work: a send lock left by a dead send whose pid was reused blocked every later send (#54), and two processes opening a fresh repo could fail one with a false "receipt-key.local is corrupt" (#58).
|
|
41
|
+
|
|
42
|
+
**Publishing**
|
|
43
|
+
- `publish.yml` run 40451563636 first failed on macOS 15 Intel only: `tests/cli.test.ts` "uses an explicit --cwd target for local HTTP MCP start" timed out at 90 s; the same commit passed on the five other platforms and in PR CI. The failed job was rerun and passed, then npm publish with SLSA v1 provenance and the GitHub Release ran. The GitHub Release lists the ten entries 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.43. 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 `834e3e2` and rebuilt.
|
|
49
|
+
|
|
50
|
+
**Container**
|
|
51
|
+
- `container/redesign-patches` `f72862c` ports #49-#58; full suite (164 files, 2098 tests) and `release:verify` pass on it.
|
|
52
|
+
- WSL: image rebuilt and the container swapped to `1d48d753fbf7`; the in-container send lock was idle before and after.
|
|
53
|
+
- M3: not rebuilt in this release. The build over ssh was refused by the agent's permission classifier; the M3 container still runs the 0.40.42 port.
|
|
54
|
+
|
|
55
|
+
**Verified live on installed 0.40.43 (WSL)**
|
|
56
|
+
- `pro blockers --since 7` refused: "--since expects a date or an age like 7d or 24h".
|
|
57
|
+
- `pro blockers --json` across the machine: 21 roots and 657 consults (before: 27 roots, about twice the consults, counting case-variant paths of the same repo).
|
|
58
|
+
- `ask --project " "` refused: "--project is blank".
|
|
59
|
+
|
|
60
|
+
## 0.40.42 Release Record
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
**Publishing**
|
|
65
|
+
- `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`.
|
|
66
|
+
- `npm run release:verify` passed on the release commit after a build.
|
|
67
|
+
|
|
68
|
+
**Installed**
|
|
69
|
+
- 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.
|
|
70
|
+
- The maintainer watchdog checkout was pulled to `9c97a14` and rebuilt.
|
|
71
|
+
|
|
72
|
+
**Verified live on installed 0.40.42 (WSL, scratch repo)**
|
|
73
|
+
- `--attach .env` refused: "Path .env is sensitive and cannot be read through repo tools".
|
|
74
|
+
- `--attach link.pdf` (symlink to a file outside the repo) refused: "escapes the repository root after resolving symlinks".
|
|
75
|
+
- `--attach note.txt` still uploads; ChatGPT answered with the code word from the file, `request_verified: yes`.
|
|
76
|
+
|
|
77
|
+
**Browser container**
|
|
78
|
+
- `container/redesign-patches` `41c34de` ports PR #48. Branch suite: 2081 passed, 3 skipped; `release:verify` ok.
|
|
79
|
+
- 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`.
|
|
80
|
+
|
|
38
81
|
## 0.40.41 Release Record
|
|
39
82
|
|
|
40
83
|
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.
|