@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.
- 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 +27 -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 +39 -2
- package/dist/store.js +40 -3
- package/docs/releasing.md +47 -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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
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) {
|
|
@@ -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.
|
|
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/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`.
|