@minhspark/codex-mcp-bridge 1.20.0 → 1.21.0
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/.codex-plugin/plugin.json +7 -2
- package/CHANGELOG.md +32 -0
- package/README.md +57 -15
- package/assets/README.md +30 -0
- package/assets/agent-pets.svg +1 -0
- package/docs/daily-handoffs.md +106 -0
- package/docs/desktop-handoff-design.md +180 -0
- package/docs/new-machine-setup.md +112 -0
- package/docs/project-onboarding.md +122 -0
- package/package.json +8 -2
- package/plugin.json +10 -3
- package/scripts/bridge-projects.mjs +44 -0
- package/scripts/check.mjs +2 -1
- package/scripts/collect-repo-analytics.mjs +5 -2
- package/scripts/configure-message-automation.mjs +18 -0
- package/scripts/install-claude-code.mjs +64 -0
- package/scripts/install-claude-desktop.mjs +48 -5
- package/scripts/install-native-relay.mjs +6 -1
- package/scripts/install-npm-footer.mjs +13 -1
- package/scripts/publish-repo-analytics.mjs +24 -1
- package/src/app-server-client.mjs +5 -2
- package/src/bridge-readiness.mjs +23 -0
- package/src/card-settings-sync.mjs +125 -0
- package/src/claude-bridge.mjs +54 -20
- package/src/claude-code-registration.mjs +54 -0
- package/src/claude-sender-context.mjs +1 -1
- package/src/claude-session-creation.mjs +10 -2
- package/src/codex-mcp-registration.mjs +1 -1
- package/src/codex-native-response.mjs +139 -107
- package/src/codex-sender-context.mjs +62 -61
- package/src/desktop-reply-receipts.mjs +87 -0
- package/src/desktop-task-receipts.mjs +30 -7
- package/src/hardened-root-policy.mjs +5 -0
- package/src/index.mjs +97 -19
- package/src/mcp-supervisor.mjs +6 -2
- package/src/message-automation.mjs +89 -0
- package/src/native-creation-receipts.mjs +155 -0
- package/src/native-relay-companion.mjs +37 -6
- package/src/native-relay.mjs +12 -3
- package/src/peer-protocol.mjs +192 -18
- package/src/project-manager-registration.mjs +34 -0
- package/src/project-onboarding-tools.mjs +30 -0
- package/src/project-onboarding.mjs +178 -0
- package/src/project-policy.mjs +221 -0
- package/src/project-scope.mjs +33 -5
- package/src/prompt-guidance.mjs +2 -0
- package/src/release-snapshot.mjs +30 -5
- package/src/rollout-reader.mjs +58 -0
- package/src/rollout-segments.mjs +65 -0
- package/src/security-policy.mjs +4 -2
- package/src/sites-connector.mjs +18 -3
- package/src/telemetry.mjs +10 -8
- package/src/thread-delivery.mjs +134 -35
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
5
|
+
|
|
6
|
+
// Continuations retain session_meta.id but append a second UUID to the filename.
|
|
7
|
+
// Filename/mtime ordering is not evidence of which turn is currently active.
|
|
8
|
+
export function findRolloutSegments(sessions, threadId) {
|
|
9
|
+
if (!new RegExp(`^${UUID}$`).test(threadId)) throw new Error("The Codex task identity is invalid");
|
|
10
|
+
const root = fs.lstatSync(sessions);
|
|
11
|
+
if (!root.isDirectory() || root.isSymbolicLink()) throw new Error("The Codex sessions path is not a regular directory");
|
|
12
|
+
const canonical = fs.realpathSync.native(sessions);
|
|
13
|
+
const pattern = new RegExp(`^rollout-.+-${threadId}(?:_${UUID})?\\.jsonl$`);
|
|
14
|
+
const queue = [{ directory: canonical, depth: 0 }], matches = [];
|
|
15
|
+
let entries = 0, directories = 0;
|
|
16
|
+
while (queue.length) {
|
|
17
|
+
const { directory, depth } = queue.pop();
|
|
18
|
+
if (++directories > 4096) throw new Error("The bounded Codex sessions scan exceeded its directory limit");
|
|
19
|
+
const children = fs.readdirSync(directory, { withFileTypes: true });
|
|
20
|
+
entries += children.length;
|
|
21
|
+
if (entries > 100000) throw new Error("The bounded Codex sessions scan exceeded its entry limit");
|
|
22
|
+
for (const child of children) {
|
|
23
|
+
const candidate = path.join(directory, child.name);
|
|
24
|
+
if (depth < 3 && (depth === 0 ? /^\d{4}$/ : /^\d{2}$/).test(child.name)) {
|
|
25
|
+
if (child.isSymbolicLink()) throw new Error("The Codex sessions scan encountered a linked date directory");
|
|
26
|
+
if (child.isDirectory()) {
|
|
27
|
+
const resolved = fs.realpathSync.native(candidate);
|
|
28
|
+
const relative = path.relative(canonical, resolved);
|
|
29
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("A Codex date directory escapes the sessions path");
|
|
30
|
+
queue.push({ directory: resolved, depth: depth + 1 });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (depth === 3 && pattern.test(child.name)) {
|
|
34
|
+
if (!child.isFile() || child.isSymbolicLink()) throw new Error("The Codex rollout is not a regular file");
|
|
35
|
+
matches.push(candidate);
|
|
36
|
+
if (matches.length > 64) throw new Error("The Codex task exceeds the 64-segment discovery limit");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!matches.length) throw new Error("No rollout matches the calling Codex task");
|
|
41
|
+
return matches.sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Only independently retrieved native metadata may be supplied here. Tool
|
|
45
|
+
// arguments, process globals, and manual relay bindings are not ownership proof.
|
|
46
|
+
export function confirmsDesktopTask(evidence, threadId, cwd, turnId) {
|
|
47
|
+
const thread = evidence?.thread;
|
|
48
|
+
if (thread?.id !== threadId || thread.kind !== "codex" || thread.hostId !== "local"
|
|
49
|
+
|| typeof thread.cwd !== "string" || !path.isAbsolute(thread.cwd)
|
|
50
|
+
|| (turnId !== undefined && evidence.latestTurnId !== turnId)) return false;
|
|
51
|
+
return !path.relative(fs.realpathSync.native(thread.cwd), fs.realpathSync.native(cwd));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isLegacyCliSession(session) {
|
|
55
|
+
return session?.originator === "codex-tui" && session.source === "cli";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function assertRolloutSetStable(sessions, threadId, snapshots) {
|
|
59
|
+
const files = findRolloutSegments(sessions, threadId);
|
|
60
|
+
if (files.length !== snapshots.length || files.some((file, i) => file !== snapshots[i].file)) throw new Error("The rollout segment set changed while reading");
|
|
61
|
+
for (const { file, version } of snapshots) {
|
|
62
|
+
const current = fs.lstatSync(file);
|
|
63
|
+
if (!current.isFile() || current.isSymbolicLink() || Object.entries(version).some(([key, value]) => current[key] !== value)) throw new Error("A rollout segment changed while reading");
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/security-policy.mjs
CHANGED
|
@@ -212,11 +212,13 @@ export class BridgeSecurityPolicy {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
summary() {
|
|
215
|
+
const projectPolicy = this.hardenedRoots.mode === "project-policy" ? this.hardenedRoots.status() : null;
|
|
215
216
|
return {
|
|
216
217
|
authorizedThreads: this.allowedThreadIds.size + this.ownedThreadIds.size,
|
|
217
218
|
allowAllThreads: this.allowAllThreads,
|
|
218
|
-
allowedRoots: this.allowAllRoots ? [ALLOW_ALL_ROOTS] : this.allowedRoots,
|
|
219
|
-
allowAllRoots: this.allowAllRoots,
|
|
219
|
+
allowedRoots: projectPolicy ? (projectPolicy.error ? [] : this.hardenedRoots.roots) : this.allowAllRoots ? [ALLOW_ALL_ROOTS] : this.allowedRoots,
|
|
220
|
+
allowAllRoots: projectPolicy ? false : this.allowAllRoots,
|
|
221
|
+
...(projectPolicy ? { projectPolicy } : {}),
|
|
220
222
|
threadPolicy: this.threadPolicy,
|
|
221
223
|
approvalPolicy: this.approvalPolicy,
|
|
222
224
|
sandbox: this.sandbox,
|
package/src/sites-connector.mjs
CHANGED
|
@@ -54,8 +54,23 @@ async function readPrivateJson(file, maxBytes = 2 * 1024 * 1024) {
|
|
|
54
54
|
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
55
55
|
try {
|
|
56
56
|
const current = await handle.stat();
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
const fields = ["dev", "ino", "size", "mtimeMs", "ctimeMs"];
|
|
58
|
+
const matches = (left, right) => fields.every((field) => left[field] === right[field]);
|
|
59
|
+
if (!current.isFile() || current.nlink !== 1 || current.size > maxBytes || !matches(info, current)) throw new Error("Private connector file changed during access");
|
|
60
|
+
const data = Buffer.alloc(current.size);
|
|
61
|
+
let offset = 0;
|
|
62
|
+
while (offset < data.length) {
|
|
63
|
+
const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
|
|
64
|
+
if (!bytesRead) throw new Error("Private connector file changed during access");
|
|
65
|
+
offset += bytesRead;
|
|
66
|
+
}
|
|
67
|
+
const after = await handle.stat();
|
|
68
|
+
const entry = await fs.lstat(file);
|
|
69
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1 || !matches(current, after) || !matches(after, entry)) throw new Error("Private connector file changed during access");
|
|
70
|
+
return JSON.parse(data.toString("utf8"));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code === "ENOENT") throw new Error("Private connector file changed during access", { cause: error });
|
|
73
|
+
throw error;
|
|
59
74
|
} finally { await handle.close(); }
|
|
60
75
|
}
|
|
61
76
|
|
|
@@ -382,7 +397,7 @@ export async function runSitesConnector({ configFile, once = false, signal, ...d
|
|
|
382
397
|
return new SitesConnector({ config, backend, journal, log: (state) => process.stderr.write(`[sites-connector] ${JSON.stringify(state)}\n`), ...dependencies }).run({ once, signal });
|
|
383
398
|
}
|
|
384
399
|
|
|
385
|
-
if (process.argv[1] &&
|
|
400
|
+
if (process.argv[1] && await fs.realpath(process.argv[1]).catch(() => null) === fileURLToPath(import.meta.url)) {
|
|
386
401
|
const args = process.argv.slice(2);
|
|
387
402
|
const flags = new Map();
|
|
388
403
|
let invalid = false;
|
package/src/telemetry.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, open, rename, unlink,
|
|
1
|
+
import { mkdir, readFile, open, rename, unlink, lstat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -48,14 +48,16 @@ async function locked(directory, action) {
|
|
|
48
48
|
lock = await open(filename, 'wx', 0o600);
|
|
49
49
|
} catch (error) {
|
|
50
50
|
if (error.code !== 'EEXIST') throw error;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
throw new Error('Telemetry settings are busy. Locks never expire automatically; inspect active telemetry processes before repairing a leftover lock.');
|
|
52
|
+
}
|
|
53
|
+
let owned;
|
|
54
|
+
try { owned = await lock.stat(); return await action(); }
|
|
55
|
+
finally {
|
|
56
|
+
try {
|
|
57
|
+
const current = await lstat(filename).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
|
|
58
|
+
if (owned && current?.isFile() && !current.isSymbolicLink() && current.nlink === 1 && owned.dev === current.dev && owned.ino === current.ino && owned.birthtimeMs === current.birthtimeMs) await unlink(filename);
|
|
59
|
+
} finally { await lock.close(); }
|
|
56
60
|
}
|
|
57
|
-
try { return await action(); }
|
|
58
|
-
finally { await lock.close(); await unlink(filename).catch(() => {}); }
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
function suppressed(env) {
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -2,8 +2,10 @@ import { NativeDesktopRelay, accountRelaySocketPath, desktopTaskSocketPath, desk
|
|
|
2
2
|
import { runTurn } from "./turn.mjs";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { creationRequestHash } from "./native-creation-receipts.mjs";
|
|
5
7
|
import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
|
|
6
|
-
import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse } from "./codex-native-response.mjs";
|
|
8
|
+
import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse } from "./codex-native-response.mjs";
|
|
7
9
|
import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
|
|
8
10
|
import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
9
11
|
|
|
@@ -85,9 +87,10 @@ export class DesktopTaskDelivery {
|
|
|
85
87
|
return inspected.projectScope;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
async request(operation, args, { deadline, includeRelayContext = false, projectScope: expectedScope } = {}) {
|
|
90
|
+
async request(operation, args, { deadline, includeRelayContext = false, scopeBinding, creationReceiptId, projectScope: expectedScope } = {}) {
|
|
89
91
|
try {
|
|
90
92
|
await this.beforeRequest?.({ operation, args });
|
|
93
|
+
if (scopeBinding) this.security.hardenedRoots.recheck(scopeBinding);
|
|
91
94
|
const projectScope = expectedScope ?? await this.mutationProjectScope(operation, args, { deadline });
|
|
92
95
|
if (expectedScope) recheckProjectScope(expectedScope, this.senderProjectScope(expectedScope.recipient.input));
|
|
93
96
|
const remaining = deadline === undefined ? undefined : deadline - this.now();
|
|
@@ -95,15 +98,17 @@ export class DesktopTaskDelivery {
|
|
|
95
98
|
const accountContext = this.accountContext?.();
|
|
96
99
|
const response = await this.relay.requestDesktop(operation, args, {
|
|
97
100
|
...(remaining === undefined ? {} : { timeoutMs: remaining }),
|
|
98
|
-
...(this.beforeRequest || projectScope ? { beforeSend: async () => {
|
|
101
|
+
...(this.beforeRequest || projectScope || scopeBinding ? { beforeSend: async () => {
|
|
99
102
|
await this.beforeRequest?.({ operation, args, phase: "write" });
|
|
100
103
|
if (projectScope) {
|
|
101
104
|
recheckProjectScope(projectScope);
|
|
102
105
|
recheckProjectScope(projectScope, await this.mutationProjectScope(operation, args, { deadline }));
|
|
103
106
|
}
|
|
104
107
|
await this.beforeRequest?.({ operation, args, phase: "dispatch" });
|
|
108
|
+
if (scopeBinding) this.security.hardenedRoots.recheck(scopeBinding);
|
|
105
109
|
} } : {}),
|
|
106
110
|
...(accountContext ? { accountContext } : {}),
|
|
111
|
+
...(creationReceiptId ? { creationReceiptId } : {}),
|
|
107
112
|
});
|
|
108
113
|
return includeRelayContext ? { result: response.result, executorThreadId: response.executorThreadId } : response.result;
|
|
109
114
|
} catch (err) {
|
|
@@ -175,12 +180,16 @@ export class DesktopTaskDelivery {
|
|
|
175
180
|
return { rows, coverage: "Codex Desktop's recent/pinned snapshot; local Codex workspaces only. Agent-created tasks visible in the sidebar can be omitted; read a known task ID directly before assuming creation failed." };
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
async reuseReceipt(receipt, { cwd, promptHash, deadline, accountContext }) {
|
|
183
|
+
async reuseReceipt(receipt, { cwd, promptHash, deadline, accountContext, includeReplyReceipt }) {
|
|
179
184
|
if (!receipt) return null;
|
|
180
185
|
if (accountContext && (!receipt.accountContext || ["claude", "codex"].some((provider) => receipt.accountContext[provider] !== accountContext[provider]))) {
|
|
181
186
|
throw new Error(`The existing Desktop creation receipt ${receipt.threadId ? `for task ${receipt.threadId} ` : ""}${receipt.accountContext ? "belongs to different accounts" : "has no verified original account binding"}. Its receipt and task ID were retained. No creation or prompt resend was attempted; inspect the explicit existing task before continuing.`);
|
|
182
187
|
}
|
|
183
188
|
if (path.relative(cwd, realpathSync.native(receipt.cwd))) throw new Error("The stored Desktop creation receipt belongs to another workspace. No prompt was sent.");
|
|
189
|
+
if (receipt.state === "unknown" && includeReplyReceipt && receipt.deliveryId) return {
|
|
190
|
+
threadId: null, cwd, deliveryId: receipt.deliveryId, deliveryStatus: "unconfirmed", reused: true, name: receipt.name,
|
|
191
|
+
projectId: receipt.projectId, projectName: receipt.projectName, promptChanged: receipt.promptHash !== promptHash,
|
|
192
|
+
};
|
|
184
193
|
if (receipt.state !== "known") throw new Error(`An earlier Desktop creation is ${receipt.state} and may already have started. ${receipt.threadId ? `threadId: ${receipt.threadId}. ` : ""}Do not resend or create another task; inspect the existing Desktop task and resolve its creation receipt first.`);
|
|
185
194
|
let response;
|
|
186
195
|
try {
|
|
@@ -194,7 +203,7 @@ export class DesktopTaskDelivery {
|
|
|
194
203
|
this.security.assertCwd(thread.cwd);
|
|
195
204
|
this.security.assertThread(thread.id, thread.cwd);
|
|
196
205
|
const assignment = await this.receiptProjectAssignment(receipt, cwd, { deadline });
|
|
197
|
-
return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, ...assignment, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash };
|
|
206
|
+
return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, ...assignment, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash, ...(includeReplyReceipt && receipt.deliveryId ? { deliveryId: receipt.deliveryId } : {}) };
|
|
198
207
|
}
|
|
199
208
|
|
|
200
209
|
async receiptProjectAssignment(receipt, cwd, { deadline }) {
|
|
@@ -234,16 +243,17 @@ export class DesktopTaskDelivery {
|
|
|
234
243
|
return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
|
|
235
244
|
}
|
|
236
245
|
|
|
237
|
-
async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
246
|
+
async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, onAccepted, onPrepared, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
238
247
|
this.security.assertCwd(cwd);
|
|
239
248
|
const projectScope = this.senderProjectScope(cwd);
|
|
240
249
|
cwd = realpathSync.native(cwd);
|
|
241
250
|
this.security.assertCwd(cwd);
|
|
251
|
+
const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(cwd) : undefined;
|
|
242
252
|
dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
|
|
243
253
|
const identity = this.receipts.key({ cwd, prompt, name: dedupeName, requestId });
|
|
244
254
|
const accountContext = this.accountContext?.();
|
|
245
255
|
if (this.accountContext && !accountContext) throw new Error("Desktop creation requires the original verified account context.");
|
|
246
|
-
const options = { cwd, promptHash: identity.promptHash, deadline, accountContext };
|
|
256
|
+
const options = { cwd, promptHash: identity.promptHash, deadline, accountContext, includeReplyReceipt: Boolean(onAccepted || onPrepared) };
|
|
247
257
|
const reused = await this.reuseReceipt(await this.receipts.read(identity.key), options);
|
|
248
258
|
if (reused) return reused;
|
|
249
259
|
const listed = await this.request("list_projects", {}, { deadline });
|
|
@@ -254,15 +264,27 @@ export class DesktopTaskDelivery {
|
|
|
254
264
|
if (existing) return existing;
|
|
255
265
|
if (this.now() >= deadline) throw new Error("The response deadline elapsed before Desktop creation. No task was created.");
|
|
256
266
|
const receipt = { version: 1, ...identity, cwd, state: "pending", startedAt: this.now(), ...(accountContext ? { accountContext: { ...accountContext } } : {}), ...(dedupeName ? { name: dedupeName } : {}), projectId: project.projectId, ...(project.label ? { projectName: project.label } : {}) };
|
|
267
|
+
const creationArgs = { prompt, title: name,
|
|
268
|
+
target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
|
|
269
|
+
...(model ? { model } : {}), ...(effort ? { thinking: effort } : {}) };
|
|
270
|
+
const creationReceiptId = onPrepared ? randomUUID() : undefined;
|
|
271
|
+
if (onPrepared) {
|
|
272
|
+
// Feature-detect before mutation; old companions refuse this read-only
|
|
273
|
+
// probe. Never create first and discover missing recovery afterwards.
|
|
274
|
+
if (!accountContext) throw new Error("Creation continuation requires verified accounts");
|
|
275
|
+
const requestHash = creationRequestHash(creationArgs);
|
|
276
|
+
const probe = await this.request("get_creation_receipt", { receiptId: creationReceiptId, requestHash }, { deadline });
|
|
277
|
+
if (probe?.status !== "missing") throw new Error("Native creation receipt capability could not be confirmed; no task was created");
|
|
278
|
+
receipt.deliveryId = onPrepared({ threadId: null, cwd, previousTurnId: null, deliveryStatus: "unconfirmed",
|
|
279
|
+
creationObservation: { receiptId: creationReceiptId, requestHash, receiptKey: identity.key, args: creationArgs, accountContext: { ...accountContext } } });
|
|
280
|
+
}
|
|
257
281
|
await this.receipts.write(identity.key, receipt);
|
|
258
282
|
let threadId;
|
|
259
283
|
let creationConfirmed = false;
|
|
260
284
|
try {
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
...(model ? { model } : {}), ...(effort ? { thinking: effort } : {}),
|
|
265
|
-
}, { deadline, projectScope });
|
|
285
|
+
const envelope = await this.request("create_thread", creationArgs,
|
|
286
|
+
{ deadline, projectScope, scopeBinding, creationReceiptId, includeRelayContext: true });
|
|
287
|
+
const response = envelope.result;
|
|
266
288
|
const confirmedId = response?.threadId ?? response?.conversationId;
|
|
267
289
|
threadId = confirmedId;
|
|
268
290
|
if (!threadId && (response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown")) threadId = response?.clientThreadId;
|
|
@@ -271,56 +293,130 @@ export class DesktopTaskDelivery {
|
|
|
271
293
|
creationConfirmed = true;
|
|
272
294
|
this.security.registerThread(threadId);
|
|
273
295
|
if (response.firstTurn && response.firstTurn.status !== "accepted") throw new Error(`Desktop created task ${threadId}; its first turn reports ${response.firstTurn.status ?? "an unconfirmed state"}: ${JSON.stringify(response)}`);
|
|
274
|
-
|
|
296
|
+
const created = { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
|
|
297
|
+
const delivered = { ...created, previousTurnId: null, deliveryStatus: "accepted", responseObservation: {
|
|
298
|
+
operation: "create_thread", threadId, previousTurnId: null, expectedCwd: cwd,
|
|
299
|
+
executorThreadId: envelope.executorThreadId ?? null, prompt,
|
|
300
|
+
accountContext: accountContext ? { ...accountContext } : null,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
const deliveryId = receipt.deliveryId ?? onAccepted?.(delivered);
|
|
304
|
+
if (deliveryId) {
|
|
305
|
+
await this.receipts.write(identity.key, { ...receipt, state: "known", threadId, deliveryId });
|
|
306
|
+
created.deliveryId = deliveryId;
|
|
307
|
+
}
|
|
308
|
+
return created;
|
|
275
309
|
} catch (err) {
|
|
276
310
|
if (!creationConfirmed) await this.receipts.write(identity.key, { ...receipt, state: "unknown", ...(typeof threadId === "string" && threadId ? { threadId } : {}) }).catch(() => {});
|
|
311
|
+
const cause = err.cause ?? err;
|
|
312
|
+
if (receipt.deliveryId && cause.reachedCompanion === true && ["RELAY_TIMEOUT", "RELAY_DELIVERY_UNCONFIRMED"].includes(cause.code)) {
|
|
313
|
+
return { threadId: null, cwd, name, projectId: project.projectId, projectName: project.label, deliveryStatus: "unconfirmed", deliveryId: receipt.deliveryId };
|
|
314
|
+
}
|
|
277
315
|
throw new Error(`${err.message}. ${threadId ? `threadId: ${threadId}. ` : ""}Do not resend the prompt. The creation receipt blocks duplicate tasks even after a bridge restart.`, { cause: err });
|
|
278
316
|
}
|
|
279
317
|
});
|
|
280
318
|
}
|
|
281
319
|
|
|
320
|
+
async resolveCreation(delivered, { deadline = this.now() + DESKTOP_TOOL_BUDGET_MS } = {}) {
|
|
321
|
+
const binding = delivered.creationObservation;
|
|
322
|
+
if (!binding) return delivered;
|
|
323
|
+
for (;;) {
|
|
324
|
+
if (this.now() >= deadline) return null;
|
|
325
|
+
this.security.assertCwd(delivered.cwd);
|
|
326
|
+
this.senderProjectScope(delivered.cwd);
|
|
327
|
+
if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
|
|
328
|
+
const receipt = await this.receipts.read(binding.receiptKey);
|
|
329
|
+
if (!receipt || !sameAccountContext(receipt.accountContext, binding.accountContext) || receipt.cwd !== delivered.cwd ||
|
|
330
|
+
receipt.promptHash !== this.receipts.key({ cwd: delivered.cwd, prompt: binding.args.prompt }).promptHash) throw new Error("Original creation receipt binding changed");
|
|
331
|
+
let envelope;
|
|
332
|
+
try { envelope = await this.request("get_creation_receipt", { receiptId: binding.receiptId, requestHash: binding.requestHash }, { deadline, includeRelayContext: true }); }
|
|
333
|
+
catch (error) { if (this.now() >= deadline && ["RELAY_TIMEOUT", "RELAY_UNREACHABLE"].includes(error.cause?.code)) return null; throw error; }
|
|
334
|
+
const row = envelope.result;
|
|
335
|
+
if (row?.status === "completed") {
|
|
336
|
+
const response = row.result, threadId = response?.threadId;
|
|
337
|
+
if (typeof threadId !== "string" || !threadId.trim() || response.hostId !== "local" || response.status === "outcome-unknown" ||
|
|
338
|
+
response.firstTurn && response.firstTurn.status !== "accepted" || receipt.threadId && receipt.threadId !== threadId) throw new Error("Native creation is not confirmed; do not resend");
|
|
339
|
+
let inspected;
|
|
340
|
+
try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline }); }
|
|
341
|
+
catch (error) { if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null; throw error; }
|
|
342
|
+
if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
|
|
343
|
+
if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
|
|
344
|
+
this.security.assertCwd(delivered.cwd);
|
|
345
|
+
this.senderProjectScope(delivered.cwd);
|
|
346
|
+
if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
|
|
347
|
+
if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
|
|
348
|
+
this.security.registerThread(threadId);
|
|
349
|
+
this.security.assertThread(threadId, inspected.thread.cwd);
|
|
350
|
+
return { threadId, cwd: delivered.cwd, previousTurnId: null, deliveryStatus: "accepted",
|
|
351
|
+
responseObservation: { operation: "create_thread", threadId, previousTurnId: null, expectedCwd: delivered.cwd,
|
|
352
|
+
executorThreadId: envelope.executorThreadId, prompt: binding.args.prompt, accountContext: binding.accountContext } };
|
|
353
|
+
}
|
|
354
|
+
if (row?.status === "failed") throw new Error(`Native creation remains unconfirmed: ${row.error?.message ?? "unknown failure"}. Do not resend.`);
|
|
355
|
+
if (!["missing", "pending"].includes(row?.status)) throw new Error("Invalid native creation receipt response");
|
|
356
|
+
await this.sleep(Math.min(1000, Math.max(0, deadline - this.now())));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
282
360
|
async inspect(threadId, cwd, { deadline } = {}) {
|
|
283
|
-
const
|
|
361
|
+
const inspected = await this.#inspectMetadata(threadId, cwd, { deadline });
|
|
362
|
+
this.security.assertThread(threadId, inspected.thread.cwd);
|
|
363
|
+
return inspected;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async #inspectMetadata(threadId, cwd, { deadline } = {}) {
|
|
367
|
+
const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 }, { deadline, includeRelayContext: true });
|
|
368
|
+
const response = envelope.result;
|
|
284
369
|
const thread = response?.thread;
|
|
285
370
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
286
|
-
this.security.assertThread(threadId, thread.cwd);
|
|
287
371
|
this.security.assertCwd(thread.cwd);
|
|
288
372
|
const projectScope = this.senderProjectScope(thread.cwd);
|
|
289
373
|
if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
|
|
290
374
|
throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
|
|
291
375
|
}
|
|
292
|
-
|
|
376
|
+
const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(thread.cwd) : undefined;
|
|
377
|
+
return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope, scopeBinding, executorThreadId: envelope.executorThreadId ?? null };
|
|
293
378
|
}
|
|
294
379
|
|
|
295
|
-
async send({ threadId, prompt, cwd, model, effort, name, deadline }) {
|
|
380
|
+
async send({ threadId, prompt, cwd, model, effort, name, deadline, onPrepared }) {
|
|
296
381
|
const inspected = await this.inspect(threadId, cwd, { deadline });
|
|
297
|
-
|
|
382
|
+
const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(inspected.thread.cwd) : undefined;
|
|
383
|
+
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline, projectScope: inspected.projectScope, scopeBinding });
|
|
298
384
|
const expectedCwd = realpathSync.native(inspected.thread.cwd);
|
|
299
385
|
const accountContext = this.accountContext?.();
|
|
300
|
-
const watermark = this.captureResponse({ threadId, expectedCwd });
|
|
301
|
-
|
|
386
|
+
const watermark = this.captureResponse({ threadId, expectedCwd, desktopEvidence: inspected });
|
|
387
|
+
// Preserve the exact pre-send observation before any possibly ambiguous write.
|
|
388
|
+
// An unconfirmed receipt permits observation, never resending or an approval bypass.
|
|
389
|
+
const delivered = {
|
|
390
|
+
threadId, cwd: expectedCwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND,
|
|
391
|
+
deliveryStatus: "unconfirmed",
|
|
392
|
+
responseObservation: {
|
|
393
|
+
threadId, previousTurnId: inspected.latestTurnId, expectedCwd,
|
|
394
|
+
executorThreadId: inspected.executorThreadId, prompt,
|
|
395
|
+
accountContext: accountContext ? { ...accountContext } : null, watermark,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
onPrepared?.(delivered);
|
|
399
|
+
let envelope;
|
|
400
|
+
try { envelope = await this.request("send_message_to_thread", {
|
|
302
401
|
threadId, prompt,
|
|
303
402
|
...(model ? { model } : {}),
|
|
304
403
|
...(effort ? { thinking: effort } : {}),
|
|
305
|
-
}, { deadline, includeRelayContext: true, projectScope: inspected.projectScope });
|
|
404
|
+
}, { deadline, includeRelayContext: true, projectScope: inspected.projectScope, scopeBinding });
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const cause = error.cause ?? error;
|
|
407
|
+
if (onPrepared && cause.reachedCompanion === true && ["RELAY_TIMEOUT", "RELAY_DELIVERY_UNCONFIRMED"].includes(cause.code)) {
|
|
408
|
+
return { ...delivered, deliveryNote: error.message };
|
|
409
|
+
}
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
306
412
|
const response = envelope.result;
|
|
307
413
|
if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
|
|
308
414
|
(response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
|
|
309
415
|
(response?.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
310
416
|
throw new Error(`Desktop send is not confirmed for ${threadId}. Do not resend: ${JSON.stringify(response)}`);
|
|
311
417
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
responseObservation: {
|
|
315
|
-
threadId,
|
|
316
|
-
previousTurnId: inspected.latestTurnId,
|
|
317
|
-
expectedCwd,
|
|
318
|
-
executorThreadId: envelope.executorThreadId ?? null,
|
|
319
|
-
prompt,
|
|
320
|
-
accountContext: accountContext ? { ...accountContext } : null,
|
|
321
|
-
watermark,
|
|
322
|
-
},
|
|
323
|
-
};
|
|
418
|
+
if ((envelope.executorThreadId ?? null) !== inspected.executorThreadId) throw new Error("Native executor changed during dispatch; inspect the existing task without resending");
|
|
419
|
+
return { ...delivered, deliveryStatus: "accepted" };
|
|
324
420
|
}
|
|
325
421
|
|
|
326
422
|
async open(threadId, { deadline } = {}) {
|
|
@@ -340,7 +436,10 @@ export class DesktopTaskDelivery {
|
|
|
340
436
|
if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its response was being observed");
|
|
341
437
|
};
|
|
342
438
|
await recheck();
|
|
343
|
-
const
|
|
439
|
+
const initial = await this.inspect(threadId, expectedCwd, { deadline });
|
|
440
|
+
if (initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
|
|
441
|
+
const read = responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
|
|
442
|
+
const observed = read({ ...responseObservation, threadId, turnId, desktopEvidence: initial });
|
|
344
443
|
await recheck();
|
|
345
444
|
const inspected = await this.inspect(threadId, expectedCwd, { deadline });
|
|
346
445
|
if (inspected.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
|
|
@@ -361,7 +460,7 @@ export class DesktopTaskDelivery {
|
|
|
361
460
|
if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its turn was being inspected");
|
|
362
461
|
};
|
|
363
462
|
await recheck();
|
|
364
|
-
const result = this.inspectResponse({ threadId, turnId, expectedCwd });
|
|
463
|
+
const result = this.inspectResponse({ threadId, turnId, expectedCwd, desktopEvidence: initial });
|
|
365
464
|
await recheck();
|
|
366
465
|
await this.inspect(threadId, expectedCwd, { deadline });
|
|
367
466
|
await recheck();
|