@minhspark/codex-mcp-bridge 1.16.0 → 1.16.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 +12 -0
- package/README.md +212 -574
- package/package.json +3 -1
- package/scripts/collect-repo-analytics.mjs +163 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +208 -0
- package/scripts/refresh-hosted-analytics.mjs +33 -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/hardened-root-policy.mjs +95 -0
- package/src/index.mjs +17 -5
- package/src/native-relay-companion.mjs +268 -18
- package/src/native-relay.mjs +3 -0
- package/src/peer-protocol.mjs +88 -11
- 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/windows-pipe-acl.mjs +27 -0
- package/src/windows-pipe-acl.ps1 +85 -0
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
relaySocketPath,
|
|
20
20
|
resolveRelayThreadId,
|
|
21
21
|
desktopTaskSocketPath,
|
|
22
|
+
desktopTasksConfigured,
|
|
22
23
|
validateDesktopOperation,
|
|
23
24
|
decodeNativeToolResult,
|
|
24
25
|
} from "./native-relay.mjs";
|
|
@@ -27,10 +28,13 @@ import { exitForVersionRequest } from "./cli-version.mjs";
|
|
|
27
28
|
import { assertAccountIdentity } from "./bridge-account-context.mjs";
|
|
28
29
|
import { createReloadControl } from "./reload-control.mjs";
|
|
29
30
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
31
|
+
import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
|
|
32
|
+
import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
|
|
33
|
+
import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
30
34
|
|
|
31
35
|
exitForVersionRequest(import.meta.url);
|
|
32
36
|
|
|
33
|
-
const VERSION = "1.16.
|
|
37
|
+
const VERSION = "1.16.1";
|
|
34
38
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
35
39
|
|
|
36
40
|
function errorResponse(code, message, sent) {
|
|
@@ -61,6 +65,172 @@ function errorCode(err) {
|
|
|
61
65
|
return typeof err?.code === "string" ? err.code : "NATIVE_DISPATCH_FAILED";
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
export function createNativeScopeAuthorizer({ dispatchDesktop, env = process.env } = {}) {
|
|
69
|
+
const roots = createHardenedRootPolicy(env);
|
|
70
|
+
if (typeof dispatchDesktop !== "function") throw new Error("Native metadata dispatcher is required");
|
|
71
|
+
const call = async (executorThreadId, operation, arguments_, accountContext) => decodeNativeToolResult(await dispatchDesktop({ executorThreadId, operation, arguments: arguments_ }, { accountContext }));
|
|
72
|
+
const fail = (message) => { throw new NativeRelayError(message, "NATIVE_SCOPE_UNVERIFIED"); };
|
|
73
|
+
const projectRecord = (project) => {
|
|
74
|
+
if (project?.projectKind !== "local" || project.hostId !== "local" || typeof project.projectId !== "string" || !project.projectId) return null;
|
|
75
|
+
try {
|
|
76
|
+
return { project, projectId: project.projectId, root: roots.capture(project.path, "Saved project directory") };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const projectsFor = async (executorThreadId, accountContext) => {
|
|
82
|
+
const result = await call(executorThreadId, "list_projects", {}, accountContext);
|
|
83
|
+
if (!Array.isArray(result?.projects)) fail("Native project metadata is unavailable");
|
|
84
|
+
const projects = result.projects.map(projectRecord).filter(Boolean);
|
|
85
|
+
return projects.filter((candidate) =>
|
|
86
|
+
projects.filter((other) => other.projectId === candidate.projectId).length === 1
|
|
87
|
+
&& projects.filter((other) => other.root.path === candidate.root.path).length === 1);
|
|
88
|
+
};
|
|
89
|
+
const projectForThread = (thread, projects) => {
|
|
90
|
+
const root = roots.capture(thread.cwd, "Native task working directory");
|
|
91
|
+
const matching = projects.filter((project) => project.root.path === root.path);
|
|
92
|
+
if (matching.length !== 1) fail("Native task does not map to exactly one allowed local saved project");
|
|
93
|
+
if (thread.projectId !== undefined && thread.projectId !== matching[0].projectId) fail("Native task project identity conflicts with its saved project directory");
|
|
94
|
+
return { root, project: matching[0] };
|
|
95
|
+
};
|
|
96
|
+
const validateThread = (thread, threadId, projects) => {
|
|
97
|
+
if (thread?.id !== threadId || thread.kind !== "codex" || thread.hostId !== "local") fail("Native task metadata does not prove the exact allowed local task");
|
|
98
|
+
const scope = projectForThread(thread, projects);
|
|
99
|
+
return { threadId, kind: thread.kind, hostId: thread.hostId, root: scope.root, projectId: scope.project.projectId };
|
|
100
|
+
};
|
|
101
|
+
const threadFor = async (executorThreadId, threadId, accountContext, projects) => {
|
|
102
|
+
if (typeof threadId !== "string" || !threadId) fail("Operation has no target task");
|
|
103
|
+
const result = await call(executorThreadId, "read_thread", { threadId, hostId: "local", turnLimit: 1 }, accountContext);
|
|
104
|
+
return validateThread(result?.thread, threadId, projects);
|
|
105
|
+
};
|
|
106
|
+
const targetIds = (operation, args, targetThreadId) => {
|
|
107
|
+
if (["read_thread", "send_message_to_thread", "navigate_to_codex_page", "set_thread_title"].includes(operation)) return [targetThreadId ?? args?.threadId];
|
|
108
|
+
if (operation === "wait_threads") {
|
|
109
|
+
if (!Array.isArray(args?.targets) || !args.targets.length) fail("Wait targets are not verifiable");
|
|
110
|
+
const ids = args.targets.map((target) => target?.threadId);
|
|
111
|
+
if (ids.some((id) => typeof id !== "string" || !id) || new Set(ids).size !== ids.length) fail("Wait targets must be distinct exact task ids");
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
return [];
|
|
115
|
+
};
|
|
116
|
+
const contextFor = async ({ executorThreadId, targetThreadId, operation, arguments: args, accountContext }) => {
|
|
117
|
+
const projects = await projectsFor(executorThreadId, accountContext);
|
|
118
|
+
const executor = await threadFor(executorThreadId, executorThreadId, accountContext, projects);
|
|
119
|
+
if (operation === "wait_threads" && targetIds(operation, args, targetThreadId).includes(executorThreadId)) {
|
|
120
|
+
fail("The native wait operation cannot target its calling executor task");
|
|
121
|
+
}
|
|
122
|
+
const targets = [];
|
|
123
|
+
for (const id of targetIds(operation, args, targetThreadId)) targets.push(await threadFor(executorThreadId, id, accountContext, projects));
|
|
124
|
+
if (operation === "create_thread") {
|
|
125
|
+
const requested = args?.target?.type === "project" ? args.target.projectId : null;
|
|
126
|
+
const matching = projects.filter((project) => project.projectId === requested);
|
|
127
|
+
if (matching.length !== 1 || args?.target?.environment?.type !== "local") fail("Creation target is not the exact allowed saved local project");
|
|
128
|
+
return { executor, targets, requestedProject: matching[0], projects };
|
|
129
|
+
}
|
|
130
|
+
return { executor, targets, requestedProject: null, projects };
|
|
131
|
+
};
|
|
132
|
+
const compare = (expected, current) => {
|
|
133
|
+
if (!expected) return;
|
|
134
|
+
roots.recheck(expected.executor.root, "Relay executor working directory");
|
|
135
|
+
if (expected.executor.threadId !== current.executor.threadId || expected.executor.projectId !== current.executor.projectId
|
|
136
|
+
|| expected.executor.root.path !== current.executor.root.path) fail("Relay executor identity changed while the operation was pending");
|
|
137
|
+
if (expected.targets.length !== current.targets.length) fail("Native target set changed while the operation was pending");
|
|
138
|
+
for (let index = 0; index < expected.targets.length; index += 1) {
|
|
139
|
+
const before = expected.targets[index];
|
|
140
|
+
const after = current.targets[index];
|
|
141
|
+
roots.recheck(before.root, "Native target working directory");
|
|
142
|
+
if (before.threadId !== after.threadId || before.kind !== after.kind || before.hostId !== after.hostId
|
|
143
|
+
|| before.projectId !== after.projectId || before.root.path !== after.root.path) fail("Native target identity changed while the operation was pending");
|
|
144
|
+
}
|
|
145
|
+
if (expected.requestedProject) {
|
|
146
|
+
roots.recheck(expected.requestedProject.root, "Creation project directory");
|
|
147
|
+
if (!current.requestedProject || expected.requestedProject.projectId !== current.requestedProject.projectId
|
|
148
|
+
|| expected.requestedProject.root.path !== current.requestedProject.root.path) fail("Creation project identity changed while the operation was pending");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const filterProjects = (result, current) => {
|
|
152
|
+
if (!Array.isArray(result?.projects)) fail("Native project list result is invalid");
|
|
153
|
+
const allowed = new Map(current.projects.map((project) => [project.projectId, project]));
|
|
154
|
+
return { projects: result.projects.filter((project) => {
|
|
155
|
+
const scope = projectRecord(project);
|
|
156
|
+
const expected = scope && allowed.get(scope.projectId);
|
|
157
|
+
return Boolean(expected && expected.root.path === scope.root.path);
|
|
158
|
+
}) };
|
|
159
|
+
};
|
|
160
|
+
const filterThreads = (result, current) => {
|
|
161
|
+
if (!Array.isArray(result?.threads) || !Array.isArray(result?.pinnedThreads)) fail("Native thread list result is invalid");
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const filter = (rows) => rows.filter((thread) => {
|
|
164
|
+
if (typeof thread?.id !== "string" || seen.has(thread.id)) return false;
|
|
165
|
+
try {
|
|
166
|
+
validateThread(thread, thread.id, current.projects);
|
|
167
|
+
seen.add(thread.id);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return { pinnedThreads: filter(result.pinnedThreads), threads: filter(result.threads) };
|
|
174
|
+
};
|
|
175
|
+
const filterWait = (result, current) => {
|
|
176
|
+
if (typeof result?.timedOut !== "boolean" || !Array.isArray(result?.polls)) fail("Native wait result is invalid");
|
|
177
|
+
const allowed = new Map(current.targets.map((target) => [target.threadId, target]));
|
|
178
|
+
const checkReturnedBinding = (row, target) => {
|
|
179
|
+
// Native wait rows may omit these fields; explicit contradictions cannot
|
|
180
|
+
// override the independently reread target binding.
|
|
181
|
+
if ((row.kind !== undefined && row.kind !== target.kind)
|
|
182
|
+
|| (row.projectId !== undefined && row.projectId !== target.projectId)) fail("Native wait result contradicts the selected task identity");
|
|
183
|
+
if (row.cwd !== undefined) {
|
|
184
|
+
const returned = roots.capture(row.cwd, "Native wait result working directory");
|
|
185
|
+
if (returned.path !== target.root.path || returned.identity !== target.root.identity) fail("Native wait result contradicts the selected task directory");
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const polls = result.polls.filter((poll) => {
|
|
190
|
+
const id = poll?.thread?.id;
|
|
191
|
+
if (typeof id !== "string" || poll?.thread?.hostId !== "local" || !allowed.has(id) || seen.has(id)) return false;
|
|
192
|
+
checkReturnedBinding(poll.thread, allowed.get(id));
|
|
193
|
+
seen.add(id);
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
const wake = result.wake && typeof result.wake === "object"
|
|
197
|
+
&& result.wake.hostId === "local" && allowed.has(result.wake.threadId)
|
|
198
|
+
? result.wake : null;
|
|
199
|
+
if (wake) checkReturnedBinding(wake, allowed.get(wake.threadId));
|
|
200
|
+
return { timedOut: result.timedOut, wake, polls };
|
|
201
|
+
};
|
|
202
|
+
return async (request) => {
|
|
203
|
+
try {
|
|
204
|
+
const current = await contextFor(request);
|
|
205
|
+
compare(request.expected, current);
|
|
206
|
+
if (request.phase !== "return") return current;
|
|
207
|
+
const result = request.result;
|
|
208
|
+
if (request.operation === "list_projects") return { ...current, result: filterProjects(result, current) };
|
|
209
|
+
if (request.operation === "list_threads") return { ...current, result: filterThreads(result, current) };
|
|
210
|
+
if (request.operation === "wait_threads") return { ...current, result: filterWait(result, current) };
|
|
211
|
+
if (request.operation === "read_thread") {
|
|
212
|
+
const returned = validateThread(result?.thread, request.targetThreadId ?? request.arguments?.threadId, current.projects);
|
|
213
|
+
const selected = current.targets[0];
|
|
214
|
+
if (!selected || returned.projectId !== selected.projectId || returned.root.path !== selected.root.path
|
|
215
|
+
|| returned.root.identity !== selected.root.identity) fail("Native read result does not match the selected task binding");
|
|
216
|
+
}
|
|
217
|
+
if (request.operation === "create_thread") {
|
|
218
|
+
const createdId = result?.threadId;
|
|
219
|
+
if (typeof createdId !== "string" || !createdId.trim()) throw new NativeRelayError("Native creation did not return a confirmed task id; inspect the existing outcome before any further creation", "NATIVE_DELIVERY_UNCONFIRMED");
|
|
220
|
+
if (result.hostId !== undefined && result.hostId !== "local") fail("Native creation result does not identify the requested local host");
|
|
221
|
+
const created = await threadFor(request.executorThreadId, createdId, request.accountContext, current.projects);
|
|
222
|
+
roots.recheck(current.requestedProject.root, "Creation project directory");
|
|
223
|
+
if (created.projectId !== current.requestedProject.projectId || created.root.path !== current.requestedProject.root.path
|
|
224
|
+
|| created.root.identity !== current.requestedProject.root.identity) fail("Created task does not belong to the requested saved project");
|
|
225
|
+
}
|
|
226
|
+
return { ...current, result };
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (typeof error?.code === "string") throw error;
|
|
229
|
+
fail(error?.message ?? String(error));
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
64
234
|
/**
|
|
65
235
|
* The whole request handler, kept free of sockets and of the MCP connection so
|
|
66
236
|
* the rules it enforces can be tested against a stub dispatcher rather than
|
|
@@ -68,10 +238,12 @@ function errorCode(err) {
|
|
|
68
238
|
*/
|
|
69
239
|
export async function handleRelayRequest(
|
|
70
240
|
payload,
|
|
71
|
-
{ dispatch, dispatchDesktop, resolveExecutor = resolveRelayThreadId, env = process.env, assertAccount = assertAccountIdentity } = {},
|
|
241
|
+
{ dispatch, dispatchDesktop, resolveExecutor = resolveRelayThreadId, env = process.env, assertAccount = assertAccountIdentity, authorize, strict = hardenedBridgeEnabled(env) } = {},
|
|
72
242
|
) {
|
|
243
|
+
if (strict && (!boundRequest(payload) || !validProtocol(payload))) return errorResponse("RELAY_BAD_REQUEST", "Hardened relay accepts only protocol 2 requests with account context", false);
|
|
244
|
+
if (strict && typeof authorize !== "function") return errorResponse("NATIVE_SCOPE_UNVERIFIED", "Hardened relay requires verified current native project and task metadata; no dispatch was attempted", false);
|
|
73
245
|
if (payload && typeof payload === "object" && Object.hasOwn(payload, "operation")) {
|
|
74
|
-
return handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount });
|
|
246
|
+
return handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount, authorize, strict });
|
|
75
247
|
}
|
|
76
248
|
if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
|
|
77
249
|
Object.keys(payload).some((key) => !["v", "targetThreadId", "message", "accountContext"].includes(key)) || !validProtocol(payload)) {
|
|
@@ -88,6 +260,8 @@ export async function handleRelayRequest(
|
|
|
88
260
|
try {
|
|
89
261
|
accountContext = await checkAccountContext(payload, assertAccount);
|
|
90
262
|
executorThreadId = resolveExecutor(env).threadId;
|
|
263
|
+
var authorization;
|
|
264
|
+
if (strict) authorization = await authorize({ executorThreadId, targetThreadId, operation: "send_message_to_thread", accountContext });
|
|
91
265
|
} catch (err) {
|
|
92
266
|
return errorResponse(errorCode(err), err.message, false);
|
|
93
267
|
}
|
|
@@ -106,7 +280,11 @@ export async function handleRelayRequest(
|
|
|
106
280
|
}
|
|
107
281
|
|
|
108
282
|
try {
|
|
109
|
-
const result = await dispatch({ executorThreadId, targetThreadId, message }, {
|
|
283
|
+
const result = await dispatch({ executorThreadId, targetThreadId, message }, {
|
|
284
|
+
accountContext,
|
|
285
|
+
...(strict ? { beforeSend: () => authorize({ executorThreadId, targetThreadId, operation: "send_message_to_thread", accountContext, phase: "write", expected: authorization }) } : {}),
|
|
286
|
+
});
|
|
287
|
+
if (strict) await authorize({ executorThreadId, targetThreadId, operation: "send_message_to_thread", accountContext, phase: "return", expected: authorization, result });
|
|
110
288
|
if (result?.success !== true || result?.isError === true) {
|
|
111
289
|
const detail = typeof result?.error === "string" ? result.error : result?.error?.message;
|
|
112
290
|
return errorResponse("NATIVE_DISPATCH_FAILED", detail ?? "Codex Desktop did not confirm successful native dispatch");
|
|
@@ -117,7 +295,8 @@ export async function handleRelayRequest(
|
|
|
117
295
|
}
|
|
118
296
|
}
|
|
119
297
|
|
|
120
|
-
async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount }) {
|
|
298
|
+
async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount, authorize, strict = hardenedBridgeEnabled(env) }) {
|
|
299
|
+
if (strict && !boundRequest(payload)) return errorResponse("RELAY_BAD_REQUEST", "Hardened relay accepts only protocol 2 Desktop operations", false);
|
|
121
300
|
if (Array.isArray(payload) || ![RELAY_PROTOCOL_VERSION, ACCOUNT_RELAY_PROTOCOL_VERSION].includes(payload.v) || !validProtocol(payload) ||
|
|
122
301
|
Object.keys(payload).some((key) => !["v", "operation", "arguments", "accountContext"].includes(key))) {
|
|
123
302
|
return errorResponse("RELAY_BAD_REQUEST", "expected an allowlisted Desktop operation");
|
|
@@ -134,6 +313,8 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
|
|
|
134
313
|
return errorResponse("NATIVE_OPERATION_UNAVAILABLE", "This companion does not support Desktop operations; reload the native relay");
|
|
135
314
|
}
|
|
136
315
|
const executorThreadId = resolveExecutor(env).threadId;
|
|
316
|
+
const request = { executorThreadId, targetThreadId: payload.arguments?.threadId, operation: payload.operation, arguments: payload.arguments, accountContext };
|
|
317
|
+
const authorization = strict ? await authorize(request) : null;
|
|
137
318
|
if (payload.operation === "send_message_to_thread" && payload.arguments.threadId === executorThreadId) {
|
|
138
319
|
return errorResponse("RELAY_BAD_REQUEST", "The relay executor cannot receive its own relayed message");
|
|
139
320
|
}
|
|
@@ -141,13 +322,18 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
|
|
|
141
322
|
executorThreadId,
|
|
142
323
|
operation: payload.operation,
|
|
143
324
|
arguments: payload.arguments,
|
|
144
|
-
}, {
|
|
325
|
+
}, {
|
|
326
|
+
accountContext,
|
|
327
|
+
...(strict ? { beforeSend: () => authorize({ ...request, phase: "write", expected: authorization }) } : {}),
|
|
328
|
+
});
|
|
329
|
+
const decoded = decodeNativeToolResult(nativeResult);
|
|
330
|
+
const checked = strict ? await authorize({ ...request, phase: "return", expected: authorization, result: decoded }) : null;
|
|
145
331
|
return {
|
|
146
332
|
ok: true,
|
|
147
333
|
v: boundRequest(payload) ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION,
|
|
148
334
|
operation: payload.operation,
|
|
149
335
|
executorThreadId,
|
|
150
|
-
result:
|
|
336
|
+
result: checked?.result ?? decoded,
|
|
151
337
|
};
|
|
152
338
|
} catch (err) {
|
|
153
339
|
return errorResponse(errorCode(err), err?.message ?? String(err), err?.sent === false && err?.reachedCompanion !== true ? false : undefined);
|
|
@@ -167,7 +353,10 @@ export class RelaySocketServer {
|
|
|
167
353
|
dispatchDesktop,
|
|
168
354
|
resolveExecutor = resolveRelayThreadId,
|
|
169
355
|
assertAccount = assertAccountIdentity,
|
|
356
|
+
authorize,
|
|
170
357
|
requireAccountContext = false,
|
|
358
|
+
strict = hardenedBridgeEnabled(),
|
|
359
|
+
protectSocket = protectCurrentUserPipe,
|
|
171
360
|
restrictSocket = (target) => {
|
|
172
361
|
if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
|
|
173
362
|
},
|
|
@@ -178,7 +367,10 @@ export class RelaySocketServer {
|
|
|
178
367
|
this.dispatchDesktop = dispatchDesktop;
|
|
179
368
|
this.resolveExecutor = resolveExecutor;
|
|
180
369
|
this.assertAccount = assertAccount;
|
|
370
|
+
this.authorize = authorize;
|
|
181
371
|
this.requireAccountContext = requireAccountContext;
|
|
372
|
+
this.strict = strict;
|
|
373
|
+
this.protectSocket = protectSocket;
|
|
182
374
|
this.restrictSocket = restrictSocket;
|
|
183
375
|
this.log = logFn;
|
|
184
376
|
this.server = null;
|
|
@@ -188,12 +380,28 @@ export class RelaySocketServer {
|
|
|
188
380
|
this.accepting = true;
|
|
189
381
|
this.closed = Promise.resolve();
|
|
190
382
|
this.processHandlers = new Map();
|
|
383
|
+
this.aclReady = !IS_WINDOWS || !strict;
|
|
384
|
+
this.provisionalSockets = new Set();
|
|
385
|
+
this.startGeneration = 0;
|
|
386
|
+
this.startPromise = null;
|
|
191
387
|
}
|
|
192
388
|
|
|
193
|
-
|
|
389
|
+
start() {
|
|
194
390
|
if (this.started) return this.socketPath;
|
|
391
|
+
if (this.startPromise) return this.startPromise;
|
|
392
|
+
const starting = this.#startOnce();
|
|
393
|
+
const shared = starting.finally(() => {
|
|
394
|
+
if (this.startPromise === shared) this.startPromise = null;
|
|
395
|
+
});
|
|
396
|
+
this.startPromise = shared;
|
|
397
|
+
return shared;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async #startOnce() {
|
|
195
401
|
if (!IS_WINDOWS) fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
|
|
196
402
|
|
|
403
|
+
const generation = ++this.startGeneration;
|
|
404
|
+
this.aclReady = !IS_WINDOWS || !this.strict;
|
|
197
405
|
this.server = net.createServer((socket) => this.#handleConnection(socket));
|
|
198
406
|
await this.#listen({ replaceStale: true });
|
|
199
407
|
|
|
@@ -204,11 +412,19 @@ export class RelaySocketServer {
|
|
|
204
412
|
* thread writes on an address anyone can open.
|
|
205
413
|
*/
|
|
206
414
|
try {
|
|
207
|
-
|
|
415
|
+
if (IS_WINDOWS && this.strict) {
|
|
416
|
+
await this.protectSocket(this.socketPath);
|
|
417
|
+
if (generation !== this.startGeneration) throw new Error("Windows pipe ACL startup was cancelled");
|
|
418
|
+
this.aclReady = true;
|
|
419
|
+
for (const provisional of this.provisionalSockets) provisional.destroy();
|
|
420
|
+
this.provisionalSockets.clear();
|
|
421
|
+
} else this.restrictSocket(this.socketPath);
|
|
422
|
+
if (generation !== this.startGeneration) throw new Error("Relay socket startup was cancelled");
|
|
208
423
|
} catch (err) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
424
|
+
this.aclReady = !IS_WINDOWS || !this.strict;
|
|
425
|
+
for (const socket of this.provisionalSockets) socket.destroy();
|
|
426
|
+
this.provisionalSockets.clear();
|
|
427
|
+
await this.#closeServer();
|
|
212
428
|
throw new Error(`refusing to serve on ${this.socketPath}: its mode could not be restricted (${err.message})`);
|
|
213
429
|
}
|
|
214
430
|
this.started = true;
|
|
@@ -229,6 +445,19 @@ export class RelaySocketServer {
|
|
|
229
445
|
return this.socketPath;
|
|
230
446
|
}
|
|
231
447
|
|
|
448
|
+
#closeServer() {
|
|
449
|
+
const server = this.server;
|
|
450
|
+
return new Promise((resolve) => {
|
|
451
|
+
if (!server) return resolve();
|
|
452
|
+
try {
|
|
453
|
+
if (!server.listening) return resolve();
|
|
454
|
+
server.close(() => resolve());
|
|
455
|
+
} catch {
|
|
456
|
+
resolve();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
232
461
|
/**
|
|
233
462
|
* A companion killed with SIGKILL leaves its socket file behind, and the next
|
|
234
463
|
* one then fails to bind a path nothing is listening on. Removing it blindly
|
|
@@ -280,10 +509,20 @@ export class RelaySocketServer {
|
|
|
280
509
|
}
|
|
281
510
|
|
|
282
511
|
async isListening() {
|
|
512
|
+
if (this.startPromise) {
|
|
513
|
+
try { await this.startPromise; } catch { return false; }
|
|
514
|
+
return this.started;
|
|
515
|
+
}
|
|
283
516
|
return this.started || this.#socketIsLive();
|
|
284
517
|
}
|
|
285
518
|
|
|
286
519
|
#handleConnection(socket) {
|
|
520
|
+
if (IS_WINDOWS && !this.aclReady) {
|
|
521
|
+
this.provisionalSockets.add(socket);
|
|
522
|
+
socket.once("close", () => this.provisionalSockets.delete(socket));
|
|
523
|
+
socket.once("error", () => {});
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
287
526
|
if (!this.accepting) {
|
|
288
527
|
socket.on("error", () => {});
|
|
289
528
|
socket.end(`${JSON.stringify(errorResponse("RELAY_RELOADING", "The native relay is reloading; no message was sent", false))}\n`);
|
|
@@ -324,7 +563,7 @@ export class RelaySocketServer {
|
|
|
324
563
|
this.#reply(socket, errorResponse("RELAY_BAD_REQUEST", `malformed JSON: ${err.message}`));
|
|
325
564
|
return;
|
|
326
565
|
}
|
|
327
|
-
if (this.requireAccountContext && !boundRequest(payload)) {
|
|
566
|
+
if ((this.requireAccountContext || this.strict) && !boundRequest(payload)) {
|
|
328
567
|
this.#reply(socket, errorResponse("RELAY_BAD_REQUEST", "The account relay requires protocol 2 and the original account context", false));
|
|
329
568
|
return;
|
|
330
569
|
}
|
|
@@ -333,6 +572,8 @@ export class RelaySocketServer {
|
|
|
333
572
|
dispatchDesktop: this.dispatchDesktop,
|
|
334
573
|
resolveExecutor: this.resolveExecutor,
|
|
335
574
|
assertAccount: this.assertAccount,
|
|
575
|
+
authorize: this.authorize,
|
|
576
|
+
strict: this.strict,
|
|
336
577
|
});
|
|
337
578
|
if (!response.ok) this.log(`relay refused ${payload?.targetThreadId ?? "?"}: ${response.error.message}`);
|
|
338
579
|
else this.log(response.operation ? `completed Desktop operation ${response.operation}` : `relayed a message into thread ${response.targetThreadId}`);
|
|
@@ -345,6 +586,10 @@ export class RelaySocketServer {
|
|
|
345
586
|
}
|
|
346
587
|
|
|
347
588
|
stop() {
|
|
589
|
+
this.startGeneration += 1;
|
|
590
|
+
this.aclReady = !IS_WINDOWS || !this.strict;
|
|
591
|
+
for (const socket of this.provisionalSockets) socket.destroy();
|
|
592
|
+
this.provisionalSockets.clear();
|
|
348
593
|
for (const [event, handler] of this.processHandlers) process.off(event, handler);
|
|
349
594
|
this.processHandlers.clear();
|
|
350
595
|
for (const socket of this.connections) socket.destroy();
|
|
@@ -507,6 +752,8 @@ if (invokedDirectly) {
|
|
|
507
752
|
const nativeTools = new NativeToolsClient();
|
|
508
753
|
const dispatch = (args, options) => nativeTools.dispatch(args, options);
|
|
509
754
|
|
|
755
|
+
const hardened = hardenedBridgeEnabled();
|
|
756
|
+
if (hardened && !desktopTasksConfigured()) throw new Error("CODEX_BRIDGE_HARDENED=1 requires CODEX_BRIDGE_DESKTOP_TASKS=1; legacy relay listeners are disabled");
|
|
510
757
|
const relay = new RelaySocketServer({
|
|
511
758
|
socketPath: relaySocketPath(),
|
|
512
759
|
dispatch,
|
|
@@ -521,12 +768,15 @@ if (invokedDirectly) {
|
|
|
521
768
|
const accountRelay = new RelaySocketServer({
|
|
522
769
|
socketPath: accountRelaySocketPath(),
|
|
523
770
|
requireAccountContext: true,
|
|
771
|
+
strict: hardened,
|
|
524
772
|
dispatch,
|
|
525
773
|
dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options),
|
|
774
|
+
authorize: hardened ? createNativeScopeAuthorizer({ dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options) }) : undefined,
|
|
526
775
|
log,
|
|
527
776
|
});
|
|
528
777
|
const runtime = createRuntimeState();
|
|
529
|
-
const
|
|
778
|
+
const strictDesktop = hardened && desktopTasksConfigured();
|
|
779
|
+
const lifecycle = createNativeRelayLifecycle({ nativeTools, relays: strictDesktop ? [accountRelay] : [relay, desktopRelay, accountRelay], log });
|
|
530
780
|
const reload = createReloadControl({ entry: "native-relay-companion.mjs", ...lifecycle });
|
|
531
781
|
|
|
532
782
|
mcp.registerTool(
|
|
@@ -543,7 +793,7 @@ if (invokedDirectly) {
|
|
|
543
793
|
},
|
|
544
794
|
},
|
|
545
795
|
async () => reload.run(async () => {
|
|
546
|
-
const listening = await relay.isListening();
|
|
796
|
+
const listening = await (strictDesktop ? accountRelay : relay).isListening();
|
|
547
797
|
let executor = "(unconfigured)";
|
|
548
798
|
try {
|
|
549
799
|
const resolved = resolveRelayThreadId();
|
|
@@ -560,8 +810,8 @@ if (invokedDirectly) {
|
|
|
560
810
|
`platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
|
|
561
811
|
`companion: codex-native-relay ${VERSION}`,
|
|
562
812
|
`runtime state: ${runtime.status().current ? "current" : "stale"} (${reload.inspect().phase}, PID ${process.pid})`,
|
|
563
|
-
|
|
564
|
-
|
|
813
|
+
`relay socket: ${strictDesktop ? "disabled by hardened Desktop profile" : relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
|
|
814
|
+
`desktop tasks: ${strictDesktop ? "disabled by hardened Desktop profile" : `${desktopRelay.socketPath} (${await desktopRelay.isListening() ? "listening" : "not listening"})`}`,
|
|
565
815
|
`account relay: ${accountRelay.socketPath} (protocol ${ACCOUNT_RELAY_PROTOCOL_VERSION}, ${await accountRelay.isListening() ? "listening" : "not listening"})`,
|
|
566
816
|
`executor: ${executor}`,
|
|
567
817
|
`dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
|
|
@@ -577,5 +827,5 @@ if (invokedDirectly) {
|
|
|
577
827
|
mcp.server.onclose = () => { void lifecycle.stop(); };
|
|
578
828
|
await mcp.connect(new StdioServerTransport());
|
|
579
829
|
reload.listen();
|
|
580
|
-
log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
|
|
830
|
+
log(`ready on ${PLATFORM_LABEL} (${strictDesktop ? accountRelay.socketPath : relay.started ? relay.socketPath : "socket down"})`);
|
|
581
831
|
}
|
package/src/native-relay.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
|
|
|
7
7
|
|
|
8
8
|
import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
|
|
9
9
|
import { assertAccountIdentity } from "./bridge-account-context.mjs";
|
|
10
|
+
import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The Codex Desktop app owns the per-thread writer lock of every thread it has
|
|
@@ -793,6 +794,7 @@ export class NativeDesktopRelay {
|
|
|
793
794
|
|
|
794
795
|
async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs, beforeSend, accountContext } = {}) {
|
|
795
796
|
const accounts = relayAccountContext(accountContext);
|
|
797
|
+
if (hardenedBridgeEnabled(this.env) && !accounts) throw beforeWriteError(new NativeRelayError("Hardened relay requires protocol 2 account context", "RELAY_BAD_REQUEST"));
|
|
796
798
|
const request = { v: accounts ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, targetThreadId, message, ...(accounts ? { accountContext: accounts } : {}) };
|
|
797
799
|
return this.#request(request, timeoutMs, beforeSend);
|
|
798
800
|
}
|
|
@@ -800,6 +802,7 @@ export class NativeDesktopRelay {
|
|
|
800
802
|
async requestDesktop(operation, args, { timeoutMs = this.timeoutMs, beforeSend, accountContext } = {}) {
|
|
801
803
|
validateDesktopOperation(operation, args);
|
|
802
804
|
const accounts = relayAccountContext(accountContext);
|
|
805
|
+
if (hardenedBridgeEnabled(this.env) && !accounts) throw beforeWriteError(new NativeRelayError("Hardened relay requires protocol 2 account context", "RELAY_BAD_REQUEST"));
|
|
803
806
|
return this.#request({ v: accounts ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, operation, arguments: args, ...(accounts ? { accountContext: accounts } : {}) }, timeoutMs, beforeSend);
|
|
804
807
|
}
|
|
805
808
|
|