@youdie006/prodex 0.40.6 → 0.40.9
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 +31 -13
- package/dist/browser-send-lock.js +10 -131
- package/dist/chatgpt-browser.js +428 -746
- package/dist/cli-args.js +2 -0
- package/dist/cli-help.js +30 -24
- package/dist/cli-ledger.js +2 -0
- package/dist/cli-pro.js +328 -121
- package/dist/cli-server.js +2 -2
- package/dist/cli.js +1 -4
- package/dist/config.js +3 -3
- package/dist/continue-thread.js +9 -2
- 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 +11 -7
- package/dist/registry.js +54 -6
- package/dist/repo-write.js +22 -2
- package/dist/safe-file.js +249 -1
- package/dist/schema.js +8 -0
- package/dist/store.js +10 -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 +6 -2
- package/docs/cli-reference.md +18 -7
- package/docs/clients.md +18 -6
- package/docs/http-mcp.md +5 -1
- package/docs/releasing.md +3 -1
- package/package.json +1 -1
package/dist/cli-server.js
CHANGED
|
@@ -40,8 +40,8 @@ export async function runSetupCommand(rest, io) {
|
|
|
40
40
|
? await runBrowserDefaultsWizard(resolvePromptUser(io), io.stdout)
|
|
41
41
|
: parseBrowserDefaultFlags(rest);
|
|
42
42
|
const config = await writeLocalConfig(targetCwd, {
|
|
43
|
-
host: readFlag(rest, "--host")
|
|
44
|
-
port: readPortFlag(rest, "--port")
|
|
43
|
+
host: readFlag(rest, "--host"),
|
|
44
|
+
port: readPortFlag(rest, "--port"),
|
|
45
45
|
token: readFlag(rest, "--token"),
|
|
46
46
|
tokenTtlHours: readPositiveNumberFlag(rest, "--token-ttl-hours"),
|
|
47
47
|
browserDefaults
|
package/dist/cli.js
CHANGED
|
@@ -82,10 +82,6 @@ async function runInteractiveUi(io) {
|
|
|
82
82
|
const { listRecentChatGptConversations } = await import("./chatgpt-browser.js");
|
|
83
83
|
return listRecentChatGptConversations({});
|
|
84
84
|
},
|
|
85
|
-
openThread: async (url) => {
|
|
86
|
-
const { navigateChatGptTabTo } = await import("./chatgpt-browser.js");
|
|
87
|
-
return navigateChatGptTabTo(url, {});
|
|
88
|
-
},
|
|
89
85
|
listProjectsWithIds: async () => {
|
|
90
86
|
const { listChatGptProjectsWithIds } = await import("./chatgpt-browser.js");
|
|
91
87
|
return listChatGptProjectsWithIds({});
|
|
@@ -97,6 +93,7 @@ async function runInteractiveUi(io) {
|
|
|
97
93
|
},
|
|
98
94
|
runConsult: (args, onProgress) => runCli(args, {
|
|
99
95
|
...io,
|
|
96
|
+
navigateInteractiveTarget: true,
|
|
100
97
|
stderr: (line) => {
|
|
101
98
|
if (line.startsWith("progress:"))
|
|
102
99
|
onProgress(line);
|
package/dist/config.js
CHANGED
|
@@ -102,9 +102,9 @@ export async function writeLocalConfig(cwd, input = {}) {
|
|
|
102
102
|
await ensureBridgeLocalFiles(cwd);
|
|
103
103
|
await assertLocalConfigTargetSafe(cwd);
|
|
104
104
|
const now = new Date().toISOString();
|
|
105
|
-
const host = normalizeLoopbackHttpHost(input.host ?? "127.0.0.1");
|
|
106
|
-
const port = input.port ?? 8787;
|
|
107
105
|
const existing = await readExistingConfig(cwd);
|
|
106
|
+
const host = normalizeLoopbackHttpHost(input.host ?? existing?.host ?? "127.0.0.1");
|
|
107
|
+
const port = input.port ?? existing?.port ?? 8787;
|
|
108
108
|
// A setup re-run that only adjusts defaults/host/port must NOT rotate the
|
|
109
109
|
// token - that would silently 401 every client holding the old MCP URL.
|
|
110
110
|
// Rotation happens only when the caller explicitly asks for a token
|
|
@@ -275,7 +275,7 @@ export function getTokenExpiryStatus(config, now = new Date()) {
|
|
|
275
275
|
? {
|
|
276
276
|
status: "expired",
|
|
277
277
|
token_expires_at: config.token_expires_at,
|
|
278
|
-
warning: `Token expired at ${config.token_expires_at}. Run \`prodex setup
|
|
278
|
+
warning: `Token expired at ${config.token_expires_at}. Run \`prodex setup --token-ttl-hours <hours>\` to create a new URL, then restart \`prodex start\`.`
|
|
279
279
|
}
|
|
280
280
|
: { status: "valid", token_expires_at: config.token_expires_at };
|
|
281
281
|
}
|
package/dist/continue-thread.js
CHANGED
|
@@ -125,8 +125,8 @@ export function projectIdFromSidebar(projects, name) {
|
|
|
125
125
|
*
|
|
126
126
|
* Naming a task wins over the search, because the caller who names one knows
|
|
127
127
|
* which conversation they mean. Otherwise it is the most recent consult that
|
|
128
|
-
* finished, in this project - fail-closed when there is none,
|
|
129
|
-
* the conversation is the failure this exists to prevent.
|
|
128
|
+
* finished for this caller, in this project - fail-closed when there is none,
|
|
129
|
+
* since guessing the conversation is the failure this exists to prevent.
|
|
130
130
|
*/
|
|
131
131
|
export function resolveContinuationThread(input) {
|
|
132
132
|
const withThread = input.consults.filter((consult) => consult.thread && isChatGptConversationUrl(consult.thread));
|
|
@@ -147,10 +147,17 @@ export function resolveContinuationThread(input) {
|
|
|
147
147
|
}
|
|
148
148
|
return { target: { taskId: named.taskId, thread: named.thread } };
|
|
149
149
|
}
|
|
150
|
+
if (!input.sessionKey) {
|
|
151
|
+
return {
|
|
152
|
+
error: "--continue needs a caller session key so it cannot select another client's conversation. " +
|
|
153
|
+
"Pass --session-key <id> (or PRODEX_SESSION_KEY/CODEX_THREAD_ID), or name the intended consult with --continue-task <task_id>."
|
|
154
|
+
};
|
|
155
|
+
}
|
|
150
156
|
const knownProjectIds = new Set(input.project ? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project)) ?? [] : []);
|
|
151
157
|
if (input.projectId)
|
|
152
158
|
knownProjectIds.add(input.projectId.toLowerCase());
|
|
153
159
|
const candidates = withThread
|
|
160
|
+
.filter((consult) => consult.sessionKey === input.sessionKey)
|
|
154
161
|
.filter((consult) => consult.status === "done")
|
|
155
162
|
.filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
|
|
156
163
|
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
package/dist/http-mcp.js
CHANGED
|
@@ -27,7 +27,7 @@ export async function startHttpMcpServer(options) {
|
|
|
27
27
|
// HOW to authorize without leaking anything token-specific.
|
|
28
28
|
writeJson(res, 401, {
|
|
29
29
|
error: "unauthorized",
|
|
30
|
-
hint: "Provide a valid token via `?prodex_token=<token>` or `Authorization: Bearer <token>`. If your token expired,
|
|
30
|
+
hint: "Provide a valid token via `?prodex_token=<token>` or `Authorization: Bearer <token>`. If your token expired, run `prodex setup --token-ttl-hours <hours>`, restart `prodex start`, and read the new URL with `prodex status --show-token --url-only`."
|
|
31
31
|
});
|
|
32
32
|
return;
|
|
33
33
|
}
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
5
|
import process from "node:process";
|
|
@@ -6,7 +7,7 @@ import { serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
|
|
|
6
7
|
import { JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { createMcpToolHandlers, MAX_MCP_BRIDGE_TEXT_BYTES, MAX_MCP_SHORT_TEXT_BYTES, staleServerWarning, withServerVersionNotice } from "./mcp-tools.js";
|
|
9
|
-
import { ReceiptKindSchema } from "./schema.js";
|
|
10
|
+
import { ProdexRequestIdSchema, ReceiptKindSchema, SessionKeySchema } from "./schema.js";
|
|
10
11
|
const McpBridgeTextSchema = z.string().max(MAX_MCP_BRIDGE_TEXT_BYTES);
|
|
11
12
|
const McpShortTextSchema = z.string().max(MAX_MCP_SHORT_TEXT_BYTES);
|
|
12
13
|
const BridgeFileInputSchema = z.object({
|
|
@@ -51,6 +52,7 @@ function serverVersionNotice() {
|
|
|
51
52
|
}
|
|
52
53
|
export function createServer(cwd = process.cwd(), options = {}) {
|
|
53
54
|
const server = new McpServer({ name: "prodex", version: mcpPackageJson.version ?? "0.0.0" });
|
|
55
|
+
const mcpSessionKey = `mcp-${randomUUID()}`;
|
|
54
56
|
const handlers = createMcpToolHandlers({
|
|
55
57
|
cwd,
|
|
56
58
|
source: options.source,
|
|
@@ -167,9 +169,10 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
167
169
|
const browserConsult = options.browserConsult;
|
|
168
170
|
if (browserConsult) {
|
|
169
171
|
server.registerTool("pro_consult", {
|
|
170
|
-
description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session.
|
|
172
|
+
description: "Ask the user's logged-in ChatGPT (Pro) in the visible browser and wait for the full answer. This drives a real browser send: it can take minutes (Pro extended reasoning), is human-paced, and records a durable receipt under .bridge/. Requires a running `prodex pro browser login` session. Every ordinary consult starts a fresh chat, including inside a passed or saved default project; new_chat:false never opts into the shared current tab. To follow up, pass continue_thread:true: it resolves only the newest finished consult with this caller's session_key and project. Each MCP connection gets one default session_key. Logical agents sharing one connection must pass distinct explicit keys and preserve them for follow-ups; an explicit key also preserves identity across MCP restarts. continue_task deliberately names one task across session boundaries. If the thread is still generating a previous answer, the send queues behind it up to the timeout budget. `project` and `model` come from saved defaults when omitted. Returns task_id, thread URL, session_key, request correlation evidence, and the answer text.",
|
|
171
173
|
inputSchema: {
|
|
172
174
|
prompt: McpBridgeTextSchema.min(1),
|
|
175
|
+
session_key: SessionKeySchema.optional().describe("Stable logical-caller identifier for scoped continue_thread lookup. Omit to share this MCP connection's default key; logical agents sharing one connection should pass distinct keys and preserve them for follow-ups."),
|
|
173
176
|
model: McpShortTextSchema.optional(),
|
|
174
177
|
// pro_mode is deliberately NOT advertised: ChatGPT's 2026-07 update
|
|
175
178
|
// removed Pro sub-modes, and agents that saw the field passed
|
|
@@ -183,7 +186,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
183
186
|
.array(McpShortTextSchema)
|
|
184
187
|
.max(4)
|
|
185
188
|
.optional()
|
|
186
|
-
.describe("ChatGPT composer tools
|
|
189
|
+
.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
190
|
attach: z
|
|
188
191
|
.array(McpShortTextSchema)
|
|
189
192
|
.max(10)
|
|
@@ -192,11 +195,11 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
192
195
|
new_chat: z
|
|
193
196
|
.boolean()
|
|
194
197
|
.optional()
|
|
195
|
-
.describe("Start a fresh thread
|
|
198
|
+
.describe("Start a fresh thread. Ordinary consults already do this by default; false does not reuse the shared tab."),
|
|
196
199
|
continue_thread: z
|
|
197
200
|
.boolean()
|
|
198
201
|
.optional()
|
|
199
|
-
.describe("Follow up inside
|
|
202
|
+
.describe("Follow up inside this caller session_key's newest finished consult of the same project. Fails rather than using another MCP/Codex session or the shared browser tab."),
|
|
200
203
|
continue_task: z
|
|
201
204
|
.string()
|
|
202
205
|
.min(1)
|
|
@@ -227,15 +230,16 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
227
230
|
// Progress delivery must never break the consult.
|
|
228
231
|
});
|
|
229
232
|
};
|
|
230
|
-
return asText(withServerVersionNotice(await browserConsult(input, onProgress), serverVersionNotice()));
|
|
233
|
+
return asText(withServerVersionNotice(await browserConsult({ ...input, session_key: input.session_key ?? mcpSessionKey }, onProgress), serverVersionNotice()));
|
|
231
234
|
});
|
|
232
235
|
}
|
|
233
236
|
const browserRecover = options.browserRecover;
|
|
234
237
|
if (browserRecover) {
|
|
235
238
|
server.registerTool("pro_recover", {
|
|
236
|
-
description: "
|
|
239
|
+
description: "Read a stable, finished assistant answer rendered in the requested ChatGPT thread after a consult stopped waiting, and record a receipt. Pass the request_id returned by pro_consult to verify the answer follows that exact marked user turn. Without it, legacy recovery returns request_verified:false and an explicit warning. It sends no prompt and acquires the shared send lock before navigating. Wrong-thread, wrong-request, generating, missing, or changing answers are refused.",
|
|
237
240
|
inputSchema: {
|
|
238
241
|
thread: McpShortTextSchema.min(1).describe("The ChatGPT conversation URL from the blocker (its `thread` field)."),
|
|
242
|
+
request_id: ProdexRequestIdSchema.optional().describe("The 32-character request_id returned by the original pro_consult."),
|
|
239
243
|
timeout_ms: z.number().int().positive().max(600_000).optional()
|
|
240
244
|
}
|
|
241
245
|
}, async (input) => asText(withServerVersionNotice(await browserRecover(input), serverVersionNotice())));
|
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 () => {
|