@minhspark/codex-mcp-bridge 1.22.13 → 1.22.14
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 +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-claude-desktop.mjs +37 -9
- package/scripts/install-native-relay.mjs +6 -3
- package/src/bridge-readiness.mjs +3 -1
- package/src/claude-bridge.mjs +6 -3
- package/src/claude-code-registration.mjs +28 -4
- package/src/codex-mcp-toml.mjs +26 -4
- package/src/codex-native-response.mjs +47 -26
- package/src/codex-sender-context.mjs +13 -2
- package/src/index.mjs +29 -8
- package/src/mcp-supervisor.mjs +30 -6
- package/src/native-relay-companion.mjs +39 -34
- package/src/native-relay.mjs +2 -2
- package/src/peer-protocol.mjs +3 -2
- package/src/platform.mjs +40 -7
- package/src/project-onboarding-tools.mjs +6 -4
- package/src/project-onboarding.mjs +43 -16
- package/src/relay-wake.mjs +129 -0
- package/src/release-snapshot.mjs +360 -33
- package/src/rollout-reader.mjs +160 -24
- package/src/rollout-segments.mjs +35 -7
- package/src/thread-delivery.mjs +20 -3
- package/src/vscode-bridge.mjs +9 -23
package/src/index.mjs
CHANGED
|
@@ -37,10 +37,11 @@ import { captureProjectScope } from "./project-scope.mjs";
|
|
|
37
37
|
import { DesktopReplyReceipts } from "./desktop-reply-receipts.mjs";
|
|
38
38
|
import { registerDesktopReadTools } from "./desktop-read-tools.mjs";
|
|
39
39
|
import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
|
|
40
|
+
import { createRelayWake } from "./relay-wake.mjs";
|
|
40
41
|
|
|
41
42
|
exitForVersionRequest(import.meta.url);
|
|
42
43
|
|
|
43
|
-
const VERSION = "1.22.
|
|
44
|
+
const VERSION = "1.22.14";
|
|
44
45
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
45
46
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
46
47
|
|
|
@@ -75,7 +76,7 @@ const desktopTasksEnabled = desktopTasksConfigured();
|
|
|
75
76
|
const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
|
|
76
77
|
const desktopOperation = new AsyncLocalStorage();
|
|
77
78
|
const replyReceipts = new DesktopReplyReceipts();
|
|
78
|
-
const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts, senderContext: () => desktopOperation.getStore()?.caller });
|
|
79
|
+
const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts, senderContext: () => desktopOperation.getStore()?.caller, wakeRelay: createRelayWake({ senderCwd: () => desktopOperation.getStore()?.caller?.cwd ?? null, log }) });
|
|
79
80
|
|
|
80
81
|
async function assertDesktopOperation(context, { verifyProcess = false } = {}) {
|
|
81
82
|
if (!context || context.diagnostic) return;
|
|
@@ -105,9 +106,24 @@ async function beforeDesktopRequest({ operation, args, phase }) {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
const asSentence = (text) => /[.!?]$/.test(text) ? text : `${text}.`;
|
|
110
|
+
|
|
111
|
+
function deliveryReadGuidance(name, args) {
|
|
112
|
+
if (name === "wait_codex_reply") return "Keep the deliveryId and retry wait_codex_reply; do not resend the original message.";
|
|
113
|
+
if (name === "read_codex_thread" && args?.turnId) return "Retry read_codex_thread with the same turnId; do not resend the original message.";
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function withheldDesktopResult(error, context, { readOnly = false, completed = false, guidance = null } = {}) {
|
|
109
118
|
if (context.dispatched) reload.defer("A Desktop operation was dispatched without a fully verified outcome");
|
|
110
|
-
const reason = (error?.message ?? "
|
|
119
|
+
const reason = asSentence((error?.message ?? "").replace(/\s*No message was sent\./g, "").trim() || "The calling session or accounts could not be reverified.");
|
|
120
|
+
if (readOnly && !context.dispatched) {
|
|
121
|
+
const failed = failure(error);
|
|
122
|
+
return {
|
|
123
|
+
...textResult(`Codex bridge error: ${reason}${completed ? " The read result was withheld." : ""}${guidance ? ` ${guidance}` : ""}`, true),
|
|
124
|
+
structuredContent: { ...failed.structuredContent, accountContext: context.accounts },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
111
127
|
return {
|
|
112
128
|
...textResult(`${reason} ${context.dispatched ? "A Desktop operation may already have been dispatched. Its original destination and any creation receipt were preserved." : "No Desktop mutation was dispatched."} Reply content was withheld. Inspect the original task and do not resend automatically.`, true),
|
|
113
129
|
structuredContent: { ...failure(error).structuredContent, accountContext: context.accounts, operation: { state: context.dispatched ? "dispatched_outcome_unverified" : "blocked", threadId: context.threadId ?? null } },
|
|
@@ -464,17 +480,22 @@ function registerTool(name, definition, handler) {
|
|
|
464
480
|
const identity = requireBridgeAccounts(accounts);
|
|
465
481
|
const caller = requireClaudeSenderContext(await readClaudeSenderContext({ account: accounts.claude }));
|
|
466
482
|
const context = { accounts: identity, caller, dispatched: false, deadline, projectSetup: PROJECT_SETUP_TOOLS.has(name) };
|
|
483
|
+
const readOnly = definition.annotations?.readOnlyHint === true;
|
|
484
|
+
const guidance = deliveryReadGuidance(name, args[0]);
|
|
467
485
|
return await desktopOperation.run(context, async () => {
|
|
486
|
+
let completed = false;
|
|
468
487
|
try {
|
|
469
488
|
await assertDesktopOperation(context);
|
|
470
489
|
const result = await handler(...args);
|
|
490
|
+
completed = true;
|
|
471
491
|
await assertDesktopOperation(context);
|
|
472
492
|
if (result.isError && !context.dispatched && result.structuredContent?.responseVersion !== 1) {
|
|
473
|
-
result.structuredContent = { ...result.structuredContent, accountContext: context.accounts, operation: { state: "blocked", threadId: context.threadId ?? null } };
|
|
493
|
+
result.structuredContent = { ...result.structuredContent, accountContext: context.accounts, ...(readOnly ? {} : { operation: { state: "blocked", threadId: context.threadId ?? null } }) };
|
|
494
|
+
if (guidance && result.structuredContent?.nextAction !== "none") result.content.push({ type: "text", text: guidance });
|
|
474
495
|
}
|
|
475
496
|
return result;
|
|
476
497
|
} catch (error) {
|
|
477
|
-
return withheldDesktopResult(error, context);
|
|
498
|
+
return withheldDesktopResult(error, context, { readOnly, completed, guidance });
|
|
478
499
|
}
|
|
479
500
|
});
|
|
480
501
|
}
|
|
@@ -492,8 +513,8 @@ registerDesktopReadTools(registerTool, {
|
|
|
492
513
|
assertThreadAccess, normalizeThreadCwd, textResult, replyResult, failure,
|
|
493
514
|
});
|
|
494
515
|
|
|
495
|
-
registerProjectOnboardingTools(registerTool, { beforePrepare: async () => {
|
|
496
|
-
if (!desktopTasksEnabled || !desktopOperation.getStore()?.caller) throw new Error("Project onboarding requires a verified Desktop caller");
|
|
516
|
+
registerProjectOnboardingTools(registerTool, { options: { policyRegistration: "claude-code" }, beforePrepare: async () => {
|
|
517
|
+
if (!desktopTasksEnabled || !desktopOperation.getStore()?.caller) throw new Error("Project onboarding requires a verified Desktop caller.");
|
|
497
518
|
await assertDesktopOperation(desktopOperation.getStore(), { verifyProcess: true });
|
|
498
519
|
} });
|
|
499
520
|
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { fork } from "node:child_process";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
|
|
6
|
+
import { createReleaseSnapshot, pruneReleaseSnapshots, snapshotRoot, sourceRevision } from "./release-snapshot.mjs";
|
|
7
7
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
8
8
|
import { isMainModule, stableInstallPath } from "./install-paths.mjs";
|
|
9
9
|
import { CLIENT_CLOSE_DRAIN_MS, CLIENT_CLOSE_STOP_MS } from "./reload-control.mjs";
|
|
@@ -50,6 +50,8 @@ class Worker {
|
|
|
50
50
|
stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
|
|
51
51
|
});
|
|
52
52
|
this.exited = new Promise((resolve) => this.process.once("exit", resolve));
|
|
53
|
+
this.exited.then(() => release.lease?.release());
|
|
54
|
+
this.process.once("error", () => { if (this.process.pid === undefined) release.lease?.release(); });
|
|
53
55
|
this.process.stderr.on("data", (data) => process.stderr.write(data));
|
|
54
56
|
this.process.stdin.on("error", (error) => this.fail(error));
|
|
55
57
|
this.process.on("error", (error) => this.fail(error));
|
|
@@ -160,6 +162,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
160
162
|
};
|
|
161
163
|
const pollMs = interval(env.CODEX_BRIDGE_RELOAD_POLL_MS, 1000);
|
|
162
164
|
const settleMs = interval(env.CODEX_BRIDGE_RELOAD_SETTLE_MS, 1500);
|
|
165
|
+
const pruneDelayMs = interval(env.CODEX_BRIDGE_RUNTIME_PRUNE_DELAY_MS, 60000);
|
|
163
166
|
let active;
|
|
164
167
|
let initializeParams;
|
|
165
168
|
let initializeResult;
|
|
@@ -225,8 +228,14 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
225
228
|
},
|
|
226
229
|
};
|
|
227
230
|
const spawn = (release, routing) => {
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
let worker;
|
|
232
|
+
try {
|
|
233
|
+
if (stopping) throw new Error("The MCP client disconnected before activation");
|
|
234
|
+
worker = new Worker(release, entry, env, handlers);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
release.lease?.release();
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
230
239
|
worker.routingConfiguration = routing;
|
|
231
240
|
workers.add(worker);
|
|
232
241
|
return worker;
|
|
@@ -249,7 +258,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
249
258
|
try {
|
|
250
259
|
const state = await previous.control("inspect");
|
|
251
260
|
if (!state.reloadable) { reason = state.reason ?? "Pending deliveries prevent a safe reload"; return; }
|
|
252
|
-
const release = createReleaseSnapshot(source, { expectedRevision: revision });
|
|
261
|
+
const release = createReleaseSnapshot(source, { expectedRevision: revision, lease: true });
|
|
253
262
|
candidate = spawn(release, routing);
|
|
254
263
|
const initialized = await candidate.rpc("initialize", initializeParams);
|
|
255
264
|
if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
|
|
@@ -274,6 +283,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
274
283
|
await previous.stop().catch((error) => log(`retired ${entry} worker: ${error.message}`));
|
|
275
284
|
workers.delete(previous);
|
|
276
285
|
log(`activated ${entry} revision ${revision.slice(0, 12)}; client connection retained`);
|
|
286
|
+
schedulePrune();
|
|
277
287
|
} catch (error) {
|
|
278
288
|
reason = `Update deferred: ${error.message}`;
|
|
279
289
|
retryAt = Date.now() + Math.max(pollMs, 5000);
|
|
@@ -323,21 +333,35 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
323
333
|
active.write({ ...message, id });
|
|
324
334
|
} else active.write(message);
|
|
325
335
|
};
|
|
336
|
+
let pruneTimer = null;
|
|
337
|
+
const schedulePrune = () => {
|
|
338
|
+
if (stopping || pruneTimer) return;
|
|
339
|
+
pruneTimer = setTimeout(() => {
|
|
340
|
+
if (stopping) return;
|
|
341
|
+
void pruneReleaseSnapshots({ cache: snapshotRoot(env), keep: [...workers].map((worker) => worker.release.key) })
|
|
342
|
+
.then((report) => { if (report.removed.length) log(`removed ${report.removed.length} unused runtime snapshot(s)`); })
|
|
343
|
+
.catch((error) => log(`runtime snapshot cleanup failed: ${error.message}`))
|
|
344
|
+
.finally(() => { pruneTimer = null; });
|
|
345
|
+
}, pruneDelayMs);
|
|
346
|
+
pruneTimer.unref();
|
|
347
|
+
};
|
|
326
348
|
const shutdown = async () => {
|
|
327
349
|
if (stopping) return;
|
|
328
350
|
stopping = true;
|
|
329
351
|
clearInterval(timer);
|
|
352
|
+
clearTimeout(pruneTimer);
|
|
330
353
|
await Promise.allSettled([...workers].map((worker) => worker.stop(CLIENT_CLOSE_GRACE_MS)));
|
|
331
354
|
process.stdin.pause();
|
|
332
355
|
};
|
|
333
356
|
const preparationStarted = performance.now();
|
|
334
357
|
log(`preparing ${entry} runtime`);
|
|
335
|
-
const initial = createReleaseSnapshot(root);
|
|
336
|
-
log(`prepared ${entry} runtime in ${Math.round(performance.now() - preparationStarted)} ms`);
|
|
358
|
+
const initial = createReleaseSnapshot(root, { lease: true });
|
|
359
|
+
log(`prepared ${entry} runtime in ${Math.round(performance.now() - preparationStarted)} ms (${initial.copy})`);
|
|
337
360
|
observed = initial.revision;
|
|
338
361
|
observedRouting = routingConfiguration();
|
|
339
362
|
active = spawn(initial, observedRouting);
|
|
340
363
|
const timer = setInterval(() => { void check(); }, pollMs);
|
|
364
|
+
schedulePrune();
|
|
341
365
|
lines(process.stdin, (message) => {
|
|
342
366
|
if (!message || message.jsonrpc !== "2.0" || (!message.method && !hasId(message))) throw new Error("Invalid client JSON-RPC message");
|
|
343
367
|
dispatch(message);
|
|
@@ -39,7 +39,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
|
|
|
39
39
|
|
|
40
40
|
exitForVersionRequest(import.meta.url);
|
|
41
41
|
|
|
42
|
-
const VERSION = "1.22.
|
|
42
|
+
const VERSION = "1.22.14";
|
|
43
43
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
44
44
|
|
|
45
45
|
function errorResponse(code, message, sent, details) {
|
|
@@ -48,7 +48,7 @@ function errorResponse(code, message, sent, details) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const RELAY_RELOADING_REFUSAL = Object.freeze({ code: "RELAY_RELOADING", message: "The native relay is reloading; no message was sent" });
|
|
51
|
-
const RELAY_STOPPING_REFUSAL = Object.freeze({ code: "RELAY_STOPPING", message: "The native relay is shutting down
|
|
51
|
+
const RELAY_STOPPING_REFUSAL = Object.freeze({ code: "RELAY_STOPPING", message: "The native relay is shutting down; no message was sent" });
|
|
52
52
|
|
|
53
53
|
function boundRequest(payload) {
|
|
54
54
|
return payload?.v === ACCOUNT_RELAY_PROTOCOL_VERSION;
|
|
@@ -396,6 +396,7 @@ export class RelaySocketServer {
|
|
|
396
396
|
restrictSocket = (target) => {
|
|
397
397
|
if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
|
|
398
398
|
},
|
|
399
|
+
signalExit = true,
|
|
399
400
|
log: logFn = () => {},
|
|
400
401
|
} = {}) {
|
|
401
402
|
this.socketPath = socketPath;
|
|
@@ -410,6 +411,7 @@ export class RelaySocketServer {
|
|
|
410
411
|
this.protectSocket = protectSocket;
|
|
411
412
|
this.restrictSocket = restrictSocket;
|
|
412
413
|
this.log = logFn;
|
|
414
|
+
this.signalExit = signalExit;
|
|
413
415
|
this.server = null;
|
|
414
416
|
this.started = false;
|
|
415
417
|
this.connections = new Set();
|
|
@@ -472,12 +474,16 @@ export class RelaySocketServer {
|
|
|
472
474
|
this.aclReady = !IS_WINDOWS || !this.strict;
|
|
473
475
|
for (const socket of this.provisionalSockets) socket.destroy();
|
|
474
476
|
this.provisionalSockets.clear();
|
|
477
|
+
if (!IS_WINDOWS && this.boundIdentity && this.#ownsPublicPath()) {
|
|
478
|
+
try { fs.rmSync(this.socketPath, { force: true }); } catch {}
|
|
479
|
+
}
|
|
480
|
+
this.boundIdentity = null;
|
|
475
481
|
await this.#closeServer();
|
|
476
482
|
throw new Error(`refusing to serve on ${this.socketPath}: its mode could not be restricted (${err.message})`);
|
|
477
483
|
}
|
|
478
484
|
this.started = true;
|
|
479
485
|
|
|
480
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
486
|
+
for (const signal of this.signalExit ? ["SIGINT", "SIGTERM"] : []) {
|
|
481
487
|
const handler = () => {
|
|
482
488
|
this.stop();
|
|
483
489
|
process.exit(0);
|
|
@@ -799,7 +805,7 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
799
805
|
nativeTools.close();
|
|
800
806
|
};
|
|
801
807
|
const activate = async () => {
|
|
802
|
-
if (stopping) throw new Error("The native relay is shutting down
|
|
808
|
+
if (stopping) throw new Error("The native relay is shutting down");
|
|
803
809
|
for (const relay of servers) relay.refusal = RELAY_RELOADING_REFUSAL;
|
|
804
810
|
if (startups.length) {
|
|
805
811
|
for (const relay of servers) relay.accepting = true;
|
|
@@ -850,6 +856,27 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
850
856
|
};
|
|
851
857
|
}
|
|
852
858
|
|
|
859
|
+
export function createRelayServers({ nativeTools, env = process.env, signalExit = true, log: logFn = () => {} }) {
|
|
860
|
+
const hardened = hardenedBridgeEnabled(env);
|
|
861
|
+
if (hardened && !desktopTasksConfigured(env)) throw new Error("CODEX_BRIDGE_HARDENED=1 requires CODEX_BRIDGE_DESKTOP_TASKS=1; legacy relay listeners are disabled");
|
|
862
|
+
const dispatch = (args, options) => nativeTools.dispatch(args, options);
|
|
863
|
+
const dispatchDesktop = (args, options) => nativeTools.dispatchDesktop(args, options);
|
|
864
|
+
const shared = { signalExit, log: logFn };
|
|
865
|
+
const relay = new RelaySocketServer({ socketPath: relaySocketPath(env), dispatch, dispatchDesktop, ...shared });
|
|
866
|
+
const desktopRelay = desktopTaskSocketPath(env) === relay.socketPath ? relay : new RelaySocketServer({ socketPath: desktopTaskSocketPath(env), dispatchDesktop, ...shared });
|
|
867
|
+
const accountRelay = new RelaySocketServer({
|
|
868
|
+
socketPath: accountRelaySocketPath(env),
|
|
869
|
+
requireAccountContext: true,
|
|
870
|
+
strict: hardened,
|
|
871
|
+
dispatch,
|
|
872
|
+
dispatchDesktop,
|
|
873
|
+
authorize: hardened ? createNativeScopeAuthorizer({ dispatchDesktop, env }) : undefined,
|
|
874
|
+
...shared,
|
|
875
|
+
});
|
|
876
|
+
const strictDesktop = hardened && desktopTasksConfigured(env);
|
|
877
|
+
return { relay, desktopRelay, accountRelay, strictDesktop, relays: strictDesktop ? [accountRelay] : [relay, desktopRelay, accountRelay] };
|
|
878
|
+
}
|
|
879
|
+
|
|
853
880
|
/**
|
|
854
881
|
* `import.meta.main` is Node 24 and up, and this project supports Node 22, so
|
|
855
882
|
* the entry point is detected by comparing the resolved argv path instead.
|
|
@@ -869,33 +896,9 @@ if (invokedDirectly) {
|
|
|
869
896
|
);
|
|
870
897
|
|
|
871
898
|
const nativeTools = new NativeToolsClient();
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
const hardened = hardenedBridgeEnabled();
|
|
875
|
-
if (hardened && !desktopTasksConfigured()) throw new Error("CODEX_BRIDGE_HARDENED=1 requires CODEX_BRIDGE_DESKTOP_TASKS=1; legacy relay listeners are disabled");
|
|
876
|
-
const relay = new RelaySocketServer({
|
|
877
|
-
socketPath: relaySocketPath(),
|
|
878
|
-
dispatch,
|
|
879
|
-
dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options),
|
|
880
|
-
log,
|
|
881
|
-
});
|
|
882
|
-
const desktopRelay = desktopTaskSocketPath() === relay.socketPath ? relay : new RelaySocketServer({
|
|
883
|
-
socketPath: desktopTaskSocketPath(),
|
|
884
|
-
dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options),
|
|
885
|
-
log,
|
|
886
|
-
});
|
|
887
|
-
const accountRelay = new RelaySocketServer({
|
|
888
|
-
socketPath: accountRelaySocketPath(),
|
|
889
|
-
requireAccountContext: true,
|
|
890
|
-
strict: hardened,
|
|
891
|
-
dispatch,
|
|
892
|
-
dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options),
|
|
893
|
-
authorize: hardened ? createNativeScopeAuthorizer({ dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options) }) : undefined,
|
|
894
|
-
log,
|
|
895
|
-
});
|
|
899
|
+
const { relay, desktopRelay, accountRelay, strictDesktop, relays } = createRelayServers({ nativeTools, signalExit: false, log });
|
|
896
900
|
const runtime = createRuntimeState();
|
|
897
|
-
const
|
|
898
|
-
const lifecycle = createNativeRelayLifecycle({ nativeTools, relays: strictDesktop ? [accountRelay] : [relay, desktopRelay, accountRelay], log });
|
|
901
|
+
const lifecycle = createNativeRelayLifecycle({ nativeTools, relays, log });
|
|
899
902
|
const reload = createReloadControl({ entry: "native-relay-companion.mjs", ...lifecycle });
|
|
900
903
|
|
|
901
904
|
mcp.registerTool(
|
|
@@ -916,7 +919,7 @@ if (invokedDirectly) {
|
|
|
916
919
|
let executor = "(unconfigured)";
|
|
917
920
|
try {
|
|
918
921
|
const resolved = resolveRelayThreadId();
|
|
919
|
-
executor = `${resolved.threadId} (from ${resolved.source})`;
|
|
922
|
+
executor = `${resolved.threadId} (from ${resolved.source}; internal executor task, opening it in Codex Desktop reports a missing rollout by design)`;
|
|
920
923
|
} catch (err) {
|
|
921
924
|
executor = err.message;
|
|
922
925
|
}
|
|
@@ -943,9 +946,11 @@ if (invokedDirectly) {
|
|
|
943
946
|
);
|
|
944
947
|
|
|
945
948
|
if (!reload.staged) void lifecycle.activate().catch((error) => log(`native relay startup failed: ${error.message}`));
|
|
946
|
-
mcp.server.onclose = () => { void lifecycle.stop(); };
|
|
947
949
|
await mcp.connect(new StdioServerTransport());
|
|
948
|
-
exitWhenClientCloses({ prepare: () => lifecycle.refuseNewDeliveries(), busy: lifecycle.deliveryReason, stop: lifecycle.stop, log });
|
|
950
|
+
const shutdown = exitWhenClientCloses({ prepare: () => lifecycle.refuseNewDeliveries(), busy: lifecycle.deliveryReason, stop: lifecycle.stop, log });
|
|
951
|
+
mcp.server.onclose = () => { void shutdown.close("MCP transport closed"); };
|
|
952
|
+
let signals = 0;
|
|
953
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => { if (++signals > 1) process.exit(1); void shutdown.close(signal); });
|
|
949
954
|
reload.listen();
|
|
950
|
-
log(`ready on ${PLATFORM_LABEL} (${
|
|
955
|
+
log(`ready on ${PLATFORM_LABEL} (${reload.staged ? "relay listeners staged for reload" : `relay listeners starting on ${(strictDesktop ? accountRelay : relay).socketPath}`})`);
|
|
951
956
|
}
|
package/src/native-relay.mjs
CHANGED
|
@@ -774,7 +774,7 @@ export function nativeRelayStatus(env = process.env) {
|
|
|
774
774
|
enabled: false,
|
|
775
775
|
mode,
|
|
776
776
|
socketPath,
|
|
777
|
-
reason: `no companion socket at ${socketPath}
|
|
777
|
+
reason: `no companion socket at ${socketPath}. Install the native relay if it is missing. Codex Desktop runs codex-native-relay only while a task has its MCP servers loaded: open any regular project task (not the internal Native Relay executor task) and send it a turn or reconnect its MCP servers, then retry.`,
|
|
778
778
|
};
|
|
779
779
|
}
|
|
780
780
|
return { enabled: true, mode, socketPath, reason: null };
|
|
@@ -938,7 +938,7 @@ export class NativeDesktopRelay {
|
|
|
938
938
|
}
|
|
939
939
|
}
|
|
940
940
|
|
|
941
|
-
export async function bootstrapRelayThread(client, { cwd = homeDir(), env = process.env, name = "Native Relay" } = {}) {
|
|
941
|
+
export async function bootstrapRelayThread(client, { cwd = homeDir(), env = process.env, name = "Native Relay (internal executor, do not open)" } = {}) {
|
|
942
942
|
const res = await client.call("thread/start", {
|
|
943
943
|
cwd,
|
|
944
944
|
approvalPolicy: "never",
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -594,7 +594,7 @@ const RELOAD_DRAIN_MS = 1000;
|
|
|
594
594
|
* answer on and the conversation stays one-way.
|
|
595
595
|
*/
|
|
596
596
|
export class PeerEndpoint {
|
|
597
|
-
constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {}, env = process.env, protectSocket } = {}) {
|
|
597
|
+
constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {}, env = process.env, protectSocket, signalExit = true } = {}) {
|
|
598
598
|
this.name = name;
|
|
599
599
|
this.cwd = cwd;
|
|
600
600
|
this.log = log;
|
|
@@ -620,6 +620,7 @@ export class PeerEndpoint {
|
|
|
620
620
|
this.reloadPaused = false;
|
|
621
621
|
this.intakeClosed = false;
|
|
622
622
|
this.processHandlersRegistered = false;
|
|
623
|
+
this.signalExit = signalExit;
|
|
623
624
|
this.strict = hardenedBridgeEnabled(env);
|
|
624
625
|
this.protectSocket = protectSocket ?? (hardenedBridgeEnabled(env) ? protectCurrentUserPipe : null);
|
|
625
626
|
this.aclReady = !IS_WINDOWS || !this.protectSocket;
|
|
@@ -728,7 +729,7 @@ export class PeerEndpoint {
|
|
|
728
729
|
fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, ...processIdentity }), { mode: 0o600 });
|
|
729
730
|
|
|
730
731
|
if (!this.processHandlersRegistered) {
|
|
731
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
732
|
+
for (const signal of this.signalExit ? ["SIGINT", "SIGTERM"] : []) {
|
|
732
733
|
process.on(signal, () => {
|
|
733
734
|
this.stop();
|
|
734
735
|
process.exit(0);
|
package/src/platform.mjs
CHANGED
|
@@ -452,26 +452,59 @@ export function isDesktopAppServerRunning() {
|
|
|
452
452
|
}
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
const CODEX_DESKTOP_EXE_WINDOWS = /^[a-z]:\\Program Files\\WindowsApps\\OpenAI\.Codex_[0-9.]+_(?:x64|arm64)__2p2nqsd0c76g0\\app\\ChatGPT\.exe$/i;
|
|
456
|
+
|
|
457
|
+
export function codexDesktopListed(executablePaths) {
|
|
458
|
+
return String(executablePaths ?? "").split(/\r?\n/)
|
|
459
|
+
.some((line) => line.trim() && CODEX_DESKTOP_EXE_WINDOWS.test(path.win32.normalize(line.trim())));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export async function isCodexDesktopRunning({ platform = process.platform, env = process.env, run = execFileAsync, macos = isDesktopAppServerRunning } = {}) {
|
|
463
|
+
if (platform === "darwin") return macos();
|
|
464
|
+
if (platform !== "win32") return false;
|
|
465
|
+
const shell = windowsPowerShell(env);
|
|
466
|
+
try {
|
|
467
|
+
const { stdout } = await run(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
|
|
468
|
+
"Get-Process -Name ChatGPT -ErrorAction SilentlyContinue | ForEach-Object { $_.Path }"], { windowsHide: true, timeout: 5000 });
|
|
469
|
+
return codexDesktopListed(stdout);
|
|
470
|
+
} catch {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
455
475
|
/**
|
|
456
476
|
* Bring a thread to the foreground in the Codex desktop app so a human can
|
|
457
477
|
* watch the turn run instead of only reading the transcript afterwards.
|
|
458
478
|
* Windows registers the `codex://` scheme through the packaged Codex app, and
|
|
459
479
|
* macOS registers it through /Applications/ChatGPT.app.
|
|
460
480
|
*/
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
481
|
+
function windowsPowerShell(env) {
|
|
482
|
+
return path.win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function windowsOpenUrlCommand(url, env) {
|
|
486
|
+
const shell = windowsPowerShell(env);
|
|
487
|
+
const encodedUrl = Buffer.from(url, "utf8").toString("base64");
|
|
488
|
+
const script = `$ErrorActionPreference='Stop'; Start-Process -FilePath ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedUrl}')))`;
|
|
489
|
+
return [shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function openThreadInCodexApp(threadId, { activate = true, platform = process.platform, env = process.env, run = execFileAsync } = {}) {
|
|
493
|
+
if (typeof threadId !== "string" || !threadId) throw new Error("A Codex thread id is required");
|
|
494
|
+
const url = codexThreadUrl(encodeURIComponent(threadId));
|
|
495
|
+
if (platform === "win32") {
|
|
496
|
+
const [shell, args] = windowsOpenUrlCommand(url, env);
|
|
497
|
+
await run(shell, args, { windowsHide: true, timeout: 10000 });
|
|
465
498
|
return url;
|
|
466
499
|
}
|
|
467
|
-
if (
|
|
500
|
+
if (platform !== "darwin") {
|
|
468
501
|
throw new Error(`Opening a Codex thread in the desktop app is supported on Windows and macOS. Open ${url} manually.`);
|
|
469
502
|
}
|
|
470
503
|
if (!hasCodexDesktopApp()) {
|
|
471
504
|
throw new Error(`Codex desktop app not found at ${CODEX_DESKTOP_APP_MACOS}. Install it to use ${url}.`);
|
|
472
505
|
}
|
|
473
506
|
const args = activate ? [url] : ["-g", url];
|
|
474
|
-
await
|
|
507
|
+
await run("open", args, { timeout: 10000 });
|
|
475
508
|
return url;
|
|
476
509
|
}
|
|
477
510
|
|
|
@@ -484,7 +517,7 @@ export async function openClaudeCodeComposer(url, { platform = process.platform,
|
|
|
484
517
|
throw new Error("Invalid Claude Desktop Code creation link");
|
|
485
518
|
}
|
|
486
519
|
if (platform === "win32") {
|
|
487
|
-
const shell =
|
|
520
|
+
const shell = windowsPowerShell(env);
|
|
488
521
|
const encodedUrl = Buffer.from(url, "utf8").toString("base64");
|
|
489
522
|
const script = String.raw`$ErrorActionPreference='Stop'; $creationUrl=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedUrl}')); try { Start-Process -FilePath $creationUrl -ErrorAction Stop } catch { $desktopPaths=@(Get-Process claude -ErrorAction SilentlyContinue | ForEach-Object { $_.Path } | Where-Object { $_ -match '\\(?:AnthropicClaude\\app-[^\\]+|WindowsApps\\Claude_[^\\]+\\app)\\claude\.exe$' -and (Test-Path -LiteralPath $_) } | Select-Object -Unique); if ($desktopPaths.Count -eq 0 -and $env:LOCALAPPDATA) { $desktopPaths=@(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'AnthropicClaude') -Directory -Filter 'app-*' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | ForEach-Object { Join-Path $_.FullName 'claude.exe' } | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1) }; if ($desktopPaths.Count -ne 1) { throw }; Start-Process -FilePath $desktopPaths[0] -ArgumentList ('"'+$creationUrl+'"') -WindowStyle Hidden -ErrorAction Stop }`;
|
|
490
523
|
await run(shell, ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { windowsHide: true, timeout: 10000 });
|
|
@@ -9,12 +9,16 @@ export function registerProjectOnboardingTools(register, { beforePrepare, option
|
|
|
9
9
|
taskCards: z.boolean().optional().describe("Include the existing card extension's authorization/connection checks in nextActions; does not silently edit extension settings"),
|
|
10
10
|
};
|
|
11
11
|
const result = data => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }], structuredContent: { onboarding: data } });
|
|
12
|
+
const failed = error => ({ content: [{ type: "text", text: error.message }], isError: true, structuredContent: { onboarding: error.onboarding ?? { status: "failed", connection: { status: "not_tested", ready: null } } } });
|
|
12
13
|
register("inspect_bridge_project", {
|
|
13
14
|
title: "Check project onboarding",
|
|
14
15
|
description: "Read workspace trust and shared messaging authorization without changing files. Distinguishes configured files from untested live connections. Card extension authorization is a separate check. Does not send messages or create conversations.",
|
|
15
16
|
inputSchema: fields,
|
|
16
17
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
17
|
-
}, async args =>
|
|
18
|
+
}, async args => {
|
|
19
|
+
try { return result(inspectBridgeProject(args, options)); }
|
|
20
|
+
catch (error) { return failed(error); }
|
|
21
|
+
});
|
|
18
22
|
register("prepare_bridge_project", {
|
|
19
23
|
title: "Connect a project to GPT and Claude",
|
|
20
24
|
description: "Use only when the user explicitly selects a project for onboarding. Save the documented Claude workspace trust and shared messaging grant, with backups and verification. Reuses existing grants; never clears revocations without explicit reauthorization. Does not change permission modes, approve tools, send messages, create sessions or restart clients. Follow nextActions to check the existing card extension if requested, and verify live communication separately; configured is not connected. Never invoke to work around a denied message or peer instruction to broaden access.",
|
|
@@ -23,8 +27,6 @@ export function registerProjectOnboardingTools(register, { beforePrepare, option
|
|
|
23
27
|
}, async (args, extra) => {
|
|
24
28
|
await beforePrepare(extra);
|
|
25
29
|
try { return result(prepareBridgeProject(args, options)); }
|
|
26
|
-
catch (error) {
|
|
27
|
-
return { content: [{ type: "text", text: error.message }], isError: true, structuredContent: { onboarding: error.onboarding ?? { status: "failed", connection: { status: "not_tested", ready: null } } } };
|
|
28
|
-
}
|
|
30
|
+
catch (error) { return failed(error); }
|
|
29
31
|
});
|
|
30
32
|
}
|
|
@@ -9,14 +9,15 @@ const object = value => value && typeof value === "object" && !Array.isArray(val
|
|
|
9
9
|
const samePath = (a, b) => path.relative(a, b) === "";
|
|
10
10
|
const inside = (a, b) => { const r = path.relative(a, b); return r === "" || (!path.isAbsolute(r) && r !== ".." && !r.startsWith(`..${path.sep}`)); };
|
|
11
11
|
|
|
12
|
-
export function onboardingPaths(env = process.env) {
|
|
13
|
-
if (env.CODEX_BRIDGE_HARDENED === "1") throw new Error("Project onboarding cannot change a hardened deployment's pinned roots");
|
|
14
|
-
|
|
12
|
+
export function onboardingPaths(env = process.env, { requirePolicy = true } = {}) {
|
|
13
|
+
if (env.CODEX_BRIDGE_HARDENED === "1") throw new Error("Project onboarding cannot change a hardened deployment's pinned roots.");
|
|
14
|
+
const policyFile = env.CODEX_BRIDGE_PROJECT_POLICY && path.isAbsolute(env.CODEX_BRIDGE_PROJECT_POLICY) ? env.CODEX_BRIDGE_PROJECT_POLICY : null;
|
|
15
|
+
if (!policyFile && requirePolicy) throw new Error("Configure the shared CODEX_BRIDGE_PROJECT_POLICY before onboarding projects.");
|
|
15
16
|
const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
16
17
|
const configFile = env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(home, ".claude.json");
|
|
17
|
-
if (!path.isAbsolute(configFile)) throw new Error("Claude configuration directory must be absolute");
|
|
18
|
-
if (samePath(configFile,
|
|
19
|
-
return { configFile, policyFile
|
|
18
|
+
if (!path.isAbsolute(configFile)) throw new Error("Claude configuration directory must be absolute.");
|
|
19
|
+
if (policyFile && samePath(configFile, policyFile)) throw new Error("Trust configuration and project policy must be separate files.");
|
|
20
|
+
return { configFile, policyFile, home };
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function read(file, maxBytes = MAX_CONFIG_BYTES) {
|
|
@@ -58,17 +59,41 @@ function denied(policy, project) {
|
|
|
58
59
|
|| (d.repository && project.repo && samePath(d.repository.path, project.repo.common.path)));
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function
|
|
62
|
-
const
|
|
62
|
+
function loadPolicy(file, project, options, tolerant) {
|
|
63
|
+
const unavailable = problem => ({ policyRaw: null, policy: { version: 1, grants: [], denies: [] }, denials: [], authorized: false, policyProblem: problem });
|
|
64
|
+
if (!file) return unavailable({ status: "policy_not_configured", detail: "CODEX_BRIDGE_PROJECT_POLICY is not set to an absolute file in this bridge process." });
|
|
65
|
+
try {
|
|
66
|
+
const policyRaw = read(file, 256 * 1024);
|
|
67
|
+
const policy = policyRaw === null ? { version: 1, grants: [], denies: [] } : parseProjectPolicy(policyRaw).policy;
|
|
68
|
+
const authorized = policyRaw !== null && createProjectScope(file, options).allows(project.directory.path);
|
|
69
|
+
return { policyRaw, policy, denials: denied(policy, project), authorized, policyProblem: null };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!tolerant) throw error;
|
|
72
|
+
return unavailable({ status: "policy_invalid", detail: error.message });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function load(cwd, options, { inspect = false } = {}) {
|
|
77
|
+
const files = onboardingPaths(options.env, { requirePolicy: !inspect });
|
|
63
78
|
const project = target(cwd, { ...options, home: files.home });
|
|
64
79
|
const configRaw = read(files.configFile);
|
|
65
80
|
const config = parse(configRaw);
|
|
66
81
|
const trust = trustEntry(config, project);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
return { files, project, configRaw, config, trust, policyRegistration: options.policyRegistration, ...loadPolicy(files.policyFile, project, options, inspect) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const POLICY_REGISTRATION = {
|
|
86
|
+
"claude-code": file => `register that file with codex-bridge-code-install --policy "${file}" (review the plan, then add --apply --clients-stopped), and reconnect this MCP server`,
|
|
87
|
+
codex: file => `register that file by running claude-mcp-bridge-install with CODEX_BRIDGE_PROJECT_POLICY="${file}" set, then restart Codex Desktop once so this MCP server loads it`,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function policyActions(state) {
|
|
91
|
+
if (!state.policyProblem) return [{ tool: "prepare_bridge_project", cwd: state.project.directory.path }];
|
|
92
|
+
const suggestedFile = path.join(state.files.home, ".config", "GptClaudeBridge", "projects.json");
|
|
93
|
+
if (state.policyProblem.status === "policy_invalid") return [{ setting: "CODEX_BRIDGE_PROJECT_POLICY", file: state.files.policyFile, then: "Repair or restore this policy file from a backup; prepare_bridge_project does not overwrite an unreadable policy. Run inspect_bridge_project again afterwards." }];
|
|
94
|
+
const registration = POLICY_REGISTRATION[state.policyRegistration ?? "claude-code"];
|
|
95
|
+
if (!registration) throw new Error(`Unknown project policy registration: ${state.policyRegistration}`);
|
|
96
|
+
return [{ setting: "CODEX_BRIDGE_PROJECT_POLICY", suggestedFile, then: `Run bridge-projects prepare "${state.project.directory.path}" --file "${suggestedFile}", ${registration(suggestedFile)}. prepare_bridge_project is available once the setting is loaded.` }];
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
function report(state, taskCards, extra = {}) {
|
|
@@ -77,11 +102,13 @@ function report(state, taskCards, extra = {}) {
|
|
|
77
102
|
status: configured ? "configured" : "needs_setup",
|
|
78
103
|
project: { cwd: state.project.directory.path, root: state.project.root.path, kind: state.project.repo ? "git" : "directory", repository: state.project.repo?.common.path ?? null },
|
|
79
104
|
workspaceTrust: { status: state.trust.trusted ? "configured" : "missing", file: state.files.configFile, key: state.trust.key },
|
|
80
|
-
communicationAuthorization:
|
|
105
|
+
communicationAuthorization: state.policyProblem
|
|
106
|
+
? { ...state.policyProblem, file: state.files.policyFile }
|
|
107
|
+
: { status: state.authorized ? "authorized" : state.denials.length ? "revoked" : "missing", file: state.files.policyFile },
|
|
81
108
|
connection: { status: "not_tested", ready: null, detail: "Saved configuration does not prove a live sender, target, tool permission or successful round trip." },
|
|
82
109
|
taskCards: { status: taskCards ? "requires_extension_check" : "not_requested", detail: "The card extension owns its authorization and connection; this tool does not edit its settings or restart it." },
|
|
83
110
|
nextActions: [
|
|
84
|
-
...(!configured ?
|
|
111
|
+
...(!configured ? policyActions(state) : []),
|
|
85
112
|
...(taskCards ? [{ component: "Local Card Bridge Desktop", tool: "list_projects", cwd: state.project.root.path, then: "If absent, use authorize_project for this exact project under the user's onboarding request; verify desktop_bridge_info after saving. Stop on any denial or disconnected extension." }] : []),
|
|
86
113
|
{ tools: ["claude_bridge_status", "codex_bridge_status"], then: "Check in the actual calling sessions, then perform one authorized round trip in each direction. Do not create a new conversation without user authorization." },
|
|
87
114
|
],
|
|
@@ -90,7 +117,7 @@ function report(state, taskCards, extra = {}) {
|
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
export function inspectBridgeProject({ cwd, taskCards = false }, options = {}) {
|
|
93
|
-
return report(load(cwd, options), taskCards);
|
|
120
|
+
return report(load(cwd, options, { inspect: true }), taskCards);
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
// Two small configuration files, not a fake 'connected' flag. Preserve full backups,
|