@youdie006/prodex 0.40.6 → 0.40.8
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/README.md +25 -10
- package/dist/browser-send-lock.js +10 -131
- package/dist/chatgpt-browser.js +275 -691
- package/dist/cli-help.js +20 -17
- package/dist/cli-ledger.js +2 -0
- package/dist/cli-pro.js +216 -102
- package/dist/cli-server.js +2 -2
- package/dist/cli.js +1 -4
- package/dist/config.js +3 -3
- package/dist/http-mcp.js +1 -1
- package/dist/issue-report.js +9 -4
- package/dist/mcp-tools.js +42 -10
- package/dist/mcp.js +2 -2
- package/dist/registry.js +54 -6
- package/dist/repo-write.js +22 -2
- package/dist/safe-file.js +249 -1
- package/dist/store.js +7 -1
- package/dist/tui-flow.js +0 -1
- package/dist/tui-run.js +62 -25
- package/dist/tui.js +68 -54
- package/docs/claude.md +3 -1
- package/docs/cli-reference.md +17 -6
- package/docs/clients.md +4 -6
- package/docs/http-mcp.md +5 -1
- package/docs/releasing.md +3 -1
- package/package.json +1 -1
package/dist/issue-report.js
CHANGED
|
@@ -24,14 +24,18 @@ export function issueAreaLabel(code) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Build the report. Only the failure travels: never the prompt, the answer, or
|
|
26
26
|
* the summary, because a public issue must not become where a private consult
|
|
27
|
-
* leaks.
|
|
27
|
+
* leaks. Blocker prose can contain answers, paths and thread URLs, so it stays
|
|
28
|
+
* local too. Public reports carry a code and a fixed diagnostic summary.
|
|
28
29
|
*/
|
|
29
30
|
export function buildIssueReport(consult, environment) {
|
|
30
31
|
if (consult.status !== "blocked" || !consult.blocker) {
|
|
31
32
|
throw new Error(`${consult.task_id} is not a failure (status ${consult.status}), so there is nothing to report.`);
|
|
32
33
|
}
|
|
33
|
-
const
|
|
34
|
-
const
|
|
34
|
+
const rawCode = (consult.blocker.code ?? "unknown").trim();
|
|
35
|
+
const code = /^[a-z][a-z0-9_]{0,79}$/.test(rawCode) ? rawCode : "unknown";
|
|
36
|
+
const message = code === "smoke_token_mismatch"
|
|
37
|
+
? "The browser response did not match the smoke-test token."
|
|
38
|
+
: "Consult blocked; detailed diagnostics are kept in the local receipt.";
|
|
35
39
|
const area = issueAreaLabel(code);
|
|
36
40
|
const body = [
|
|
37
41
|
"A consult was blocked. Filed from its receipt, so the prompt and the answer are not included.",
|
|
@@ -45,7 +49,8 @@ export function buildIssueReport(consult, environment) {
|
|
|
45
49
|
`| platform | ${environment.platform} |`,
|
|
46
50
|
`| node | ${environment.nodeVersion} |`,
|
|
47
51
|
"",
|
|
48
|
-
|
|
52
|
+
"Private error details and recovery instructions are omitted. Review the local receipt before sharing more context.",
|
|
53
|
+
"",
|
|
49
54
|
"Receipt (local, not attached): " + consult.task_id
|
|
50
55
|
].join("\n");
|
|
51
56
|
return {
|
package/dist/mcp-tools.js
CHANGED
|
@@ -4,6 +4,33 @@ import { applyRepoWriteDryRun, createRepoWriteDryRun, stageReviewedPaths } from
|
|
|
4
4
|
export const MAX_MCP_BRIDGE_TEXT_BYTES = MAX_FETCHABLE_RESULT_ARTIFACT_BYTES;
|
|
5
5
|
export const MAX_MCP_SHORT_TEXT_BYTES = 10_000;
|
|
6
6
|
const MAX_MCP_LIST_ITEMS = 100;
|
|
7
|
+
function redactContextText(text, privateValues = []) {
|
|
8
|
+
let redacted = text;
|
|
9
|
+
for (const value of privateValues) {
|
|
10
|
+
if (value)
|
|
11
|
+
redacted = redacted.replaceAll(value, "[redacted]");
|
|
12
|
+
}
|
|
13
|
+
return redacted.replace(/https?:\/\/(?:chatgpt\.com|chat\.openai\.com)(?![\w.-])(?:[/?#][^\s<>"'`)]*)?/gi, "[redacted ChatGPT URL]");
|
|
14
|
+
}
|
|
15
|
+
function redactBlockerForMcp(blocker, privateValues = []) {
|
|
16
|
+
if (!blocker)
|
|
17
|
+
return undefined;
|
|
18
|
+
const context = [blocker.thread, ...privateValues];
|
|
19
|
+
return {
|
|
20
|
+
code: blocker.code,
|
|
21
|
+
message: redactContextText(blocker.message, context),
|
|
22
|
+
retryable: blocker.retryable,
|
|
23
|
+
...(blocker.next_step !== undefined ? { next_step: redactContextText(blocker.next_step, context) } : {})
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function redactResultForMcp(result) {
|
|
27
|
+
return {
|
|
28
|
+
...result,
|
|
29
|
+
summary: result.status === "blocked" ? redactContextText(result.summary, [result.blocker?.thread]) : result.summary,
|
|
30
|
+
blocker: redactBlockerForMcp(result.blocker),
|
|
31
|
+
warnings: result.warnings.map((warning) => redactContextText(warning, [result.blocker?.thread]))
|
|
32
|
+
};
|
|
33
|
+
}
|
|
7
34
|
/**
|
|
8
35
|
* Drop the ChatGPT project name and thread URL before a session crosses the
|
|
9
36
|
* MCP boundary. Both are personal context (the same way receipts redact the
|
|
@@ -17,6 +44,8 @@ function redactSessionForMcp(session) {
|
|
|
17
44
|
redacted.project = undefined;
|
|
18
45
|
if (Object.hasOwn(redacted, "thread"))
|
|
19
46
|
redacted.thread = undefined;
|
|
47
|
+
redacted.blocker = redactBlockerForMcp(session.blocker, [session.thread, session.project]);
|
|
48
|
+
redacted.warnings = session.warnings.map((warning) => redactContextText(warning, [session.thread, session.project]));
|
|
20
49
|
return redacted;
|
|
21
50
|
}
|
|
22
51
|
/**
|
|
@@ -27,17 +56,20 @@ function redactSessionForMcp(session) {
|
|
|
27
56
|
* raw task file keeps them for local CLI inspection.
|
|
28
57
|
*/
|
|
29
58
|
function redactTaskForMcp(task) {
|
|
30
|
-
if (!task || typeof task !== "object" || !("provenance" in task) || !task.provenance)
|
|
31
|
-
return task;
|
|
32
59
|
const provenance = task.provenance;
|
|
33
|
-
if (!Object.hasOwn(provenance, "thread") && !Object.hasOwn(provenance, "project"))
|
|
34
|
-
return task;
|
|
35
60
|
const redactedProvenance = { ...provenance };
|
|
36
61
|
if (Object.hasOwn(redactedProvenance, "thread"))
|
|
37
62
|
redactedProvenance.thread = undefined;
|
|
38
63
|
if (Object.hasOwn(redactedProvenance, "project"))
|
|
39
64
|
redactedProvenance.project = undefined;
|
|
40
|
-
|
|
65
|
+
if (provenance?.warnings) {
|
|
66
|
+
redactedProvenance.warnings = provenance.warnings.map((warning) => redactContextText(warning, [provenance.thread, provenance.project]));
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...task,
|
|
70
|
+
...(provenance ? { provenance: redactedProvenance } : {}),
|
|
71
|
+
blocker: redactBlockerForMcp(task.blocker, [provenance?.thread, provenance?.project])
|
|
72
|
+
};
|
|
41
73
|
}
|
|
42
74
|
export function createMcpToolHandlers(context) {
|
|
43
75
|
const store = new BridgeStore(context.cwd, {
|
|
@@ -71,7 +103,7 @@ export function createMcpToolHandlers(context) {
|
|
|
71
103
|
async bridge_claim_task(input) {
|
|
72
104
|
assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
|
|
73
105
|
assertMcpTextField(input.claimed_by, "claimed_by", MAX_MCP_SHORT_TEXT_BYTES);
|
|
74
|
-
return { task: await store.claimTask(input.task_id, input.claimed_by ?? claimedBy) };
|
|
106
|
+
return { task: redactTaskForMcp(await store.claimTask(input.task_id, input.claimed_by ?? claimedBy)) };
|
|
75
107
|
},
|
|
76
108
|
async bridge_complete_task(input) {
|
|
77
109
|
assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
|
|
@@ -87,7 +119,7 @@ export function createMcpToolHandlers(context) {
|
|
|
87
119
|
warnings: input.warnings,
|
|
88
120
|
provenance: { adapter: "mcp" }
|
|
89
121
|
});
|
|
90
|
-
return { result };
|
|
122
|
+
return { result: redactResultForMcp(result) };
|
|
91
123
|
},
|
|
92
124
|
async bridge_block_task(input) {
|
|
93
125
|
assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
|
|
@@ -111,14 +143,14 @@ export function createMcpToolHandlers(context) {
|
|
|
111
143
|
},
|
|
112
144
|
provenance: { adapter: "mcp" }
|
|
113
145
|
});
|
|
114
|
-
return { result };
|
|
146
|
+
return { result: redactResultForMcp(result) };
|
|
115
147
|
},
|
|
116
148
|
async bridge_list_results() {
|
|
117
|
-
return { results: await store.listFinalizedResultsReadOnly() };
|
|
149
|
+
return { results: (await store.listFinalizedResultsReadOnly()).map(redactResultForMcp) };
|
|
118
150
|
},
|
|
119
151
|
async bridge_fetch_result(input) {
|
|
120
152
|
assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
|
|
121
|
-
return { result: await store.getFinalizedResultReadOnly(input.task_id) };
|
|
153
|
+
return { result: redactResultForMcp(await store.getFinalizedResultReadOnly(input.task_id)) };
|
|
122
154
|
},
|
|
123
155
|
async bridge_fetch_result_artifact(input) {
|
|
124
156
|
assertMcpTextField(input.task_id, "task_id", MAX_MCP_SHORT_TEXT_BYTES);
|
package/dist/mcp.js
CHANGED
|
@@ -183,7 +183,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
183
183
|
.array(McpShortTextSchema)
|
|
184
184
|
.max(4)
|
|
185
185
|
.optional()
|
|
186
|
-
.describe("ChatGPT composer tools
|
|
186
|
+
.describe("Rendered ChatGPT composer tools: \"web-search\" or \"create-image\". Only rendered answer content is returned. Automatic deep-research report retrieval is unsupported; requesting \"deep-research\" stops before sending. Use Deep research manually in the ChatGPT UI."),
|
|
187
187
|
attach: z
|
|
188
188
|
.array(McpShortTextSchema)
|
|
189
189
|
.max(10)
|
|
@@ -233,7 +233,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
233
233
|
const browserRecover = options.browserRecover;
|
|
234
234
|
if (browserRecover) {
|
|
235
235
|
server.registerTool("pro_recover", {
|
|
236
|
-
description: "
|
|
236
|
+
description: "Read a stable, finished assistant answer rendered in the requested ChatGPT thread after a consult stopped waiting, and record a receipt. It sends no prompt and acquires the shared send lock before navigating. Wrong-thread, generating, missing, or changing answers are refused. Hidden transcripts and deep-research report retrieval are unsupported; inspect those manually in ChatGPT.",
|
|
237
237
|
inputSchema: {
|
|
238
238
|
thread: McpShortTextSchema.min(1).describe("The ChatGPT conversation URL from the blocker (its `thread` field)."),
|
|
239
239
|
timeout_ms: z.number().int().positive().max(600_000).optional()
|
package/dist/registry.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
3
|
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
5
|
+
import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
|
|
4
6
|
const SCHEMA_VERSION = 1;
|
|
5
7
|
/**
|
|
6
8
|
* Central registry of every bridge root on this machine, so local indexers
|
|
@@ -86,6 +88,7 @@ async function registerBridgeRootInner(root) {
|
|
|
86
88
|
try {
|
|
87
89
|
const file = bridgesRegistryPath();
|
|
88
90
|
const abs = await canonicalize(root);
|
|
91
|
+
const parentRealPath = await preparePrivateRegistryParent(file);
|
|
89
92
|
// Read-modify-write with a bounded verify-retry: rename gives torn-write
|
|
90
93
|
// atomicity but not lost-update safety - two processes registering
|
|
91
94
|
// DIFFERENT roots at the same instant would each read the old list and
|
|
@@ -95,7 +98,7 @@ async function registerBridgeRootInner(root) {
|
|
|
95
98
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
96
99
|
let roots = [];
|
|
97
100
|
try {
|
|
98
|
-
const parsed = JSON.parse(await
|
|
101
|
+
const parsed = JSON.parse(await readRegistryFileForWrite(file, parentRealPath));
|
|
99
102
|
if (Array.isArray(parsed?.roots)) {
|
|
100
103
|
roots = parsed.roots.filter((r) => typeof r === "string");
|
|
101
104
|
}
|
|
@@ -115,13 +118,19 @@ async function registerBridgeRootInner(root) {
|
|
|
115
118
|
}
|
|
116
119
|
survivors.push(abs);
|
|
117
120
|
roots = survivors.length > MAX_REGISTRY_ROOTS ? survivors.slice(survivors.length - MAX_REGISTRY_ROOTS) : survivors;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
122
|
+
try {
|
|
123
|
+
await writeVerifiedUtf8File(tmp, `${JSON.stringify({ schema_version: SCHEMA_VERSION, roots }, null, 2)}\n`, () => assertRegistryWritePathsSafe(file, parentRealPath), { create: true, exclusive: true, mode: 0o600 });
|
|
124
|
+
await assertRegistryWritePathsSafe(file, parentRealPath);
|
|
125
|
+
await fs.rename(tmp, file);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
await cleanupRegistryTemp(tmp, parentRealPath);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
122
131
|
// Verify our root survived a concurrent writer's rename.
|
|
123
132
|
try {
|
|
124
|
-
const check = JSON.parse(await
|
|
133
|
+
const check = JSON.parse(await readRegistryFileForWrite(file, parentRealPath));
|
|
125
134
|
if (Array.isArray(check?.roots) && check.roots.includes(abs))
|
|
126
135
|
return;
|
|
127
136
|
}
|
|
@@ -134,3 +143,42 @@ async function registerBridgeRootInner(root) {
|
|
|
134
143
|
// Advisory registry - never let it fail a bridge operation.
|
|
135
144
|
}
|
|
136
145
|
}
|
|
146
|
+
async function preparePrivateRegistryParent(file) {
|
|
147
|
+
const parent = path.dirname(file);
|
|
148
|
+
await fs.mkdir(parent, { recursive: true, mode: 0o700 });
|
|
149
|
+
const parentStat = await fs.lstat(parent);
|
|
150
|
+
if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
|
|
151
|
+
throw new Error("Registry parent must be a real directory and must not be a symlink");
|
|
152
|
+
}
|
|
153
|
+
await fs.chmod(parent, 0o700);
|
|
154
|
+
return fs.realpath(parent);
|
|
155
|
+
}
|
|
156
|
+
async function assertRegistryWritePathsSafe(file, parentRealPath) {
|
|
157
|
+
const parent = path.dirname(file);
|
|
158
|
+
const parentStat = await fs.lstat(parent);
|
|
159
|
+
if (parentStat.isSymbolicLink() || !parentStat.isDirectory() || (await fs.realpath(parent)) !== parentRealPath) {
|
|
160
|
+
throw new Error("Registry parent changed during write");
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const targetStat = await fs.lstat(file);
|
|
164
|
+
if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
|
|
165
|
+
throw new Error("Registry target must be a regular file and must not be a symlink");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error.code !== "ENOENT")
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function readRegistryFileForWrite(file, parentRealPath) {
|
|
174
|
+
return readVerifiedUtf8File(file, () => assertRegistryWritePathsSafe(file, parentRealPath), { maxBytes: 1_000_000 });
|
|
175
|
+
}
|
|
176
|
+
async function cleanupRegistryTemp(tmp, parentRealPath) {
|
|
177
|
+
try {
|
|
178
|
+
if ((await fs.realpath(path.dirname(tmp))) === parentRealPath)
|
|
179
|
+
await fs.rm(tmp, { force: true });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Do not follow a parent that changed while the advisory write was in flight.
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/repo-write.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { assertResolvedRepoPathAllowed, resolveRepoPath } from "./repo.js";
|
|
6
|
-
import { readVerifiedUtf8File, replaceVerifiedUtf8File } from "./safe-file.js";
|
|
7
|
+
import { readVerifiedUtf8File, replaceVerifiedUtf8File, withCrossProcessFileLock } from "./safe-file.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
const MAX_WRITE_BYTES = 1_000_000;
|
|
9
10
|
let testHooks = {};
|
|
@@ -73,8 +74,28 @@ export async function applyRepoWriteDryRun(root, store, input) {
|
|
|
73
74
|
if (metadata.preimage_sha256 !== input.preimage_sha256) {
|
|
74
75
|
throw new Error(`Expected preimage does not match dry-run receipt`);
|
|
75
76
|
}
|
|
77
|
+
const newContent = await readDryRunReplacementContent(store, metadata);
|
|
78
|
+
const resolvedPath = resolveRepoPath(root, metadata.path);
|
|
79
|
+
await assertResolvedRepoPathAllowed(root, resolvedPath, metadata.path);
|
|
80
|
+
const canonicalTarget = await realpath(resolvedPath);
|
|
81
|
+
const canonicalRoot = await realpath(root);
|
|
82
|
+
const lockFile = path.join(canonicalRoot, ".bridge", `.repo-write-${sha256(canonicalTarget)}.lock`);
|
|
83
|
+
// This ownership interval closes races among prodex applies. An editor that
|
|
84
|
+
// does not participate in the lock protocol can still replace the path.
|
|
85
|
+
return withCrossProcessFileLock(lockFile, {
|
|
86
|
+
waitMs: 30_000,
|
|
87
|
+
retryMs: 25,
|
|
88
|
+
privateParent: true,
|
|
89
|
+
busyError: (holder) => new Error(`Another prodex repo write for ${metadata.path} is in progress (pid ${holder.pid ?? "unknown"})`),
|
|
90
|
+
unavailableError: () => new Error(`The prodex repo write lock at ${lockFile} could not be recovered. Stop all writers before removing that lock and its matching .reap claim, then retry.`)
|
|
91
|
+
}, () => applyRepoWriteUnderLock(root, store, input, metadata, newContent, canonicalTarget));
|
|
92
|
+
}
|
|
93
|
+
async function applyRepoWriteUnderLock(root, store, input, metadata, newContent, canonicalTarget) {
|
|
76
94
|
await assertGitHead(root, input.expected_head);
|
|
77
95
|
const current = await readWritableExistingFile(root, metadata.path);
|
|
96
|
+
if ((await realpath(current.resolved)) !== canonicalTarget) {
|
|
97
|
+
throw new Error(`File target changed for ${metadata.path}`);
|
|
98
|
+
}
|
|
78
99
|
const currentPreimage = sha256(current.content);
|
|
79
100
|
if (currentPreimage !== input.preimage_sha256) {
|
|
80
101
|
// A retry after a successful apply sees the file already holding the new
|
|
@@ -85,7 +106,6 @@ export async function applyRepoWriteDryRun(root, store, input) {
|
|
|
85
106
|
}
|
|
86
107
|
throw new Error(`File preimage changed for ${metadata.path}`);
|
|
87
108
|
}
|
|
88
|
-
const newContent = await readDryRunReplacementContent(store, metadata);
|
|
89
109
|
let receipt;
|
|
90
110
|
try {
|
|
91
111
|
await replaceVerifiedUtf8File(current.resolved, newContent, async () => {
|
package/dist/safe-file.js
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { lstat, open, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
let testHooks = {};
|
|
6
6
|
export function setSafeFileTestHooks(hooks) {
|
|
7
7
|
testHooks = hooks;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Serialize cooperating processes through a complete, atomically published
|
|
11
|
+
* lock record. A live process is never evicted based on elapsed time: callers
|
|
12
|
+
* must cancel their own work before releasing the lease.
|
|
13
|
+
*/
|
|
14
|
+
export async function withCrossProcessFileLock(filePath, options, fn) {
|
|
15
|
+
const parentSnapshot = await prepareFileLockParent(filePath, options.privateParent === true);
|
|
16
|
+
const deadline = Date.now() + Math.max(0, options.waitMs);
|
|
17
|
+
const retryMs = Math.max(1, options.retryMs ?? 50);
|
|
18
|
+
let waited = false;
|
|
19
|
+
let retriedMissingAtDeadline = false;
|
|
20
|
+
let lease;
|
|
21
|
+
for (;;) {
|
|
22
|
+
lease = await tryAcquireFileLock(filePath, parentSnapshot);
|
|
23
|
+
if (lease)
|
|
24
|
+
break;
|
|
25
|
+
const snapshot = await readFileLockSnapshot(filePath, parentSnapshot);
|
|
26
|
+
if (!snapshot) {
|
|
27
|
+
// The holder commonly disappears between our failed link and inspection.
|
|
28
|
+
// Retry that acquisition once even at the deadline, but do not let a
|
|
29
|
+
// rapidly flapping path bypass a bounded caller's wait budget forever.
|
|
30
|
+
if (Date.now() >= deadline) {
|
|
31
|
+
if (retriedMissingAtDeadline)
|
|
32
|
+
throw options.unavailableError();
|
|
33
|
+
retriedMissingAtDeadline = true;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
retriedMissingAtDeadline = false;
|
|
41
|
+
const holderAlive = snapshot.holder.pid !== undefined && processIsAlive(snapshot.holder.pid);
|
|
42
|
+
if (!holderAlive && (await reapFileLockSnapshot(filePath, snapshot, parentSnapshot)))
|
|
43
|
+
continue;
|
|
44
|
+
if (Date.now() >= deadline) {
|
|
45
|
+
throw holderAlive ? options.busyError(snapshot.holder) : options.unavailableError();
|
|
46
|
+
}
|
|
47
|
+
if (!waited) {
|
|
48
|
+
waited = true;
|
|
49
|
+
options.onWait?.(snapshot.holder);
|
|
50
|
+
}
|
|
51
|
+
await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return await fn();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await releaseFileLockLease(filePath, lease, parentSnapshot);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
9
60
|
export async function readVerifiedUtf8File(filePath, validate, options = {}) {
|
|
10
61
|
await validate();
|
|
11
62
|
const parentSnapshot = await captureParentSnapshot(filePath);
|
|
@@ -286,9 +337,206 @@ async function openNoFollow(filePath, flags, operation, mode) {
|
|
|
286
337
|
throw error;
|
|
287
338
|
}
|
|
288
339
|
}
|
|
340
|
+
async function prepareFileLockParent(filePath, makePrivate) {
|
|
341
|
+
const parentPath = path.dirname(filePath);
|
|
342
|
+
await mkdir(parentPath, { recursive: true, mode: 0o700 });
|
|
343
|
+
const parentStat = await lstat(parentPath);
|
|
344
|
+
if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
|
|
345
|
+
throw new Error("Lock parent must be a real directory and must not be a symlink");
|
|
346
|
+
}
|
|
347
|
+
if (makePrivate)
|
|
348
|
+
await chmod(parentPath, 0o700);
|
|
349
|
+
const snapshot = await captureParentSnapshot(filePath);
|
|
350
|
+
if (!snapshot)
|
|
351
|
+
throw new Error("Lock files cannot use a descriptor-backed parent path");
|
|
352
|
+
await assertParentSnapshotStable(snapshot);
|
|
353
|
+
return snapshot;
|
|
354
|
+
}
|
|
355
|
+
async function tryAcquireFileLock(filePath, parentSnapshot) {
|
|
356
|
+
const owner = {
|
|
357
|
+
pid: process.pid,
|
|
358
|
+
nonce: randomUUID(),
|
|
359
|
+
started_at: new Date().toISOString()
|
|
360
|
+
};
|
|
361
|
+
const tokenPath = fileLockTokenPath(filePath, owner);
|
|
362
|
+
const handle = await openStableNoFollow(tokenPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, "write", parentSnapshot, 0o600);
|
|
363
|
+
try {
|
|
364
|
+
await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
|
|
365
|
+
await handle.chmod(0o600);
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
await handle.close();
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
await assertParentSnapshotStable(parentSnapshot);
|
|
372
|
+
await link(tokenPath, filePath);
|
|
373
|
+
return { owner, tokenPath };
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
await rm(tokenPath, { force: true }).catch(() => undefined);
|
|
377
|
+
if (isErrorCode(error, "EEXIST"))
|
|
378
|
+
return undefined;
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function readFileLockSnapshot(filePath, parentSnapshot) {
|
|
383
|
+
let pathIdentity;
|
|
384
|
+
try {
|
|
385
|
+
const pathStat = await lstat(filePath, { bigint: true });
|
|
386
|
+
pathIdentity = { dev: pathStat.dev, ino: pathStat.ino };
|
|
387
|
+
if (pathStat.isSymbolicLink() || !pathStat.isFile()) {
|
|
388
|
+
return { identity: pathIdentity, holder: {}, unsafe: true };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (isMissingFileError(error))
|
|
393
|
+
return undefined;
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
let handle;
|
|
397
|
+
try {
|
|
398
|
+
handle = await openStableNoFollow(filePath, constants.O_RDONLY, "read", parentSnapshot);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (isMissingFileError(error))
|
|
402
|
+
return undefined;
|
|
403
|
+
return { identity: pathIdentity, holder: {}, unsafe: true };
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const stat = await handle.stat({ bigint: true });
|
|
407
|
+
if (!stat.isFile())
|
|
408
|
+
return { identity: pathIdentity, holder: {}, unsafe: true };
|
|
409
|
+
const identity = { dev: stat.dev, ino: stat.ino };
|
|
410
|
+
if (stat.size > 16384n)
|
|
411
|
+
return { identity, holder: {} };
|
|
412
|
+
const content = await handle.readFile({ encoding: "utf8" });
|
|
413
|
+
const holder = parseFileLockHolder(content);
|
|
414
|
+
return { identity, holder, owner: parseFileLockOwner(holder) };
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return { identity: pathIdentity, holder: {} };
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
await handle.close();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function parseFileLockHolder(content) {
|
|
424
|
+
try {
|
|
425
|
+
const parsed = JSON.parse(content);
|
|
426
|
+
return {
|
|
427
|
+
pid: Number.isSafeInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : undefined,
|
|
428
|
+
nonce: typeof parsed.nonce === "string" && /^[A-Za-z0-9-]{1,128}$/.test(parsed.nonce) ? parsed.nonce : undefined,
|
|
429
|
+
started_at: typeof parsed.started_at === "string" ? parsed.started_at : undefined
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return {};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function parseFileLockOwner(holder) {
|
|
437
|
+
if (holder.pid === undefined || holder.nonce === undefined)
|
|
438
|
+
return undefined;
|
|
439
|
+
return { pid: holder.pid, nonce: holder.nonce, started_at: holder.started_at };
|
|
440
|
+
}
|
|
441
|
+
async function releaseFileLockLease(filePath, lease, parentSnapshot) {
|
|
442
|
+
try {
|
|
443
|
+
const [current, tokenIdentity] = await Promise.all([
|
|
444
|
+
readFileLockSnapshot(filePath, parentSnapshot),
|
|
445
|
+
readRegularFileIdentity(lease.tokenPath)
|
|
446
|
+
]);
|
|
447
|
+
if (current?.owner?.pid === lease.owner.pid &&
|
|
448
|
+
current.owner.nonce === lease.owner.nonce &&
|
|
449
|
+
tokenIdentity &&
|
|
450
|
+
sameFileIdentity(current.identity, tokenIdentity)) {
|
|
451
|
+
await rm(filePath, { force: true });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// A release is best-effort; the unique token cleanup below cannot affect a successor.
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
await rm(lease.tokenPath, { force: true }).catch(() => undefined);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async function reapFileLockSnapshot(filePath, snapshot, parentSnapshot) {
|
|
462
|
+
if (snapshot.unsafe)
|
|
463
|
+
return false;
|
|
464
|
+
return reapClaimedFileLock(filePath, snapshot, parentSnapshot);
|
|
465
|
+
}
|
|
466
|
+
async function reapClaimedFileLock(filePath, snapshot, parentSnapshot) {
|
|
467
|
+
const claimPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.reap`);
|
|
468
|
+
try {
|
|
469
|
+
await link(filePath, claimPath);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (isMissingFileError(error))
|
|
473
|
+
return true;
|
|
474
|
+
// An interrupted reaper requires manual cleanup after all users stop.
|
|
475
|
+
// Reaping this claim recursively would reintroduce successor-unlink races.
|
|
476
|
+
if (isErrorCode(error, "EEXIST"))
|
|
477
|
+
return false;
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
await testHooks.afterLockReapClaim?.(filePath);
|
|
482
|
+
const [claimIdentity, current, ownerTokenIdentity] = await Promise.all([
|
|
483
|
+
readRegularFileIdentity(claimPath),
|
|
484
|
+
readFileLockSnapshot(filePath, parentSnapshot),
|
|
485
|
+
snapshot.owner ? readRegularFileIdentity(fileLockTokenPath(filePath, snapshot.owner)) : undefined
|
|
486
|
+
]);
|
|
487
|
+
if (!claimIdentity ||
|
|
488
|
+
!current ||
|
|
489
|
+
!sameFileIdentity(claimIdentity, snapshot.identity) ||
|
|
490
|
+
!sameFileIdentity(current.identity, snapshot.identity)) {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
await rm(filePath, { force: true });
|
|
494
|
+
if (snapshot.owner && ownerTokenIdentity && sameFileIdentity(ownerTokenIdentity, snapshot.identity)) {
|
|
495
|
+
await rm(fileLockTokenPath(filePath, snapshot.owner), { force: true }).catch(() => undefined);
|
|
496
|
+
}
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
await rm(claimPath, { force: true }).catch(() => undefined);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function readRegularFileIdentity(filePath) {
|
|
504
|
+
try {
|
|
505
|
+
const stat = await lstat(filePath, { bigint: true });
|
|
506
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
507
|
+
return undefined;
|
|
508
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
if (isMissingFileError(error))
|
|
512
|
+
return undefined;
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function fileLockTokenPath(filePath, owner) {
|
|
517
|
+
return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${owner.pid}.${owner.nonce}.owner`);
|
|
518
|
+
}
|
|
519
|
+
function sameFileIdentity(left, right) {
|
|
520
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
521
|
+
}
|
|
522
|
+
function processIsAlive(pid) {
|
|
523
|
+
try {
|
|
524
|
+
process.kill(pid, 0);
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
return error.code === "EPERM";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function delay(ms) {
|
|
532
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
533
|
+
}
|
|
289
534
|
function procFdPath(fd) {
|
|
290
535
|
return `/proc/self/fd/${fd}`;
|
|
291
536
|
}
|
|
292
537
|
function isMissingFileError(error) {
|
|
293
538
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
294
539
|
}
|
|
540
|
+
function isErrorCode(error, code) {
|
|
541
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
542
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -389,7 +389,13 @@ export class BridgeStore {
|
|
|
389
389
|
if (artifact.sha256 && sha256(content) !== artifact.sha256) {
|
|
390
390
|
throw new Error(`Result artifact changed after finalization for ${taskId}: ${artifact.path} sha256 mismatch`);
|
|
391
391
|
}
|
|
392
|
-
return {
|
|
392
|
+
return {
|
|
393
|
+
artifact,
|
|
394
|
+
content,
|
|
395
|
+
...(!artifact.sha256 ? {
|
|
396
|
+
warnings: ["legacy_artifact_unverified: this artifact has no recorded sha256; its current contents cannot be verified against finalization."]
|
|
397
|
+
} : {})
|
|
398
|
+
};
|
|
393
399
|
}
|
|
394
400
|
async withResultArtifactHashes(artifacts) {
|
|
395
401
|
const withHashes = [];
|
package/dist/tui-flow.js
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export const SEND_KINDS = [
|
|
12
12
|
{ id: "chat", label: "Normal chat", hint: "ordinary Pro answer", tools: [] },
|
|
13
|
-
{ id: "deep-research", label: "Deep research", hint: "browsed report, runs about 10 minutes", tools: ["deep-research"] },
|
|
14
13
|
{ id: "web-search", label: "Web search", hint: "current facts, with sources", tools: ["web-search"] },
|
|
15
14
|
{ id: "create-image", label: "Create image", tools: ["create-image"] }
|
|
16
15
|
];
|