@minhspark/codex-mcp-bridge 1.11.3 → 1.12.1
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/CHANGELOG.md +76 -0
- package/README.md +100 -11
- package/package.json +7 -3
- package/scripts/install-native-relay.mjs +100 -0
- package/scripts/sync-version.mjs +5 -1
- package/src/claude-bridge.mjs +19 -16
- package/src/index.mjs +4 -2
- package/src/native-relay-companion.mjs +343 -0
- package/src/native-relay.mjs +327 -0
- package/src/peer-protocol.mjs +76 -22
- package/src/platform.mjs +20 -1
- package/src/thread-delivery.mjs +85 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
MAX_FRAME_BYTES,
|
|
13
|
+
NATIVE_DISPATCH_METHOD,
|
|
14
|
+
RELAY_PROTOCOL_VERSION,
|
|
15
|
+
nativeDispatchParams,
|
|
16
|
+
relaySocketPath,
|
|
17
|
+
resolveRelayThreadId,
|
|
18
|
+
} from "./native-relay.mjs";
|
|
19
|
+
import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The companion half of the Codex Desktop native relay.
|
|
23
|
+
*
|
|
24
|
+
* Codex Desktop launches this as one of its own MCP servers, so the connection
|
|
25
|
+
* it answers on belongs to the app's real app-server - the one already holding
|
|
26
|
+
* the writer lock of every thread the human has open. Asking that app-server to
|
|
27
|
+
* deliver a message is therefore not a second writer, and the thread stays open
|
|
28
|
+
* and owned by Codex Desktop throughout.
|
|
29
|
+
*
|
|
30
|
+
* Everything else is deliberately small: a private socket, one accepted shape
|
|
31
|
+
* (`{ targetThreadId, message }`), one dispatch, one acknowledgement.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const VERSION = "1.12.1";
|
|
35
|
+
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
36
|
+
|
|
37
|
+
function errorResponse(code, message) {
|
|
38
|
+
return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A JSON-RPC error arrives with a numeric code, and passing that straight back
|
|
43
|
+
* would put `-32601` in a field whose other values read `RELAY_TIMEOUT`. Only a
|
|
44
|
+
* string code from this project's own errors is carried through.
|
|
45
|
+
*/
|
|
46
|
+
function errorCode(err) {
|
|
47
|
+
return typeof err?.code === "string" ? err.code : "NATIVE_DISPATCH_FAILED";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The whole request handler, kept free of sockets and of the MCP connection so
|
|
52
|
+
* the rules it enforces can be tested against a stub dispatcher rather than
|
|
53
|
+
* against a running Codex Desktop.
|
|
54
|
+
*/
|
|
55
|
+
export async function handleRelayRequest(
|
|
56
|
+
payload,
|
|
57
|
+
{ dispatch, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
|
|
58
|
+
) {
|
|
59
|
+
const targetThreadId = typeof payload?.targetThreadId === "string" ? payload.targetThreadId.trim() : "";
|
|
60
|
+
const message = typeof payload?.message === "string" ? payload.message : "";
|
|
61
|
+
|
|
62
|
+
if (!targetThreadId) return errorResponse("RELAY_BAD_REQUEST", "targetThreadId must be a non-empty string");
|
|
63
|
+
if (!message.trim()) return errorResponse("RELAY_BAD_REQUEST", "message must be a non-empty string");
|
|
64
|
+
|
|
65
|
+
let executorThreadId;
|
|
66
|
+
try {
|
|
67
|
+
executorThreadId = resolveExecutor(env).threadId;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return errorResponse(errorCode(err), err.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Codex validates the executor thread, so a destination that is also the
|
|
74
|
+
* executor would be dispatched rather than refused - and the message would
|
|
75
|
+
* land in the relay thread instead of the thread the human is watching.
|
|
76
|
+
* Nothing downstream can tell those two apart afterwards.
|
|
77
|
+
*/
|
|
78
|
+
if (executorThreadId === targetThreadId) {
|
|
79
|
+
return errorResponse(
|
|
80
|
+
"RELAY_BAD_REQUEST",
|
|
81
|
+
`${targetThreadId} is the relay's own executor thread, not a destination. Bind the thread you are watching in Codex Desktop.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result = await dispatch({ executorThreadId, targetThreadId, message });
|
|
87
|
+
return { ok: true, v: RELAY_PROTOCOL_VERSION, targetThreadId, executorThreadId, result: result ?? null };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return errorResponse(errorCode(err), err?.message ?? String(err));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Listens on a private local socket or Windows named pipe and answers one NDJSON line per request.
|
|
95
|
+
*
|
|
96
|
+
* POSIX sockets are mode 0600 inside the Codex home directory. Windows uses the
|
|
97
|
+
* Claude-compatible local named-pipe namespace instead of a filesystem mode.
|
|
98
|
+
*/
|
|
99
|
+
export class RelaySocketServer {
|
|
100
|
+
constructor({
|
|
101
|
+
socketPath,
|
|
102
|
+
dispatch,
|
|
103
|
+
resolveExecutor = resolveRelayThreadId,
|
|
104
|
+
restrictSocket = (target) => {
|
|
105
|
+
if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
|
|
106
|
+
},
|
|
107
|
+
log: logFn = () => {},
|
|
108
|
+
} = {}) {
|
|
109
|
+
this.socketPath = socketPath;
|
|
110
|
+
this.dispatch = dispatch;
|
|
111
|
+
this.resolveExecutor = resolveExecutor;
|
|
112
|
+
this.restrictSocket = restrictSocket;
|
|
113
|
+
this.log = logFn;
|
|
114
|
+
this.server = null;
|
|
115
|
+
this.started = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async start() {
|
|
119
|
+
if (this.started) return this.socketPath;
|
|
120
|
+
if (!IS_WINDOWS) fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
|
|
121
|
+
|
|
122
|
+
this.server = net.createServer((socket) => this.#handleConnection(socket));
|
|
123
|
+
await this.#listen({ replaceStale: true });
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The file mode is the whole security boundary, so a socket whose mode
|
|
127
|
+
* could not be set is not a degraded relay - it is an open one. Refuse it
|
|
128
|
+
* and let the caller fall back to the app-server path, rather than serving
|
|
129
|
+
* thread writes on an address anyone can open.
|
|
130
|
+
*/
|
|
131
|
+
try {
|
|
132
|
+
this.restrictSocket(this.socketPath);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
try {
|
|
135
|
+
this.server.close();
|
|
136
|
+
} catch {}
|
|
137
|
+
throw new Error(`refusing to serve on ${this.socketPath}: its mode could not be restricted (${err.message})`);
|
|
138
|
+
}
|
|
139
|
+
this.started = true;
|
|
140
|
+
|
|
141
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
142
|
+
process.on(signal, () => {
|
|
143
|
+
this.stop();
|
|
144
|
+
process.exit(0);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
process.on("exit", () => this.stop());
|
|
148
|
+
|
|
149
|
+
this.log(`relay socket listening on ${this.socketPath}`);
|
|
150
|
+
return this.socketPath;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A companion killed with SIGKILL leaves its socket file behind, and the next
|
|
155
|
+
* one then fails to bind a path nothing is listening on. Removing it blindly
|
|
156
|
+
* would be worse: Codex Desktop can launch more than one companion, and the
|
|
157
|
+
* second would silently steal the address from the first. So an in-use path
|
|
158
|
+
* is probed - a refused connection means the owner is gone and the file is
|
|
159
|
+
* swept, an accepted one means a live companion already has the socket and
|
|
160
|
+
* this process leaves it alone.
|
|
161
|
+
*/
|
|
162
|
+
async #listen({ replaceStale }) {
|
|
163
|
+
try {
|
|
164
|
+
await new Promise((resolve, reject) => {
|
|
165
|
+
this.server.once("error", reject);
|
|
166
|
+
this.server.listen(this.socketPath, () => {
|
|
167
|
+
this.server.off("error", reject);
|
|
168
|
+
resolve();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err.code !== "EADDRINUSE" || !replaceStale) throw err;
|
|
173
|
+
if (IS_WINDOWS) {
|
|
174
|
+
if (await this.#socketIsLive()) {
|
|
175
|
+
throw new Error(`another native relay companion already owns ${this.socketPath}`);
|
|
176
|
+
}
|
|
177
|
+
await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
|
|
178
|
+
return this.#listen({ replaceStale: false });
|
|
179
|
+
}
|
|
180
|
+
if (await this.#socketIsLive()) {
|
|
181
|
+
throw new Error(`another native relay companion already owns ${this.socketPath}`);
|
|
182
|
+
}
|
|
183
|
+
this.log(`removing the stale relay socket left at ${this.socketPath}`);
|
|
184
|
+
fs.rmSync(this.socketPath, { force: true });
|
|
185
|
+
await this.#listen({ replaceStale: false });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#socketIsLive() {
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
const probe = net.connect({ path: this.socketPath });
|
|
192
|
+
const timer = globalThis.setTimeout(() => done(false), 1000);
|
|
193
|
+
const done = (answer) => {
|
|
194
|
+
globalThis.clearTimeout(timer);
|
|
195
|
+
probe.destroy();
|
|
196
|
+
resolve(answer);
|
|
197
|
+
};
|
|
198
|
+
probe.on("connect", () => done(true));
|
|
199
|
+
probe.on("error", () => done(false));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#handleConnection(socket) {
|
|
204
|
+
let buffer = "";
|
|
205
|
+
socket.on("error", (err) => this.log(`relay socket error: ${err.message}`));
|
|
206
|
+
socket.on("data", (chunk) => {
|
|
207
|
+
buffer += chunk.toString("utf8");
|
|
208
|
+
if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
209
|
+
this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
|
|
210
|
+
socket.destroy();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
let index;
|
|
214
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
215
|
+
const line = buffer.slice(0, index).trim();
|
|
216
|
+
buffer = buffer.slice(index + 1);
|
|
217
|
+
if (!line) continue;
|
|
218
|
+
void this.#handleLine(socket, line);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async #handleLine(socket, line) {
|
|
224
|
+
let payload;
|
|
225
|
+
try {
|
|
226
|
+
payload = JSON.parse(line);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
this.#reply(socket, errorResponse("RELAY_BAD_REQUEST", `malformed JSON: ${err.message}`));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const response = await handleRelayRequest(payload, {
|
|
232
|
+
dispatch: this.dispatch,
|
|
233
|
+
resolveExecutor: this.resolveExecutor,
|
|
234
|
+
});
|
|
235
|
+
if (!response.ok) this.log(`relay refused ${payload?.targetThreadId ?? "?"}: ${response.error.message}`);
|
|
236
|
+
else this.log(`relayed a message into thread ${response.targetThreadId}`);
|
|
237
|
+
this.#reply(socket, response);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#reply(socket, response) {
|
|
241
|
+
if (socket.destroyed) return;
|
|
242
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
stop() {
|
|
246
|
+
try {
|
|
247
|
+
this.server?.close();
|
|
248
|
+
} catch {}
|
|
249
|
+
try {
|
|
250
|
+
if (this.started && !IS_WINDOWS) fs.rmSync(this.socketPath, { force: true });
|
|
251
|
+
} catch {}
|
|
252
|
+
this.started = false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* `import.meta.main` is Node 24 and up, and this project supports Node 22, so
|
|
258
|
+
* the entry point is detected by comparing the resolved argv path instead.
|
|
259
|
+
*/
|
|
260
|
+
const invokedDirectly =
|
|
261
|
+
Boolean(process.argv[1]) && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
262
|
+
|
|
263
|
+
if (invokedDirectly) {
|
|
264
|
+
const mcp = new McpServer(
|
|
265
|
+
{ name: "codex-native-relay", version: VERSION },
|
|
266
|
+
{
|
|
267
|
+
instructions:
|
|
268
|
+
"Companion process for the Codex Desktop native relay. It carries no work of its own: it accepts " +
|
|
269
|
+
"messages from claude-bridge on a private local socket and asks the Codex Desktop app-server that " +
|
|
270
|
+
"launched it to deliver them into an already-open thread, so that thread keeps its writer lock.",
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The dispatch goes back over the very connection Codex Desktop opened to
|
|
276
|
+
* launch this process, which is what keeps the app the single writer. Sent as
|
|
277
|
+
* a plain JSON-RPC request rather than through a typed helper because the
|
|
278
|
+
* method is an internal of the app, not part of the MCP specification.
|
|
279
|
+
*/
|
|
280
|
+
const dispatch = ({ executorThreadId, targetThreadId, message }) =>
|
|
281
|
+
mcp.server.request(
|
|
282
|
+
{
|
|
283
|
+
method: process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
|
|
284
|
+
params: nativeDispatchParams({ executorThreadId, targetThreadId, message }),
|
|
285
|
+
},
|
|
286
|
+
z.any(),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
|
|
290
|
+
|
|
291
|
+
mcp.registerTool(
|
|
292
|
+
"native_relay_status",
|
|
293
|
+
{
|
|
294
|
+
title: "Check the Codex Desktop native relay",
|
|
295
|
+
description:
|
|
296
|
+
"Report the local socket this companion listens on, the executor thread it dispatches through, and " +
|
|
297
|
+
"whether the relay is ready to deliver messages into threads Codex Desktop has open.",
|
|
298
|
+
inputSchema: {},
|
|
299
|
+
annotations: {
|
|
300
|
+
readOnlyHint: true,
|
|
301
|
+
openWorldHint: false,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
async () => {
|
|
305
|
+
let executor = "(unconfigured)";
|
|
306
|
+
try {
|
|
307
|
+
const resolved = resolveRelayThreadId();
|
|
308
|
+
executor = `${resolved.threadId} (from ${resolved.source})`;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
executor = err.message;
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
content: [
|
|
314
|
+
{
|
|
315
|
+
type: "text",
|
|
316
|
+
text: [
|
|
317
|
+
`platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
|
|
318
|
+
`companion: codex-native-relay ${VERSION}`,
|
|
319
|
+
`relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
|
|
320
|
+
`executor: ${executor}`,
|
|
321
|
+
`dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
|
|
322
|
+
].join("\n"),
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Never let the socket take the MCP server down. Codex Desktop waits on the
|
|
331
|
+
* `initialize` handshake, so a process that dies before answering reads as a
|
|
332
|
+
* hang rather than an error - the same failure mode `claude-bridge` already
|
|
333
|
+
* guards its peer endpoint against.
|
|
334
|
+
*/
|
|
335
|
+
try {
|
|
336
|
+
await relay.start();
|
|
337
|
+
} catch (err) {
|
|
338
|
+
log(`relay socket unavailable (${err.message}) - claude-bridge will fall back to the app-server path`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await mcp.connect(new StdioServerTransport());
|
|
342
|
+
log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
|
|
343
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Codex Desktop app owns the per-thread writer lock of every thread it has
|
|
9
|
+
* open, and it keeps it for as long as the thread is open. Anything that wants
|
|
10
|
+
* to write into such a thread by attaching a second app-server loses: the
|
|
11
|
+
* app-server answers `thread <id> already has an active writer`. Closing the
|
|
12
|
+
* thread first is not an answer either, because the whole point of binding a
|
|
13
|
+
* thread is that the human keeps watching it in Codex Desktop.
|
|
14
|
+
*
|
|
15
|
+
* The way through is to stop bringing a second writer. A companion MCP process
|
|
16
|
+
* launched by Codex Desktop's own app-server already sits inside the app's
|
|
17
|
+
* context, so it can ask that app-server to deliver the message on the app's
|
|
18
|
+
* behalf. No resume, no attach, no second app-server, no lock to fight over.
|
|
19
|
+
*
|
|
20
|
+
* This module is the client half - the part `claude-bridge` talks to. The
|
|
21
|
+
* companion half lives in `native-relay-companion.mjs`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Measured, not documented. `codex_app.send_message_to_thread` is an internal
|
|
26
|
+
* of the Codex Desktop native tools pipe, on the same footing as the Claude
|
|
27
|
+
* peer protocol in `peer-protocol.mjs`: it works today and carries no public
|
|
28
|
+
* contract. When Codex changes it, this constant and `nativeDispatchParams`
|
|
29
|
+
* below are the two places to fix, and `CODEX_NATIVE_RELAY_METHOD` overrides
|
|
30
|
+
* the name without a release.
|
|
31
|
+
*/
|
|
32
|
+
export const NATIVE_DISPATCH_METHOD = "codex_app.send_message_to_thread";
|
|
33
|
+
|
|
34
|
+
export const RELAY_PROTOCOL_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A relay frame carries one chat message, so a megabyte-scale line is either a
|
|
38
|
+
* bug or something trying to make the companion buffer without limit. The cap
|
|
39
|
+
* is applied on both halves: the client refuses to send an oversized message,
|
|
40
|
+
* and the companion refuses to accumulate one.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_FRAME_BYTES = 128 * 1024;
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
45
|
+
const RELAY_SOCKET_NAME = "native-relay.sock";
|
|
46
|
+
const WINDOWS_RELAY_SOCKET = "\\\\.\\pipe\\LOCAL\\codex-native-relay";
|
|
47
|
+
const RELAY_CONFIG_NAME = "native-relay.json";
|
|
48
|
+
|
|
49
|
+
export class NativeRelayError extends Error {
|
|
50
|
+
/**
|
|
51
|
+
* `reachedCompanion` is what decides whether falling back to the app-server
|
|
52
|
+
* path is worth doing. A companion that never answered says nothing about
|
|
53
|
+
* the target thread, so the older path deserves its turn; a companion that
|
|
54
|
+
* answered with a refusal has already asked Codex, and asking again through
|
|
55
|
+
* a second app-server only adds a writer-lock failure on top.
|
|
56
|
+
*/
|
|
57
|
+
constructor(message, code, { reachedCompanion = false } = {}) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "NativeRelayError";
|
|
60
|
+
this.code = code;
|
|
61
|
+
this.reachedCompanion = reachedCompanion;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function codexHome(env = process.env) {
|
|
66
|
+
return env.CODEX_HOME ?? path.join(homeDir(), ".codex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function relaySocketPath(env = process.env) {
|
|
70
|
+
return env.CODEX_NATIVE_RELAY_SOCKET ?? (IS_WINDOWS ? WINDOWS_RELAY_SOCKET : path.join(codexHome(env), RELAY_SOCKET_NAME));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function relayConfigPath(env = process.env) {
|
|
74
|
+
return path.join(codexHome(env), RELAY_CONFIG_NAME);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isSocketFile(target) {
|
|
78
|
+
try {
|
|
79
|
+
return fs.statSync(target).isSocket();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isRelayEndpoint(target) {
|
|
86
|
+
if (IS_WINDOWS) {
|
|
87
|
+
return typeof target === "string" && target.toLowerCase().startsWith("\\\\.\\pipe\\") && fs.existsSync(target);
|
|
88
|
+
}
|
|
89
|
+
return isSocketFile(target);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readRelayConfig(env = process.env) {
|
|
93
|
+
const file = relayConfigPath(env);
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
96
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `codex_app.send_message_to_thread` runs against an executor thread, which has
|
|
104
|
+
* to be a real Codex thread and is not the destination: a synthetic UUID is
|
|
105
|
+
* rejected with `NATIVE_DISPATCH_FAILED`, so the id cannot be invented at call
|
|
106
|
+
* time. A thread dedicated to the relay keeps that requirement off the
|
|
107
|
+
* destination thread, which stays open in Codex Desktop and untouched.
|
|
108
|
+
*
|
|
109
|
+
* The order is deliberate: an explicit `CODEX_RELAY_ID` wins so a single run
|
|
110
|
+
* can be pointed elsewhere without editing state, then the id bootstrapped
|
|
111
|
+
* once into `~/.codex/native-relay.json`, and then an error. Never a guess -
|
|
112
|
+
* an invented executor fails inside Codex with a message that says nothing
|
|
113
|
+
* about the missing configuration that actually caused it.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveRelayThreadId(env = process.env) {
|
|
116
|
+
const explicit = env.CODEX_RELAY_ID?.trim();
|
|
117
|
+
if (explicit) return { threadId: explicit, source: "CODEX_RELAY_ID" };
|
|
118
|
+
|
|
119
|
+
const persisted = readRelayConfig(env)?.relayThreadId;
|
|
120
|
+
if (typeof persisted === "string" && persisted.trim()) {
|
|
121
|
+
return { threadId: persisted.trim(), source: relayConfigPath(env) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new NativeRelayError(
|
|
125
|
+
`No Codex relay thread is configured. Set CODEX_RELAY_ID, or bootstrap one into ${relayConfigPath(env)} ` +
|
|
126
|
+
"with: node scripts/install-native-relay.mjs",
|
|
127
|
+
"RELAY_THREAD_UNCONFIGURED",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Builds the parameters of the native dispatch. Kept apart from the transport
|
|
133
|
+
* so the one shape this project cannot verify against a published schema sits
|
|
134
|
+
* in a single named function with a single test, rather than inline in the
|
|
135
|
+
* middle of a request.
|
|
136
|
+
*/
|
|
137
|
+
export function nativeDispatchParams({ executorThreadId, targetThreadId, message }) {
|
|
138
|
+
return { executorThreadId, threadId: targetThreadId, message };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether the native path is usable right now, and when it is not, why. The
|
|
143
|
+
* reason is carried rather than dropped because "the relay did nothing" is the
|
|
144
|
+
* one answer nobody can act on: a missing companion socket, an unsupported
|
|
145
|
+
* platform and an operator switching the backend off all look identical from
|
|
146
|
+
* the outside, and they need three different responses.
|
|
147
|
+
*/
|
|
148
|
+
export function nativeRelayStatus(env = process.env) {
|
|
149
|
+
const mode = (env.CODEX_BRIDGE_NATIVE_RELAY ?? "auto").toLowerCase();
|
|
150
|
+
const socketPath = relaySocketPath(env);
|
|
151
|
+
|
|
152
|
+
if (mode === "0" || mode === "off") {
|
|
153
|
+
return { enabled: false, mode, socketPath, reason: "disabled by CODEX_BRIDGE_NATIVE_RELAY=0" };
|
|
154
|
+
}
|
|
155
|
+
const forced = mode === "1" || mode === "on";
|
|
156
|
+
if (!IS_MACOS && !IS_WINDOWS && !forced) {
|
|
157
|
+
return {
|
|
158
|
+
enabled: false,
|
|
159
|
+
mode,
|
|
160
|
+
socketPath,
|
|
161
|
+
reason: `the Codex Desktop native relay is unavailable on ${PLATFORM_LABEL}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!isRelayEndpoint(socketPath)) {
|
|
165
|
+
return {
|
|
166
|
+
enabled: false,
|
|
167
|
+
mode,
|
|
168
|
+
socketPath,
|
|
169
|
+
reason: `no companion socket at ${socketPath} - is the native relay installed and Codex Desktop running?`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { enabled: true, mode, socketPath, reason: null };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Speaks one request per connection to the companion: a single NDJSON line out,
|
|
177
|
+
* a single NDJSON line back. Connections are not pooled - a relayed message is
|
|
178
|
+
* a rare event bounded by the bridge's own ping-pong guard, and a short-lived
|
|
179
|
+
* socket cannot go stale while Codex Desktop restarts underneath it.
|
|
180
|
+
*/
|
|
181
|
+
export class NativeDesktopRelay {
|
|
182
|
+
constructor({ env = process.env, socketPath = null, timeoutMs = DEFAULT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
183
|
+
this.env = env;
|
|
184
|
+
this.explicitSocketPath = socketPath;
|
|
185
|
+
this.timeoutMs = timeoutMs;
|
|
186
|
+
this.log = log;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get socketPath() {
|
|
190
|
+
return this.explicitSocketPath ?? relaySocketPath(this.env);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
status() {
|
|
194
|
+
if (!this.explicitSocketPath) return nativeRelayStatus(this.env);
|
|
195
|
+
const status = nativeRelayStatus({ ...this.env, CODEX_NATIVE_RELAY_SOCKET: this.explicitSocketPath });
|
|
196
|
+
return { ...status, socketPath: this.explicitSocketPath };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get available() {
|
|
200
|
+
return this.status().enabled;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs } = {}) {
|
|
204
|
+
const request = { v: RELAY_PROTOCOL_VERSION, targetThreadId, message };
|
|
205
|
+
const line = `${JSON.stringify(request)}\n`;
|
|
206
|
+
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
|
|
207
|
+
throw new NativeRelayError(
|
|
208
|
+
`Relay message is larger than ${MAX_FRAME_BYTES} bytes; shorten it before relaying.`,
|
|
209
|
+
"RELAY_MESSAGE_TOO_LARGE",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const response = await this.#roundTrip(line, timeoutMs);
|
|
214
|
+
if (response?.ok) return response;
|
|
215
|
+
throw new NativeRelayError(
|
|
216
|
+
response?.error?.message ?? "the Codex Desktop relay refused the message",
|
|
217
|
+
response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
|
|
218
|
+
{ reachedCompanion: true },
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#roundTrip(line, timeoutMs) {
|
|
223
|
+
const socketPath = this.socketPath;
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
let buffer = "";
|
|
226
|
+
let settled = false;
|
|
227
|
+
const socket = net.connect({ path: socketPath });
|
|
228
|
+
|
|
229
|
+
const finish = (fn, value) => {
|
|
230
|
+
if (settled) return;
|
|
231
|
+
settled = true;
|
|
232
|
+
globalThis.clearTimeout(timer);
|
|
233
|
+
socket.destroy();
|
|
234
|
+
fn(value);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const timer = globalThis.setTimeout(
|
|
238
|
+
() =>
|
|
239
|
+
finish(
|
|
240
|
+
reject,
|
|
241
|
+
new NativeRelayError(
|
|
242
|
+
`The Codex Desktop relay did not answer within ${timeoutMs}ms`,
|
|
243
|
+
"RELAY_TIMEOUT",
|
|
244
|
+
{ reachedCompanion: true },
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
timeoutMs,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
socket.on("connect", () => socket.write(line));
|
|
251
|
+
socket.on("data", (chunk) => {
|
|
252
|
+
buffer += chunk.toString("utf8");
|
|
253
|
+
if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
254
|
+
finish(
|
|
255
|
+
reject,
|
|
256
|
+
new NativeRelayError("The Codex Desktop relay answered with an oversized frame", "RELAY_BAD_RESPONSE", {
|
|
257
|
+
reachedCompanion: true,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const index = buffer.indexOf("\n");
|
|
263
|
+
if (index < 0) return;
|
|
264
|
+
try {
|
|
265
|
+
finish(resolve, JSON.parse(buffer.slice(0, index)));
|
|
266
|
+
} catch (err) {
|
|
267
|
+
finish(
|
|
268
|
+
reject,
|
|
269
|
+
new NativeRelayError(
|
|
270
|
+
`The Codex Desktop relay answered with malformed JSON: ${err.message}`,
|
|
271
|
+
"RELAY_BAD_RESPONSE",
|
|
272
|
+
{ reachedCompanion: true },
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
socket.on("error", (err) =>
|
|
278
|
+
finish(
|
|
279
|
+
reject,
|
|
280
|
+
new NativeRelayError(`Cannot reach the Codex Desktop relay at ${socketPath}: ${err.message}`, "RELAY_UNREACHABLE"),
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
socket.on("close", () =>
|
|
284
|
+
finish(
|
|
285
|
+
reject,
|
|
286
|
+
new NativeRelayError(
|
|
287
|
+
`The Codex Desktop relay at ${socketPath} closed before answering`,
|
|
288
|
+
"RELAY_UNREACHABLE",
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Creates the dedicated executor thread once and remembers it, using the
|
|
298
|
+
* ordinary app-server path - which is allowed to take a writer lock here
|
|
299
|
+
* precisely because this thread belongs to nobody else. The caller stops the
|
|
300
|
+
* app-server afterwards, so the lock is released and Codex Desktop is left
|
|
301
|
+
* with the state to itself.
|
|
302
|
+
*/
|
|
303
|
+
export async function bootstrapRelayThread(client, { cwd = homeDir(), env = process.env, name = "Native Relay" } = {}) {
|
|
304
|
+
const res = await client.call("thread/start", {
|
|
305
|
+
cwd,
|
|
306
|
+
approvalPolicy: "never",
|
|
307
|
+
sandbox: "read-only",
|
|
308
|
+
});
|
|
309
|
+
const threadId = res?.thread?.id;
|
|
310
|
+
if (!threadId) throw new NativeRelayError("Codex app-server created no relay thread id", "RELAY_BOOTSTRAP_FAILED");
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await client.call("thread/name/set", { threadId, name });
|
|
314
|
+
} catch {
|
|
315
|
+
// A thread without a title still works as an executor context.
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
|
|
319
|
+
return { threadId, configPath: relayConfigPath(env) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function writeRelayConfig(config, env = process.env) {
|
|
323
|
+
const file = relayConfigPath(env);
|
|
324
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
325
|
+
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
326
|
+
return file;
|
|
327
|
+
}
|