@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.
@@ -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.0";
37
+ const VERSION = "1.17.0";
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 }, { accountContext });
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
- }, { accountContext });
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: decodeNativeToolResult(nativeResult),
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
- async start() {
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
- this.restrictSocket(this.socketPath);
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
- try {
210
- this.server.close();
211
- } catch {}
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();
@@ -390,11 +635,6 @@ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () =>
390
635
  } catch (err) {
391
636
  if (stopped) return;
392
637
  if (!nativeConnected) nativeTools.close();
393
- if (!nativeTools.socketPath && !nativeTools.hasDiscoveredSocket) {
394
- logFn(`native relay unavailable (${err.message})`);
395
- resolveReady(false);
396
- return;
397
- }
398
638
  logFn(`native relay unavailable (${err.message}); retrying in ${delayMs}ms`);
399
639
  timer = globalThis.setTimeout(() => {
400
640
  timer = null;
@@ -507,6 +747,8 @@ if (invokedDirectly) {
507
747
  const nativeTools = new NativeToolsClient();
508
748
  const dispatch = (args, options) => nativeTools.dispatch(args, options);
509
749
 
750
+ const hardened = hardenedBridgeEnabled();
751
+ if (hardened && !desktopTasksConfigured()) throw new Error("CODEX_BRIDGE_HARDENED=1 requires CODEX_BRIDGE_DESKTOP_TASKS=1; legacy relay listeners are disabled");
510
752
  const relay = new RelaySocketServer({
511
753
  socketPath: relaySocketPath(),
512
754
  dispatch,
@@ -521,12 +763,15 @@ if (invokedDirectly) {
521
763
  const accountRelay = new RelaySocketServer({
522
764
  socketPath: accountRelaySocketPath(),
523
765
  requireAccountContext: true,
766
+ strict: hardened,
524
767
  dispatch,
525
768
  dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options),
769
+ authorize: hardened ? createNativeScopeAuthorizer({ dispatchDesktop: (args, options) => nativeTools.dispatchDesktop(args, options) }) : undefined,
526
770
  log,
527
771
  });
528
772
  const runtime = createRuntimeState();
529
- const lifecycle = createNativeRelayLifecycle({ nativeTools, relays: [relay, desktopRelay, accountRelay], log });
773
+ const strictDesktop = hardened && desktopTasksConfigured();
774
+ const lifecycle = createNativeRelayLifecycle({ nativeTools, relays: strictDesktop ? [accountRelay] : [relay, desktopRelay, accountRelay], log });
530
775
  const reload = createReloadControl({ entry: "native-relay-companion.mjs", ...lifecycle });
531
776
 
532
777
  mcp.registerTool(
@@ -543,7 +788,7 @@ if (invokedDirectly) {
543
788
  },
544
789
  },
545
790
  async () => reload.run(async () => {
546
- const listening = await relay.isListening();
791
+ const listening = await (strictDesktop ? accountRelay : relay).isListening();
547
792
  let executor = "(unconfigured)";
548
793
  try {
549
794
  const resolved = resolveRelayThreadId();
@@ -560,8 +805,8 @@ if (invokedDirectly) {
560
805
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
561
806
  `companion: codex-native-relay ${VERSION}`,
562
807
  `runtime state: ${runtime.status().current ? "current" : "stale"} (${reload.inspect().phase}, PID ${process.pid})`,
563
- `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
564
- `desktop tasks: ${desktopRelay.socketPath} (${await desktopRelay.isListening() ? "listening" : "not listening"})`,
808
+ `relay socket: ${strictDesktop ? "disabled by hardened Desktop profile" : relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
809
+ `desktop tasks: ${strictDesktop ? "disabled by hardened Desktop profile" : `${desktopRelay.socketPath} (${await desktopRelay.isListening() ? "listening" : "not listening"})`}`,
565
810
  `account relay: ${accountRelay.socketPath} (protocol ${ACCOUNT_RELAY_PROTOCOL_VERSION}, ${await accountRelay.isListening() ? "listening" : "not listening"})`,
566
811
  `executor: ${executor}`,
567
812
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
@@ -577,5 +822,5 @@ if (invokedDirectly) {
577
822
  mcp.server.onclose = () => { void lifecycle.stop(); };
578
823
  await mcp.connect(new StdioServerTransport());
579
824
  reload.listen();
580
- log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
825
+ log(`ready on ${PLATFORM_LABEL} (${strictDesktop ? accountRelay.socketPath : relay.started ? relay.socketPath : "socket down"})`);
581
826
  }
@@ -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