@minhspark/codex-mcp-bridge 1.16.0 → 1.17.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/CHANGELOG.md +30 -0
- package/README.md +225 -565
- package/package.json +3 -1
- package/scripts/collect-repo-analytics.mjs +173 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +208 -0
- package/scripts/refresh-hosted-analytics.mjs +37 -0
- package/scripts/render-repo-analytics.mjs +50 -0
- package/scripts/usage-summary.sql +19 -0
- package/src/claude-bridge-launcher.mjs +2 -1
- package/src/claude-bridge.mjs +102 -16
- package/src/claude-session-router.mjs +16 -0
- package/src/codex-bridge-launcher.mjs +2 -1
- package/src/codex-native-response.mjs +333 -0
- package/src/codex-sender-context.mjs +5 -4
- package/src/hardened-root-policy.mjs +95 -0
- package/src/index.mjs +17 -5
- package/src/native-relay-companion.mjs +268 -23
- package/src/native-relay.mjs +3 -0
- package/src/peer-protocol.mjs +114 -20
- package/src/reply-forwarder.mjs +10 -3
- package/src/security-policy.mjs +4 -0
- package/src/telemetry-config.mjs +2 -0
- package/src/telemetry.mjs +149 -0
- package/src/thread-delivery.mjs +89 -11
- package/src/turn.mjs +35 -3
- package/src/vscode-bridge.mjs +162 -0
- package/src/vscode-delivery.mjs +19 -0
- package/src/vscode-ipc.mjs +80 -0
- package/src/windows-pipe-acl.mjs +27 -0
- package/src/windows-pipe-acl.ps1 +85 -0
package/src/turn.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
1
3
|
const TERMINAL_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
2
4
|
|
|
3
5
|
function summarizeItem(item) {
|
|
4
6
|
switch (item?.type) {
|
|
5
7
|
case "agentMessage":
|
|
6
|
-
return { kind: "agentMessage", text: item.text ?? "" };
|
|
8
|
+
return { kind: "agentMessage", id: item.id ?? null, text: item.text ?? "" };
|
|
7
9
|
case "commandExecution":
|
|
8
10
|
return {
|
|
9
11
|
kind: "command",
|
|
@@ -32,9 +34,11 @@ function summarizeItem(item) {
|
|
|
32
34
|
*/
|
|
33
35
|
export async function runTurn(client, { threadId, input, timeoutMs = 240000, turnOverrides = {} }) {
|
|
34
36
|
const messages = [];
|
|
37
|
+
const assistantItems = [];
|
|
35
38
|
const activity = [];
|
|
36
39
|
const errors = [];
|
|
37
40
|
const buffered = [];
|
|
41
|
+
const completedItems = new Map();
|
|
38
42
|
let turnId = null;
|
|
39
43
|
let settled = false;
|
|
40
44
|
let resolveDone;
|
|
@@ -56,6 +60,7 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
56
60
|
const process = (msg) => {
|
|
57
61
|
if (settled) return;
|
|
58
62
|
const params = msg.params ?? {};
|
|
63
|
+
if (params.threadId !== undefined && params.threadId !== threadId) return;
|
|
59
64
|
if (turnId && params.turnId && params.turnId !== turnId) return;
|
|
60
65
|
|
|
61
66
|
switch (msg.method) {
|
|
@@ -65,10 +70,25 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
65
70
|
return;
|
|
66
71
|
}
|
|
67
72
|
case "item/completed": {
|
|
73
|
+
if (!turnId || params.turnId !== turnId) return;
|
|
68
74
|
const summary = summarizeItem(params.item);
|
|
69
75
|
if (!summary) return;
|
|
76
|
+
const itemId = params.item?.id;
|
|
77
|
+
if (typeof itemId !== "string" || !itemId.trim()) return;
|
|
78
|
+
const fingerprint = JSON.stringify(summary);
|
|
79
|
+
if (completedItems.has(itemId)) {
|
|
80
|
+
if (completedItems.get(itemId) !== fingerprint && !settled) {
|
|
81
|
+
settled = true;
|
|
82
|
+
resolveDone({ status: "failed", error: { message: `Conflicting duplicate item ${itemId}` } });
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
completedItems.set(itemId, fingerprint);
|
|
70
87
|
if (summary.kind === "agentMessage") {
|
|
71
|
-
if (summary.text.trim())
|
|
88
|
+
if (summary.text.trim()) {
|
|
89
|
+
messages.push(summary.text);
|
|
90
|
+
assistantItems.push({ id: itemId, text: summary.text });
|
|
91
|
+
}
|
|
72
92
|
} else {
|
|
73
93
|
activity.push(summary);
|
|
74
94
|
}
|
|
@@ -86,6 +106,9 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
86
106
|
const turn = params.turn ?? {};
|
|
87
107
|
if (turnId && turn.id && turn.id !== turnId) return;
|
|
88
108
|
if (!TERMINAL_STATUSES.has(turn.status)) return;
|
|
109
|
+
if (Array.isArray(turn.items)) {
|
|
110
|
+
for (const item of turn.items) process({ method: "item/completed", params: { threadId, turnId, item } });
|
|
111
|
+
}
|
|
89
112
|
if (settled) return;
|
|
90
113
|
settled = true;
|
|
91
114
|
resolveDone({ status: turn.status, error: turn.error ?? null, durationMs: turn.durationMs ?? null });
|
|
@@ -135,18 +158,27 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
135
158
|
throw new Error("Codex app-server did not return a turn id for turn/start");
|
|
136
159
|
}
|
|
137
160
|
for (const msg of buffered.splice(0)) process(msg);
|
|
161
|
+
if (Array.isArray(start.started?.turn?.items)) {
|
|
162
|
+
for (const item of start.started.turn.items) process({ method: "item/completed", params: { threadId, turnId, item } });
|
|
163
|
+
}
|
|
138
164
|
if (TERMINAL_STATUSES.has(start.started?.turn?.status)) {
|
|
139
165
|
process({ method: "turn/completed", params: { turn: start.started.turn } });
|
|
140
166
|
}
|
|
141
167
|
outcome = await done;
|
|
142
168
|
}
|
|
169
|
+
const completedReply = outcome.status === "completed";
|
|
170
|
+
const text = completedReply ? messages.join("\n\n") : "";
|
|
171
|
+
const returnedItems = completedReply ? assistantItems : [];
|
|
143
172
|
return {
|
|
144
173
|
threadId,
|
|
145
174
|
turnId,
|
|
146
175
|
status: outcome.status,
|
|
147
176
|
error: outcome.error,
|
|
148
177
|
durationMs: outcome.durationMs ?? null,
|
|
149
|
-
text
|
|
178
|
+
text,
|
|
179
|
+
responseStatus: completedReply ? (returnedItems.length ? "completed" : "completed_no_reply") : "unavailable",
|
|
180
|
+
assistantItems: returnedItems,
|
|
181
|
+
replySha256: completedReply ? crypto.createHash("sha256").update(text.replace(/\r\n?/g, "\n").normalize("NFC"), "utf8").digest("hex") : null,
|
|
150
182
|
activity,
|
|
151
183
|
errors,
|
|
152
184
|
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { VSCodeIpc } from "./vscode-ipc.mjs";
|
|
8
|
+
import { classifyClaudeDelivery, classifyCodexCompletion } from "./vscode-delivery.mjs";
|
|
9
|
+
import { findRollout, readState, readCodexSenderContext } from "./codex-sender-context.mjs";
|
|
10
|
+
import { readProcessAncestry } from "./claude-sender-context.mjs";
|
|
11
|
+
import { listClaudeSessions, assertClaudeSessionCwd, assertClaudeSessionProcess, PeerEndpoint } from "./peer-protocol.mjs";
|
|
12
|
+
import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
|
|
13
|
+
|
|
14
|
+
const host = process.argv[2];
|
|
15
|
+
if (!["claude", "codex"].includes(host)) throw new Error("Specify the calling extension: claude or codex");
|
|
16
|
+
if (process.platform !== "win32") throw new Error("VS Code bridge preview currently supports Windows only");
|
|
17
|
+
const roots = (process.env.VSCODE_BRIDGE_ALLOWED_ROOTS ?? "").split(path.delimiter).filter(Boolean).map((root) => fs.realpathSync.native(root));
|
|
18
|
+
if (!roots.length || roots.some((root) => path.parse(root).root === root)) throw new Error("Configure explicit project roots in VSCODE_BRIDGE_ALLOWED_ROOTS");
|
|
19
|
+
const sessions = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions");
|
|
20
|
+
const peer = new PeerEndpoint({ name: "codex-vscode-bridge" });
|
|
21
|
+
const unresolved = new Map();
|
|
22
|
+
const deliveryOwners = new Map();
|
|
23
|
+
const result = (value, isError = false) => ({ content: [{ type: "text", text: JSON.stringify(value) }], isError });
|
|
24
|
+
const samePath = (left, right) => path.relative(fs.realpathSync.native(left), fs.realpathSync.native(right)) === "";
|
|
25
|
+
|
|
26
|
+
function assertRoot(cwd) {
|
|
27
|
+
const resolved = fs.realpathSync.native(cwd);
|
|
28
|
+
if (!roots.some((root) => {
|
|
29
|
+
const relative = path.relative(root, resolved);
|
|
30
|
+
return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`);
|
|
31
|
+
})) throw new Error("The session is outside the configured project roots");
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function caller(meta) {
|
|
36
|
+
if (host === "codex") {
|
|
37
|
+
const sender = readCodexSenderContext(meta, { originator: "codex_vscode" });
|
|
38
|
+
if (sender.status !== "verified") throw new Error(sender.reason);
|
|
39
|
+
assertRoot(sender.cwd);
|
|
40
|
+
return sender;
|
|
41
|
+
}
|
|
42
|
+
const ancestry = await readProcessAncestry();
|
|
43
|
+
const matches = listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && ancestry.some((row) => row.pid === session.pid && row.processStart === session.processStart));
|
|
44
|
+
if (matches.length !== 1) throw new Error("This MCP server must be launched by one live Claude Code VS Code session");
|
|
45
|
+
assertClaudeSessionProcess(matches[0]);
|
|
46
|
+
assertRoot(matches[0].cwd);
|
|
47
|
+
return matches[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function codexTask(threadId, cwd) {
|
|
51
|
+
if (!/^[0-9a-f-]{36}$/.test(threadId)) throw new Error("Invalid task ID");
|
|
52
|
+
const file = findRollout(sessions, threadId);
|
|
53
|
+
const state = readState(file, 64 * 1024 * 1024);
|
|
54
|
+
if (state.session?.id !== threadId || state.session.originator !== "codex_vscode" || state.session.source !== "vscode") throw new Error("The target is not a Codex VS Code task");
|
|
55
|
+
assertRoot(state.session.cwd);
|
|
56
|
+
if (!samePath(state.session.cwd, cwd) || !samePath(state.context.cwd, cwd)) throw new Error("Target and caller must use the same project directory");
|
|
57
|
+
return { ...state, file };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function taskCandidates(cwd) {
|
|
61
|
+
const queue = [{ directory: sessions, depth: 0 }];
|
|
62
|
+
const rows = [];
|
|
63
|
+
let count = 0;
|
|
64
|
+
while (queue.length) {
|
|
65
|
+
const { directory, depth } = queue.pop();
|
|
66
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
67
|
+
if (++count > 100000) throw new Error("Session scan limit exceeded");
|
|
68
|
+
if (entry.isSymbolicLink()) continue;
|
|
69
|
+
const file = path.join(directory, entry.name);
|
|
70
|
+
if (depth < 3 && entry.isDirectory() && /^\d{2,4}$/.test(entry.name)) queue.push({ directory: file, depth: depth + 1 });
|
|
71
|
+
if (depth !== 3 || !entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
72
|
+
const fd = fs.openSync(file, "r");
|
|
73
|
+
let first;
|
|
74
|
+
try { const data = Buffer.alloc(65536); const size = fs.readSync(fd, data); first = JSON.parse(data.subarray(0, size).toString("utf8").split("\n")[0]); }
|
|
75
|
+
catch { continue; }
|
|
76
|
+
finally { fs.closeSync(fd); }
|
|
77
|
+
const session = first.payload;
|
|
78
|
+
if (first.type === "session_meta" && session?.originator === "codex_vscode" && session.source === "vscode" && samePath(session.cwd, cwd)) rows.push({ threadId: session.id, cwd: session.cwd });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return rows.slice(-50);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const server = new McpServer({ name: `vscode-${host}-bridge`, version: "0.1.0" });
|
|
85
|
+
function tool(name, description, inputSchema, action) {
|
|
86
|
+
server.registerTool(name, { description, inputSchema }, async (args, extra) => {
|
|
87
|
+
try { return result(await action(args, await caller(extra?._meta), extra?._meta)); }
|
|
88
|
+
catch (error) { return result({ error: error.message, ...(error.msgId ? { msgId: error.msgId } : {}), ...(error.delivery ? { delivery: error.delivery } : {}) }, true); }
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
tool("vscode_bridge_status", "Verify this extension caller and list same-project counterparts in VS Code. Desktop sessions are excluded.", {}, async (_, sender) => {
|
|
93
|
+
if (host === "codex") return { host, cwd: sender.cwd, sessions: listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && samePath(session.cwd, sender.cwd)).map(({ sessionId, cwd }) => ({ sessionId, cwd })) };
|
|
94
|
+
const ipc = await new VSCodeIpc().connect();
|
|
95
|
+
try {
|
|
96
|
+
const tasks = [];
|
|
97
|
+
for (const candidate of taskCandidates(sender.cwd)) {
|
|
98
|
+
try { await ipc.owner(candidate.threadId); tasks.push(candidate); } catch {}
|
|
99
|
+
}
|
|
100
|
+
return { host, cwd: sender.cwd, tasks };
|
|
101
|
+
} finally { ipc.close(); }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (host === "claude") {
|
|
105
|
+
tool("send_to_codex_vscode", "Send to an existing Codex extension task in this project, preserving its selected permissions. Never retries or opens another app. Use read_codex_vscode_reply after submission.", { threadId: z.string(), message: z.string().min(1).max(50000) }, async ({ threadId, message }, sender, meta) => {
|
|
106
|
+
if (unresolved.has(threadId)) throw new Error("An earlier send is unresolved. Read its reply before sending again.");
|
|
107
|
+
const inspect = () => {
|
|
108
|
+
const state = codexTask(threadId, sender.cwd);
|
|
109
|
+
if (!["task_complete", "task_completed", "turn_complete", "turn_completed", "turn_aborted", "task_aborted"].includes(state.lifecycle?.type)) throw new Error("The target is busy or its lifecycle is unknown; wait for it to finish");
|
|
110
|
+
};
|
|
111
|
+
inspect();
|
|
112
|
+
const ipc = await new VSCodeIpc().connect();
|
|
113
|
+
try {
|
|
114
|
+
return await ipc.send(threadId, message, { beforeSend: async () => {
|
|
115
|
+
const current = await caller(meta);
|
|
116
|
+
if (current.sessionId !== sender.sessionId || current.processStart !== sender.processStart) throw new Error("Caller session changed");
|
|
117
|
+
inspect();
|
|
118
|
+
if (unresolved.has(threadId)) throw new Error("Another send is already pending for this task");
|
|
119
|
+
unresolved.set(threadId, "submission outcome unknown");
|
|
120
|
+
} }).then((receipt) => { unresolved.set(threadId, receipt.turnId); return receipt; });
|
|
121
|
+
} finally { ipc.close(); }
|
|
122
|
+
});
|
|
123
|
+
tool("read_codex_vscode_reply", "Read completion of the exact returned turn ID. A submission receipt alone does not prove completion.", { threadId: z.string(), turnId: z.string() }, async ({ threadId, turnId }, sender) => {
|
|
124
|
+
const state = codexTask(threadId, sender.cwd);
|
|
125
|
+
const completion = classifyCodexCompletion(state.lifecycle, turnId);
|
|
126
|
+
if (["completed", "failed", "interrupted"].includes(completion.status) && unresolved.get(threadId) === turnId) unresolved.delete(threadId);
|
|
127
|
+
return { threadId, ...completion };
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
tool("send_to_claude_vscode", "Send to the exact Claude Code VS Code session in this project. Never changes recipient permissions. Do not ask the recipient to call back while this synchronous request waits.", { sessionId: z.string(), message: z.string().min(1).max(50000), waitSec: z.number().int().min(0).max(45).default(30) }, async ({ sessionId, message, waitSec }, sender, meta) => {
|
|
131
|
+
const inspect = () => {
|
|
132
|
+
const matches = listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && session.sessionId === sessionId);
|
|
133
|
+
if (matches.length !== 1) throw new Error("No unique live Claude Code VS Code session matches");
|
|
134
|
+
const session = matches[0];
|
|
135
|
+
assertClaudeSessionCwd(session, sender.cwd);
|
|
136
|
+
assertClaudeSessionProcess(session);
|
|
137
|
+
const policy = readClaudeInboundPolicy(session.cwd);
|
|
138
|
+
if (["hold", "refuse"].includes(policy.value)) throw new Error(`Recipient crossSessionInbound is ${policy.value}; no message sent`);
|
|
139
|
+
return session;
|
|
140
|
+
};
|
|
141
|
+
const recipient = inspect();
|
|
142
|
+
await peer.start();
|
|
143
|
+
const receipt = await peer.sendAndWait(recipient.socket, message, { timeoutMs: waitSec * 1000, permissionMode: sender.mode, transcriptSession: { sessionId, cwd: recipient.cwd }, beforeSend: async () => {
|
|
144
|
+
const current = await caller(meta);
|
|
145
|
+
if (current.turnId !== sender.turnId || current.mode !== sender.mode) throw new Error("Caller turn or permissions changed");
|
|
146
|
+
const target = inspect();
|
|
147
|
+
if (target.pid !== recipient.pid || target.socket !== recipient.socket || target.processStart !== recipient.processStart) throw new Error("Recipient session changed");
|
|
148
|
+
} }).catch((error) => {
|
|
149
|
+
if (error.msgId) deliveryOwners.set(error.msgId, sender.threadId);
|
|
150
|
+
throw error;
|
|
151
|
+
});
|
|
152
|
+
deliveryOwners.set(receipt.msgId, sender.threadId);
|
|
153
|
+
return classifyClaudeDelivery(receipt, sender.mode);
|
|
154
|
+
});
|
|
155
|
+
tool("read_claude_vscode_delivery", "Inspect an earlier send without resending it.", { msgId: z.string() }, async ({ msgId }, sender) => {
|
|
156
|
+
if (deliveryOwners.get(msgId) !== sender.threadId) throw new Error("This receipt does not belong to the calling task");
|
|
157
|
+
return peer.readDelivery(msgId) ?? { status: "unknown" };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
process.on("exit", () => peer.stop());
|
|
162
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const BLOCKED = new Set(["held", "refused", "denied", "expired", "dropped"]);
|
|
2
|
+
|
|
3
|
+
export function classifyClaudeDelivery(receipt, senderMode) {
|
|
4
|
+
if (receipt.reply) return { ...receipt, status: "reply_received" };
|
|
5
|
+
const status = receipt.delivery?.status ?? "sent_unconfirmed";
|
|
6
|
+
if (BLOCKED.has(status)) {
|
|
7
|
+
throw Object.assign(new Error(`Claude returned ${status}: ${receipt.delivery.reason ?? "the message was not delivered to the conversation"}. Sender permission class: ${senderMode}. This does not prove an approval control exists in the VS Code extension. Report the receipt to the user; do not resend, change permissions, or claim that the user can click an unverified approval button.`), { msgId: receipt.msgId, delivery: receipt.delivery });
|
|
8
|
+
}
|
|
9
|
+
return { ...receipt, status };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function classifyCodexCompletion(lifecycle, turnId) {
|
|
13
|
+
if (lifecycle?.turn_id !== turnId) return { status: "pending_or_unavailable", turnId };
|
|
14
|
+
if (["turn_aborted", "task_aborted"].includes(lifecycle.type)) return { status: "interrupted", turnId };
|
|
15
|
+
if (!["task_complete", "task_completed", "turn_complete", "turn_completed"].includes(lifecycle.type)) return { status: "pending_or_unavailable", turnId };
|
|
16
|
+
if (lifecycle.error) return { status: "failed", turnId, error: lifecycle.error };
|
|
17
|
+
if (typeof lifecycle.last_agent_message !== "string") return { status: "response_unavailable", turnId };
|
|
18
|
+
return { status: "completed", turnId, text: lifecycle.last_agent_message };
|
|
19
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export class VSCodeIpc {
|
|
5
|
+
constructor({ socketPath = "\\\\.\\pipe\\codex-ipc", timeoutMs = 10000 } = {}) {
|
|
6
|
+
this.socketPath = socketPath;
|
|
7
|
+
this.timeoutMs = timeoutMs;
|
|
8
|
+
this.pending = new Map();
|
|
9
|
+
this.buffer = Buffer.alloc(0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async connect() {
|
|
13
|
+
this.socket = net.createConnection(this.socketPath);
|
|
14
|
+
this.socket.on("data", (data) => {
|
|
15
|
+
try {
|
|
16
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
17
|
+
while (this.buffer.length >= 4) {
|
|
18
|
+
const length = this.buffer.readUInt32LE(0);
|
|
19
|
+
if (!length || length > 16 * 1024 * 1024) throw new Error("Invalid IPC frame length");
|
|
20
|
+
if (this.buffer.length < length + 4) break;
|
|
21
|
+
const frame = JSON.parse(this.buffer.subarray(4, length + 4).toString("utf8"));
|
|
22
|
+
this.buffer = this.buffer.subarray(length + 4);
|
|
23
|
+
if (frame.type !== "response") continue;
|
|
24
|
+
const pending = this.pending.get(frame.requestId);
|
|
25
|
+
if (!pending) continue;
|
|
26
|
+
this.pending.delete(frame.requestId);
|
|
27
|
+
clearTimeout(pending.timer);
|
|
28
|
+
if (frame.resultType !== "success") pending.reject(new Error(`IPC ${pending.method}: ${frame.error ?? "request failed"}`));
|
|
29
|
+
else if (pending.target && frame.handledByClientId !== pending.target) pending.reject(new Error("IPC owner changed"));
|
|
30
|
+
else pending.resolve(frame);
|
|
31
|
+
}
|
|
32
|
+
} catch (error) { this.close(error); }
|
|
33
|
+
});
|
|
34
|
+
this.socket.on("error", (error) => this.close(error));
|
|
35
|
+
this.socket.on("close", () => this.close(new Error("IPC connection closed")));
|
|
36
|
+
const response = await this.request("initialize", { clientType: "codex-mcp-bridge" }, 0);
|
|
37
|
+
if (typeof response.result?.clientId !== "string") throw new Error("IPC initialization omitted client identity");
|
|
38
|
+
this.clientId = response.result.clientId;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
request(method, params, version = 1, target) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const requestId = randomUUID();
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
this.pending.delete(requestId);
|
|
47
|
+
reject(new Error(`IPC ${method} timed out; inspect the original task before retrying a send`));
|
|
48
|
+
}, this.timeoutMs);
|
|
49
|
+
this.pending.set(requestId, { resolve, reject, timer, method, target });
|
|
50
|
+
const body = Buffer.from(JSON.stringify({ type: "request", requestId, sourceClientId: this.clientId ?? "initializing-client", version, method, params, ...(target ? { targetClientId: target } : {}) }));
|
|
51
|
+
const header = Buffer.alloc(4);
|
|
52
|
+
header.writeUInt32LE(body.length);
|
|
53
|
+
this.socket.write(Buffer.concat([header, body]));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async owner(threadId) {
|
|
58
|
+
const response = await this.request("thread-owner-discovery", { hostId: "local", conversationId: threadId });
|
|
59
|
+
if (!response.handledByClientId) throw new Error("No live owner for this task");
|
|
60
|
+
return response.handledByClientId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async send(threadId, message, { beforeSend } = {}) {
|
|
64
|
+
const owner = await this.owner(threadId);
|
|
65
|
+
await beforeSend?.();
|
|
66
|
+
const response = await this.request("thread-follower-start-turn", {
|
|
67
|
+
conversationId: threadId,
|
|
68
|
+
turnStart: { request: { threadId, input: [{ type: "text", text: message, text_elements: [] }] }, context: { inheritThreadSettings: true } },
|
|
69
|
+
}, 2, owner);
|
|
70
|
+
const turnId = response.result?.result?.turn?.id;
|
|
71
|
+
if (typeof turnId !== "string") throw new Error("Submission was acknowledged without a turn ID; inspect the original task before retrying");
|
|
72
|
+
return { threadId, turnId, owner, status: "submitted" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(error = new Error("IPC client closed")) {
|
|
76
|
+
this.socket?.destroy();
|
|
77
|
+
for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
|
|
78
|
+
this.pending.clear();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { IS_WINDOWS } from "./platform.mjs";
|
|
5
|
+
|
|
6
|
+
const execFile = promisify(execFileCallback);
|
|
7
|
+
const powershell = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
8
|
+
|
|
9
|
+
export function bridgePipeName(socketPath) {
|
|
10
|
+
const prefix = "\\\\.\\pipe\\";
|
|
11
|
+
if (typeof socketPath !== "string" || !socketPath.startsWith(prefix)) throw new Error("A Windows ACL may only protect a named pipe");
|
|
12
|
+
return socketPath.slice(prefix.length);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function protectCurrentUserPipe(socketPath, { pid = process.pid, run = execFile } = {}) {
|
|
16
|
+
if (!IS_WINDOWS) return { skipped: true };
|
|
17
|
+
const name = bridgePipeName(socketPath);
|
|
18
|
+
const script = fileURLToPath(new URL("./windows-pipe-acl.ps1", import.meta.url));
|
|
19
|
+
const { stdout } = await run(powershell, ["-NoProfile", "-NonInteractive", "-File", script, "-PipeName", name, "-ServerPid", String(pid)], { windowsHide: true, timeout: 15000 });
|
|
20
|
+
let result;
|
|
21
|
+
try { result = JSON.parse(stdout.trim()); } catch { throw new Error("Windows pipe ACL helper returned invalid readback"); }
|
|
22
|
+
if (result?.serverMatches !== true || result?.serverUserMatches !== true || result?.ownerMatches !== true || result?.protected !== true || result?.aceCount !== 1
|
|
23
|
+
|| result?.rights !== 2032031 || result?.anonymousDenied !== true || result?.readError !== 5 || result?.duplexError !== 5) {
|
|
24
|
+
throw new Error("Windows pipe ACL readback did not prove exact current-user FullControl and anonymous denial");
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
param([Parameter(Mandatory)][string]$PipeName,[Parameter(Mandatory)][uint32]$ServerPid)
|
|
2
|
+
$ErrorActionPreference='Stop'
|
|
3
|
+
if($PipeName -notmatch '^LOCAL\\(?:codex-native-relay|codex-bridge|cc-msg)-[A-Za-z0-9_.-]+$'){throw 'Refusing a pipe outside the bridge-owned namespace.'}
|
|
4
|
+
Add-Type @'
|
|
5
|
+
using System;
|
|
6
|
+
using System.ComponentModel;
|
|
7
|
+
using System.Runtime.InteropServices;
|
|
8
|
+
using System.Security.Principal;
|
|
9
|
+
using Microsoft.Win32.SafeHandles;
|
|
10
|
+
public sealed class BridgeServerIdentity : IDisposable {
|
|
11
|
+
[DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
|
|
12
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
|
13
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
|
|
14
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool GetTokenInformation(IntPtr token, int kind, IntPtr buffer, uint length, out uint required);
|
|
15
|
+
IntPtr process;
|
|
16
|
+
public string User { get; private set; }
|
|
17
|
+
public string Owner { get; private set; }
|
|
18
|
+
static string ReadSid(IntPtr token, int kind) {
|
|
19
|
+
uint required;
|
|
20
|
+
if(GetTokenInformation(token, kind, IntPtr.Zero, 0, out required) || Marshal.GetLastWin32Error()!=122 || required<IntPtr.Size || required>65536) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
21
|
+
IntPtr buffer=Marshal.AllocHGlobal((int)required);
|
|
22
|
+
try {
|
|
23
|
+
if(!GetTokenInformation(token, kind, buffer, required, out required)) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
24
|
+
return new SecurityIdentifier(Marshal.ReadIntPtr(buffer)).Value;
|
|
25
|
+
} finally { Marshal.FreeHGlobal(buffer); }
|
|
26
|
+
}
|
|
27
|
+
public BridgeServerIdentity(uint pid) {
|
|
28
|
+
process=OpenProcess(0x1000, false, pid);
|
|
29
|
+
if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
30
|
+
IntPtr token=IntPtr.Zero;
|
|
31
|
+
try {
|
|
32
|
+
if(!OpenProcessToken(process, 8, out token)) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
33
|
+
User=ReadSid(token, 1);
|
|
34
|
+
Owner=ReadSid(token, 4);
|
|
35
|
+
} catch { Dispose(); throw; }
|
|
36
|
+
finally { if(token!=IntPtr.Zero) CloseHandle(token); }
|
|
37
|
+
}
|
|
38
|
+
public void Dispose() { if(process!=IntPtr.Zero) { CloseHandle(process); process=IntPtr.Zero; } }
|
|
39
|
+
}
|
|
40
|
+
public static class BridgePipeAcl {
|
|
41
|
+
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
|
|
42
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool ImpersonateAnonymousToken(IntPtr thread);
|
|
43
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool RevertToSelf();
|
|
44
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint pid);
|
|
45
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern SafeFileHandle CreateFile(string name,uint access,uint share,IntPtr sa,uint creation,uint flags,IntPtr template);
|
|
46
|
+
public static int AttemptAnonymous(string name,uint access) {
|
|
47
|
+
if(!ImpersonateAnonymousToken(GetCurrentThread())) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
48
|
+
try {
|
|
49
|
+
using(var handle=CreateFile(name,access,0,IntPtr.Zero,3,0,IntPtr.Zero)) {
|
|
50
|
+
int error=Marshal.GetLastWin32Error();
|
|
51
|
+
return handle.IsInvalid ? error : 0;
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
if(!RevertToSelf()) Environment.FailFast("Could not end anonymous pipe ACL verification.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
'@
|
|
59
|
+
$pipe=New-Object System.IO.Pipes.NamedPipeClientStream('.', $PipeName, [System.IO.Pipes.PipeAccessRights]::FullControl, [System.IO.Pipes.PipeOptions]::None, [System.Security.Principal.TokenImpersonationLevel]::Identification, [System.IO.HandleInheritability]::None)
|
|
60
|
+
$serverIdentity=$null
|
|
61
|
+
try {
|
|
62
|
+
$pipe.Connect(3000)
|
|
63
|
+
[uint32]$actualPid=0
|
|
64
|
+
if(-not [BridgePipeAcl]::GetNamedPipeServerProcessId($pipe.SafePipeHandle,[ref]$actualPid) -or $actualPid -ne $ServerPid){throw 'Pipe server identity mismatch.'}
|
|
65
|
+
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
66
|
+
$serverIdentity=New-Object BridgeServerIdentity($actualPid)
|
|
67
|
+
if($serverIdentity.User -ne $sid.Value){throw 'Pipe server token user is not current user.'}
|
|
68
|
+
$before=$pipe.GetAccessControl()
|
|
69
|
+
$owner=$before.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
70
|
+
if($owner -ne $sid.Value -and $owner -ne $serverIdentity.Owner){throw 'Pipe owner does not match the verified server token.'}
|
|
71
|
+
$acl=New-Object System.IO.Pipes.PipeSecurity
|
|
72
|
+
$acl.SetOwner($sid)
|
|
73
|
+
$acl.SetAccessRuleProtection($true,$false)
|
|
74
|
+
$acl.AddAccessRule((New-Object System.IO.Pipes.PipeAccessRule($sid,[System.IO.Pipes.PipeAccessRights]::FullControl,[System.Security.AccessControl.AccessControlType]::Allow)))
|
|
75
|
+
$pipe.SetAccessControl($acl)
|
|
76
|
+
if(-not [BridgePipeAcl]::GetNamedPipeServerProcessId($pipe.SafePipeHandle,[ref]$actualPid) -or $actualPid -ne $ServerPid){throw 'Pipe server identity changed.'}
|
|
77
|
+
$after=$pipe.GetAccessControl(); $rules=@($after.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))
|
|
78
|
+
if($after.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value){throw 'Restricted owner readback mismatch.'}
|
|
79
|
+
if(-not $after.AreAccessRulesProtected -or $rules.Count -ne 1 -or $rules[0].IsInherited -or $rules[0].IdentityReference.Value -ne $sid.Value -or $rules[0].AccessControlType -ne 'Allow' -or $rules[0].PipeAccessRights -ne [System.IO.Pipes.PipeAccessRights]::FullControl){throw 'Restricted DACL readback mismatch.'}
|
|
80
|
+
$nativeName='\\.\pipe\'+$PipeName
|
|
81
|
+
$readError=[BridgePipeAcl]::AttemptAnonymous($nativeName,[uint32]2147483648)
|
|
82
|
+
$duplexError=[BridgePipeAcl]::AttemptAnonymous($nativeName,[uint32]3221225472)
|
|
83
|
+
if($readError -ne 5 -or $duplexError -ne 5){throw "Anonymous pipe access was not denied: read=$readError duplex=$duplexError"}
|
|
84
|
+
[pscustomobject]@{serverMatches=$true;serverUserMatches=$true;ownerMatches=$true;protected=$true;aceCount=1;rights=[int]$rules[0].PipeAccessRights;anonymousDenied=$true;readError=$readError;duplexError=$duplexError}|ConvertTo-Json -Compress
|
|
85
|
+
} finally {if($null -ne $serverIdentity){$serverIdentity.Dispose()};$pipe.Dispose()}
|