@minhspark/codex-mcp-bridge 1.27.0 → 1.27.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.27.0",
3
+ "version": "1.27.2",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.27.2] - 2026-11-05
6
+
7
+ ### Fixed
8
+
9
+ - The native relay and the Antigravity sidecar now recover when an owner is killed between publishing its socket and removing the private name used to create it. That left the socket with a second hard link, which the safety check refused to remove, so every later start failed and `codex_bridge_status` kept reporting `RELAY_UNREACHABLE` with `ECONNREFUSED`, even after restarting Codex Desktop. Once a socket has refused a connection, a replacement removes only hard links of that same socket named like a private socket and owned by the same user. A socket with any other hard link is still refused, and a live owner that is confirmed by the probe is left alone. The removal is now bound to the socket that was probed, so an owner that replaces it during the probe keeps its socket. The native relay then retries, and the Antigravity sidecar start fails without touching that owner.
10
+
11
+ ### Verification and limits
12
+
13
+ - Reproduced on macOS: Codex Desktop killed a relay companion that exceeded its 30-second startup limit under heavy load, and the relay stayed down until the extra link was removed by hand. Regression tests kill a real process inside that window, and also cover a live owner and a foreign hard link.
14
+ - Private socket names left unlinked by earlier crashes stay in `~/.codex`. They are harmless and are not removed, because a live companion may be using a name of that form.
15
+ - A running supervised companion loads the new version once idle and then recovers on its own; a companion without a supervisor needs its host to reconnect. This reload was checked with a test harness, not on a machine stuck in this state with live Codex Desktop.
16
+ - In the native relay, a liveness probe that fails or times out under heavy load still counts as a dead owner. That existing limit is unchanged and now also covers an owner still inside the brief link step. The Antigravity sidecar treats a timeout as a live owner.
17
+ - `codex-native-relay-install --remove` still stops with `Unsafe relay socket file` on a socket in this state. Starting a companion recovers it, or the leftover private name can be removed by hand.
18
+ - Upgrade with `npm install -g @minhspark/codex-mcp-bridge@latest --allow-scripts=@minhspark/codex-mcp-bridge --foreground-scripts`.
19
+
20
+ ## [1.27.1] - 2026-11-05
21
+
22
+ ### Fixed
23
+
24
+ - `inspect_bridge_project` no longer reports `needs_setup` or recommends installing a project policy when the bridge process already authorizes the directory by another supported mode. `communicationAuthorization` now reports the effective `mode` and result for the calling process (`project-policy`, `allowed-roots`, `all-directories`, `pinned-roots`, `no-root-restriction`), and a new `projectPolicy` field reports the policy file separately. Installing a policy appears as `projectPolicy.optionalMigration` for an authorized directory unless policy mode is actually in force. A configured policy stays authoritative and still fails closed. Under thread policy `owned`, `existingTasks` and the detail state that existing tasks stay unreachable until they are allowlisted or the policy changes. `bridge-projects inspect` now reports `not_evaluated`, because it reads only the policy file. Hardened deployments can now be inspected through MCP without being prepared or migrated, and inspection of a directory outside their pinned roots returns only the refusal.
25
+ - Inspection now marks the same-project and new-task checks as `not_checked` and explains that a grant or allowed root does not replace them. A directory outside the allowed roots asks for an owner decision instead of a policy.
26
+ - `codex_bridge_status({cwd})` reports `authorizationMode`, `existingTasks` and, for a verified sender in the same project, `readiness.target.savedProject` (`matched`, `absent`, `rejected`, `ambiguous`, `unresolved`, `not_checked`) for creating a new Codex Desktop task. It checks the same resolved directory a delegation uses, so a mapped path can now report its resolved target. Outside hardened deployments this does not change `ready`, because sending to an existing task does not need a saved project. In `pinned-roots` mode every native operation needs one, so an unusable result makes `ready` false with `NATIVE_SCOPE_UNVERIFIED`. A refusal for a directory outside the allowed roots no longer points at the project policy.
27
+ - A failed saved-project match now separates an absent project from one rejected for its kind, host or missing ID, and counts saved projects that could not be compared, with code `DESKTOP_PROJECT_UNMATCHED`. The message does not name other saved projects.
28
+
29
+ ### Verification and limits
30
+
31
+ - Reproduced on v1.27.0 with `CODEX_BRIDGE_THREAD_POLICY=roots` and no policy: inspection said `needs_setup` while `codex_bridge_status` said `authorized=true` for the same directory.
32
+ - Delivery enforcement is unchanged: authorization, same-project, receipt and project-match decisions make the same choices as before. Only diagnostics, one error code and the message text change. Without a policy, the Codex-to-Claude bridge has no directory allowlist and now says so.
33
+ - The cause of the reported `No saved local Codex Desktop project` refusal is still unknown. The new status and message should narrow it down on the next occurrence; this release does not change which projects match.
34
+ - Upgrade with `npm install -g @minhspark/codex-mcp-bridge@latest --allow-scripts=@minhspark/codex-mcp-bridge --foreground-scripts`, then reconnect the MCP clients so they load the new version.
35
+
5
36
  ## [1.27.0] - 2026-11-05
6
37
 
7
38
  ### Added
@@ -102,6 +102,60 @@ node scripts/bridge-projects.mjs inspect /absolute/projects/example
102
102
  node scripts/bridge-projects.mjs prepare /absolute/projects/example
103
103
  ```
104
104
 
105
+ Through MCP, the inspection reports what the calling bridge process actually
106
+ enforces. Read `communicationAuthorization` for the effective result and
107
+ `projectPolicy` for the state of the shared policy file:
108
+
109
+ | `communicationAuthorization.mode` | Decided by | Policy needed for native delivery |
110
+ |---|---|---|
111
+ | `project-policy` | `CODEX_BRIDGE_PROJECT_POLICY` grants and revocations | Yes |
112
+ | `allowed-roots` | `CODEX_BRIDGE_ALLOWED_ROOTS` in the Claude-to-Codex bridge | No |
113
+ | `all-directories` | `CODEX_BRIDGE_ALLOWED_ROOTS` allowing every directory | No |
114
+ | `pinned-roots` | A hardened deployment's pinned roots | Not accepted |
115
+ | `no-root-restriction` | No directory allowlist in the Codex-to-Claude bridge | No |
116
+ | `not_evaluated` | `bridge-projects inspect`, which reads only the policy file | Unknown |
117
+
118
+ `status: "configured"` needs workspace trust and an authorized directory. A
119
+ missing policy file becomes a prerequisite only in `project-policy` mode, where
120
+ `nextActions` lists it. In the `allowed-roots`, `all-directories` and
121
+ `no-root-restriction` modes, `projectPolicy.optionalMigration` describes an
122
+ optional move to the shared policy for an authorized directory, which needs the
123
+ user's request. Independent background workers keep their own explicit grant;
124
+ check `worker_status` for them.
125
+
126
+ An authorized directory does not always make existing tasks reachable. In the
127
+ Claude-to-Codex bridge, `existingTasks: "owned-or-allowlisted"` means thread
128
+ policy `owned` is in force: new tasks can be created, but tasks this bridge did
129
+ not create need `CODEX_BRIDGE_THREAD_POLICY=roots` or an allowlisted thread ID.
130
+
131
+ A hardened deployment can be inspected through MCP but not prepared or migrated.
132
+ For a directory outside its pinned roots, inspection returns only the refusal.
133
+
134
+ `liveChecks` stays `not_checked` because a saved configuration cannot prove a
135
+ live caller. Claude-to-Codex delivery still requires the verified sender and
136
+ destination to share a project, and neither a grant nor an allowed root replaces
137
+ that. `codex_bridge_status({cwd})` checks it, and also reports
138
+ `readiness.target.savedProject` for creating a new task:
139
+
140
+ | `savedProject.status` | Meaning |
141
+ |---|---|
142
+ | `matched` | One eligible saved project has this exact directory |
143
+ | `absent` | No saved project has this exact path |
144
+ | `rejected` | A saved project has this path but its kind, host or ID makes it ineligible |
145
+ | `ambiguous` | More than one eligible saved project has this path |
146
+ | `unresolved` | The requested directory could not be resolved |
147
+ | `not_checked` | The native project list was unavailable |
148
+
149
+ The result is shown only after a verified sender and the destination are
150
+ confirmed to share a project; otherwise it stays `not_checked`. The status checks
151
+ the same resolved directory a delegation would use.
152
+
153
+ Outside hardened deployments this status does not change `ready`, because sending
154
+ to an existing task does not need a saved-project match. In `pinned-roots` mode
155
+ every native operation needs one, so an unusable result makes `ready` false with
156
+ `NATIVE_SCOPE_UNVERIFIED`. A failed creation reports the same reason with code
157
+ `DESKTOP_PROJECT_UNMATCHED`, without naming other saved projects.
158
+
105
159
  Changes are backed up and verified. Concurrent changes abort; failed rollback
106
160
  returns `partial_needs_review` and remaining files. `configured` does not mean
107
161
  live communication passed. Project preparation does not approve tools, change
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.27.0",
3
+ "version": "1.27.2",
4
4
  "description": "Connect Claude, Codex, and Antigravity through MCP, with native Desktop handoffs and bridge-owned Antigravity CLI sessions.",
5
5
  "keywords": [
6
6
  "mcp",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.27.0",
4
+ "version": "1.27.2",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -25,7 +25,7 @@ import { removeRelayFile } from "../src/native-relay-files.mjs";
25
25
 
26
26
  exitForVersionRequest(import.meta.url);
27
27
 
28
- const VERSION = "1.27.0";
28
+ const VERSION = "1.27.2";
29
29
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
30
30
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
31
31
  const entryArgs = ["native-relay-companion.mjs"];
@@ -4,7 +4,7 @@ import net from "node:net";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
7
- import { relayFileIdentity, removeRelayFile } from "./native-relay-files.mjs";
7
+ import { releasePrivateSocketLinks, relayFileIdentity, removeRelayFile, staleSocketIdentity } from "./native-relay-files.mjs";
8
8
  import { bridgeDiagnostic } from "./recipient-preflight.mjs";
9
9
 
10
10
  const LIMIT = 2 * 1024 * 1024;
@@ -131,9 +131,12 @@ export async function serveAntigravityDesktop(handler, { directory = antigravity
131
131
  try { fs.linkSync(privatePath, socketPath); }
132
132
  catch (error) {
133
133
  if (error.code !== "EEXIST") throw error;
134
- const existing = relayFileIdentity(socketPath, "socket");
135
- if (!await socketRefused(socketPath)) throw new Error("Another Antigravity sidecar owns the socket");
136
- removeRelayFile(socketPath, { expected: existing });
134
+ const existing = staleSocketIdentity(socketPath);
135
+ if (existing !== null) {
136
+ if (!await socketRefused(socketPath)) throw new Error("Another Antigravity sidecar owns the socket");
137
+ releasePrivateSocketLinks(socketPath, existing);
138
+ removeRelayFile(socketPath, { expected: existing });
139
+ }
137
140
  fs.linkSync(privatePath, socketPath);
138
141
  }
139
142
  fs.unlinkSync(privatePath);
@@ -1,6 +1,16 @@
1
1
  import { RELAY_DOWN_GUIDANCE } from "./thread-delivery.mjs";
2
2
 
3
- export function bridgeReadiness({ sender, scope, targetCwd, nativeAvailable = true, nativeDiagnostic, verifyTargetProject }) {
3
+ const SAVED_PROJECT_NOT_CHECKED = { status: "not_checked", detail: "The native project list was not available, so the exact saved project required to create a new task was not checked." };
4
+ const SAVED_PROJECT_UNVERIFIED = { status: "not_checked", detail: "The saved project is shown only after a verified sender and this destination are confirmed to share a project." };
5
+ const UNUSABLE_SAVED_PROJECT = new Set(["absent", "rejected", "ambiguous"]);
6
+
7
+ function outOfScopeGuidance(scope) {
8
+ return scope?.status
9
+ ? "Authorize this project or its parent directory once in the shared project policy."
10
+ : "This bridge authorizes by CODEX_BRIDGE_ALLOWED_ROOTS. Ask the user whether this directory belongs in scope; never widen scope to work around a refusal.";
11
+ }
12
+
13
+ export function bridgeReadiness({ sender, scope, targetCwd, nativeAvailable = true, nativeDiagnostic, verifyTargetProject, authorizationMode, existingTasks, savedProject }) {
4
14
  const issues = [];
5
15
  if (sender?.status !== "verified") issues.push({ code: "SENDER_UNVERIFIED", detail: sender?.reason ?? "No current Code session identity", action: "Load the user-level Claude Code bridge entry in this existing task; a shared Desktop entry cannot identify the caller. Run the Code registration check, then reload the affected client." });
6
16
  const projectPolicy = scope?.status?.(sender?.status === "verified" ? sender.cwd : undefined) ?? null;
@@ -11,7 +21,7 @@ export function bridgeReadiness({ sender, scope, targetCwd, nativeAvailable = tr
11
21
  const binding = scope.capture?.(targetCwd);
12
22
  target = { ...(binding ?? {}), cwd: binding?.path ?? scope.assert(targetCwd), authorized: true };
13
23
  }
14
- catch (error) { target = { cwd: targetCwd, authorized: false }; issues.push({ code: "TARGET_NOT_AUTHORIZED", detail: error.message, action: "Authorize this project or its parent directory once in the shared project policy." }); }
24
+ catch (error) { target = { cwd: targetCwd, authorized: false }; issues.push({ code: "TARGET_NOT_AUTHORIZED", detail: error.message, action: outOfScopeGuidance(scope) }); }
15
25
  }
16
26
  if (target?.authorized && sender?.status === "verified" && verifyTargetProject) {
17
27
  try { verifyTargetProject(sender.cwd, target.cwd); target.sameProject = true; }
@@ -20,6 +30,10 @@ export function bridgeReadiness({ sender, scope, targetCwd, nativeAvailable = tr
20
30
  issues.push({ code: error.code ?? "PROJECT_SCOPE_UNVERIFIED", detail: error.message, action: "Select a destination in the verified sender's project. A project grant does not authorize cross-project delivery." });
21
31
  }
22
32
  }
33
+ if (target?.authorized) target.savedProject = sender?.status === "verified" && target.sameProject === true ? savedProject ?? SAVED_PROJECT_NOT_CHECKED : SAVED_PROJECT_UNVERIFIED;
34
+ if (authorizationMode === "pinned-roots" && UNUSABLE_SAVED_PROJECT.has(target?.savedProject?.status)) {
35
+ issues.push({ code: "NATIVE_SCOPE_UNVERIFIED", detail: target.savedProject.detail, action: "This hardened deployment requires exactly one saved local project at the task's directory for every native operation. Ask the user to save or correct that project; never change the destination to work around a refusal." });
36
+ }
23
37
  if (!nativeAvailable) issues.push({ code: nativeDiagnostic?.code ?? "RELAY_UNAVAILABLE", detail: nativeDiagnostic?.reason, action: RELAY_DOWN_GUIDANCE, ...(nativeDiagnostic ? { diagnostic: nativeDiagnostic } : {}) });
24
- return { ready: issues.length === 0, readyScope: "transport-identity-and-project", hostToolPermission: "evaluated-by-client-at-call-time", sender, projectPolicy, target, issues };
38
+ return { ready: issues.length === 0, readyScope: "transport-identity-and-project", hostToolPermission: "evaluated-by-client-at-call-time", sender, ...(authorizationMode ? { authorizationMode } : {}), ...(existingTasks ? { existingTasks } : {}), projectPolicy, target, issues };
25
39
  }
@@ -23,7 +23,7 @@ import { readClaudeAccountContext } from "./desktop-account-context.mjs";
23
23
  import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
24
24
  import { recoverClaudeDesktopSession, resolveClaudeDesktopSession, sameClaudeDesktopRecipient } from "./claude-session-router.mjs";
25
25
  import { submitClaudeCodeComposer, trustClaudeCodeComposer } from "./claude-composer-submit.mjs";
26
- import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
26
+ import { createHardenedRootPolicy, rootPolicyAccessScope } from "./hardened-root-policy.mjs";
27
27
  import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
28
28
  import { registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
29
29
  import { discoverClaudeProject } from "./claude-project-discovery.mjs";
@@ -31,7 +31,7 @@ import { BACKGROUND_WORKER_GUIDANCE, registerBackgroundTools } from "./backgroun
31
31
 
32
32
  exitForVersionRequest(import.meta.url);
33
33
 
34
- const VERSION = "1.27.0";
34
+ const VERSION = "1.27.2";
35
35
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
36
36
  const FORWARD_MIN_INTERVAL_MS = 5000;
37
37
  const FORWARD_MAX_PER_SESSION = 50;
@@ -349,7 +349,7 @@ function registerTool(name, definition, handler) {
349
349
 
350
350
  registerBackgroundTools(registerTool);
351
351
 
352
- registerProjectOnboardingTools(registerTool, { options: { policyRegistration: "codex" }, beforePrepare: async (extra) => {
352
+ registerProjectOnboardingTools(registerTool, { options: { policyRegistration: "codex", evaluateAuthorization: (cwd) => rootPolicyAccessScope(rootPolicy, cwd) ?? { mode: "no-root-restriction", authorized: true } }, beforePrepare: async (extra) => {
353
353
  runtime.assertCurrent();
354
354
  if (!desktopOnly) throw new Error("Project onboarding requires a verified Desktop caller");
355
355
  await assertSender(extra?._meta);
@@ -98,3 +98,9 @@ export function createHardenedRootPolicy(env = process.env) {
98
98
  }
99
99
 
100
100
  export function hardenedBridgeEnabled(env = process.env) { return enabled(env); }
101
+
102
+ export function rootPolicyAccessScope(policy, cwd) {
103
+ if (policy.mode === "project-policy") return { mode: "project-policy", authorized: policy.allows(cwd) };
104
+ if (policy.enabled) return { mode: "pinned-roots", authorized: policy.allows(cwd) };
105
+ return null;
106
+ }
package/src/index.mjs CHANGED
@@ -44,7 +44,7 @@ import { BACKGROUND_WORKER_GUIDANCE, registerBackgroundTools } from "./backgroun
44
44
 
45
45
  exitForVersionRequest(import.meta.url);
46
46
 
47
- const VERSION = "1.27.0";
47
+ const VERSION = "1.27.2";
48
48
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
49
49
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
50
50
 
@@ -533,7 +533,7 @@ registerDesktopReadTools(registerTool, {
533
533
  assertThreadAccess, normalizeThreadCwd, textResult, replyResult, failure,
534
534
  });
535
535
 
536
- registerProjectOnboardingTools(registerTool, { options: { policyRegistration: "claude-code" }, beforePrepare: async () => {
536
+ registerProjectOnboardingTools(registerTool, { options: { policyRegistration: "claude-code", evaluateAuthorization: (cwd) => security.effectiveAccessScope(cwd) }, beforePrepare: async () => {
537
537
  if (!desktopTasksEnabled || !desktopOperation.getStore()?.caller) throw new Error("Project onboarding requires a verified Desktop caller.");
538
538
  await assertDesktopOperation(desktopOperation.getStore(), { verifyProcess: true });
539
539
  } });
@@ -1040,9 +1040,12 @@ registerTool(
1040
1040
  async ({ cwd }) => {
1041
1041
  const summary = security.summary();
1042
1042
  if (desktopTasksEnabled) {
1043
- const native = await desktopTasks.status();
1043
+ const targetCwd = cwd ? resolveWorkspacePath(cwd).path : undefined;
1044
+ const authorization = security.effectiveAccessScope(targetCwd);
1045
+ const native = await desktopTasks.status({ cwd: targetCwd });
1044
1046
  const sender = await readClaudeSenderContext({ account: readBridgeAccounts().claude });
1045
- const readiness = bridgeReadiness({ sender, scope: security.hardenedRoots.mode === "project-policy" ? security.hardenedRoots : { assert: (candidate) => { security.assertCwd(candidate); return candidate; } }, targetCwd: cwd, nativeAvailable: native.available, nativeDiagnostic: native.diagnostic, verifyTargetProject: captureProjectScope });
1047
+ const readiness = bridgeReadiness({ sender, scope: security.hardenedRoots.mode === "project-policy" ? security.hardenedRoots : { assert: (candidate) => { security.assertCwd(candidate); return candidate; } }, targetCwd, nativeAvailable: native.available, nativeDiagnostic: native.diagnostic, verifyTargetProject: captureProjectScope, authorizationMode: authorization.mode, existingTasks: authorization.existingTasks, savedProject: native.targetProject });
1048
+ const { targetProject: _targetProject, ...nativeStatus } = native;
1046
1049
  return { ...textResult([
1047
1050
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
1048
1051
  `bridge version: ${VERSION}`,
@@ -1058,9 +1061,11 @@ registerTool(
1058
1061
  `claude desktop config: ${claudeDesktopConfigPath()}`,
1059
1062
  `sender context: ${sender.status}${sender.taskId ? `; task ${sender.taskId}` : ""}`,
1060
1063
  ...(readiness.projectPolicy ? [`project policy: ${readiness.projectPolicy.file}; revision ${readiness.projectPolicy.revision ?? "unavailable"}; changes apply on the next operation`] : []),
1064
+ `authorization: ${readiness.authorizationMode}; existing tasks ${readiness.existingTasks}`,
1061
1065
  ...(readiness.target ? [`target: ${readiness.target.cwd}; authorized=${readiness.target.authorized}${readiness.target.authorizedBy ? `; via ${readiness.target.authorizedBy.kind} ${readiness.target.authorizedBy.path}` : ""}`] : []),
1066
+ ...(readiness.target?.savedProject ? [`new task target: ${readiness.target.savedProject.status}; ${readiness.target.savedProject.detail}`] : []),
1062
1067
  ...readiness.issues.map((issue) => `${issue.code}: ${issue.detail ?? ""} ${issue.action}`),
1063
- ].join("\n"), !readiness.ready), structuredContent: { readiness, native, ...(native.diagnostic ? { diagnostic: native.diagnostic } : !readiness.ready ? { error: { code: readiness.issues[0]?.code, message: readiness.issues[0]?.detail } } : {}), projectScope: { enabled: true, policy: "verified-sender-same-project", match: "canonical-directory-or-git-repository", checkedBeforeMutation: true } } };
1068
+ ].join("\n"), !readiness.ready), structuredContent: { readiness, native: nativeStatus, ...(native.diagnostic ? { diagnostic: native.diagnostic } : !readiness.ready ? { error: { code: readiness.issues[0]?.code, message: readiness.issues[0]?.detail } } : {}), projectScope: { enabled: true, policy: "verified-sender-same-project", match: "canonical-directory-or-git-repository", checkedBeforeMutation: true } } };
1064
1069
  }
1065
1070
  const up = await client.isServerUp();
1066
1071
  let liveThreads = null;
@@ -35,7 +35,7 @@ import { createRuntimeState } from "./runtime-state.mjs";
35
35
  import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
36
36
  import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
37
37
  import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
38
- import { removeRelayFile, relayFileIdentity, verifyRelaySocketMode } from "./native-relay-files.mjs";
38
+ import { releasePrivateSocketLinks, removeRelayFile, relayFileIdentity, staleSocketIdentity, verifyRelaySocketMode } from "./native-relay-files.mjs";
39
39
 
40
40
  import { NativeCreationReceipts, creationIdValid } from "./native-creation-receipts.mjs";
41
41
  import { registerRemoteCodexTools } from "./remote-codex-tools.mjs";
@@ -43,7 +43,7 @@ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } fro
43
43
 
44
44
  exitForVersionRequest(import.meta.url);
45
45
 
46
- const VERSION = "1.27.0";
46
+ const VERSION = "1.27.2";
47
47
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
48
48
 
49
49
  function errorResponse(code, message, sent, details) {
@@ -565,9 +565,13 @@ export class RelaySocketServer {
565
565
  try { fs.linkSync(privatePath, this.socketPath); break; }
566
566
  catch (error) {
567
567
  if (error.code !== "EEXIST") throw error;
568
- if (!replaceStale || attempt || await this.#socketIsLive()) throw new Error(`another native relay companion already owns ${this.socketPath}`);
568
+ if (!replaceStale || attempt) throw new Error(`another native relay companion already owns ${this.socketPath}`);
569
+ const stale = staleSocketIdentity(this.socketPath);
570
+ if (stale === null) continue;
571
+ if (await this.#socketIsLive()) throw new Error(`another native relay companion already owns ${this.socketPath}`);
569
572
  this.log(`removing the stale relay socket left at ${this.socketPath}`);
570
- removeRelayFile(this.socketPath);
573
+ releasePrivateSocketLinks(this.socketPath, stale);
574
+ removeRelayFile(this.socketPath, { expected: stale });
571
575
  }
572
576
  }
573
577
  const identity = fs.lstatSync(this.socketPath, { bigint: true });
@@ -17,6 +17,49 @@ export function relayFileIdentity(target, kind, expected) {
17
17
  return current;
18
18
  }
19
19
 
20
+ const PRIVATE_SOCKET_LINK = /^\.[0-9a-f]{12}\.sock$/;
21
+
22
+ const ownedSocket = (stat) => stat.isSocket() && (typeof process.getuid !== "function" || stat.uid === BigInt(process.getuid()));
23
+
24
+ export function staleSocketIdentity(target) {
25
+ let root;
26
+ try { root = fs.realpathSync.native(path.dirname(target)); }
27
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
28
+ const file = path.resolve(root, path.basename(target));
29
+ if (!file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`)) throw new Error("Relay file escaped its directory");
30
+ let current;
31
+ try { current = fs.lstatSync(file, { bigint: true }); }
32
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
33
+ if (!ownedSocket(current)) throw new Error(`Unsafe relay socket file: ${target}`);
34
+ return { dev: current.dev, ino: current.ino };
35
+ }
36
+
37
+ export function releasePrivateSocketLinks(target, expected) {
38
+ let root;
39
+ try { root = fs.realpathSync.native(path.dirname(target)); }
40
+ catch (error) { if (error.code === "ENOENT") return 0; throw error; }
41
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
42
+ const file = path.resolve(root, path.basename(target));
43
+ if (!file.startsWith(prefix)) throw new Error("Relay file escaped its directory");
44
+ let current;
45
+ try { current = fs.lstatSync(file, { bigint: true }); }
46
+ catch (error) { if (error.code === "ENOENT") return 0; throw error; }
47
+ if (current.nlink === 1n || !ownedSocket(current) || current.dev !== expected.dev || current.ino !== expected.ino) return 0;
48
+ let released = 0;
49
+ for (const name of fs.readdirSync(root)) {
50
+ if (!PRIVATE_SOCKET_LINK.test(name)) continue;
51
+ const link = path.resolve(root, name);
52
+ if (!link.startsWith(prefix)) continue;
53
+ let stat;
54
+ try { stat = fs.lstatSync(link, { bigint: true }); }
55
+ catch (error) { if (error.code === "ENOENT") continue; throw error; }
56
+ if (!ownedSocket(stat) || stat.dev !== current.dev || stat.ino !== current.ino) continue;
57
+ fs.unlinkSync(link);
58
+ released += 1;
59
+ }
60
+ return released;
61
+ }
62
+
20
63
  export function removeRelayFile(target, { kind = "socket", directory = path.dirname(target), expected } = {}) {
21
64
  let root;
22
65
  try { root = fs.realpathSync.native(directory); }
@@ -12,7 +12,7 @@ export function registerProjectOnboardingTools(register, { beforePrepare, option
12
12
  const failed = error => ({ content: [{ type: "text", text: error.message }], isError: true, structuredContent: { onboarding: error.onboarding ?? { status: "failed", connection: { status: "not_tested", ready: null } } } });
13
13
  register("inspect_bridge_project", {
14
14
  title: "Check project onboarding",
15
- description: "Read workspace trust and shared messaging authorization without changing files. Distinguishes configured files from untested live connections. Card extension authorization is a separate check. Does not send messages or create conversations.",
15
+ description: "Read workspace trust and this bridge process's effective messaging authorization without changing files. Reports the authorization mode separately from shared project-policy state; a missing policy is a prerequisite only when nextActions says so. Distinguishes configured files from untested live connections. Same-project and new-task checks are reported as not checked here. Card extension authorization is a separate check. Does not send messages or create conversations.",
16
16
  inputSchema: fields,
17
17
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
18
18
  }, async args => {
@@ -9,8 +9,8 @@ const object = value => value && typeof value === "object" && !Array.isArray(val
9
9
  const samePath = (a, b) => path.relative(a, b) === "";
10
10
  const inside = (a, b) => { const r = path.relative(a, b); return r === "" || (!path.isAbsolute(r) && r !== ".." && !r.startsWith(`..${path.sep}`)); };
11
11
 
12
- export function onboardingPaths(env = process.env, { requirePolicy = true } = {}) {
13
- if (env.CODEX_BRIDGE_HARDENED === "1") throw new Error("Project onboarding cannot change a hardened deployment's pinned roots.");
12
+ export function onboardingPaths(env = process.env, { requirePolicy = true, allowHardened = false } = {}) {
13
+ if (env.CODEX_BRIDGE_HARDENED === "1" && !allowHardened) throw new Error("Project onboarding cannot change a hardened deployment's pinned roots.");
14
14
  const policyFile = env.CODEX_BRIDGE_PROJECT_POLICY && path.isAbsolute(env.CODEX_BRIDGE_PROJECT_POLICY) ? env.CODEX_BRIDGE_PROJECT_POLICY : null;
15
15
  if (!policyFile && requirePolicy) throw new Error("Configure the shared CODEX_BRIDGE_PROJECT_POLICY before onboarding projects.");
16
16
  const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
@@ -74,12 +74,12 @@ function loadPolicy(file, project, options, tolerant) {
74
74
  }
75
75
 
76
76
  function load(cwd, options, { inspect = false } = {}) {
77
- const files = onboardingPaths(options.env, { requirePolicy: !inspect });
77
+ const files = onboardingPaths(options.env, { requirePolicy: !inspect, allowHardened: inspect && Boolean(options.evaluateAuthorization) });
78
78
  const project = target(cwd, { ...options, home: files.home });
79
79
  const configRaw = read(files.configFile);
80
80
  const config = parse(configRaw);
81
81
  const trust = trustEntry(config, project);
82
- return { files, project, configRaw, config, trust, policyRegistration: options.policyRegistration, ...loadPolicy(files.policyFile, project, options, inspect) };
82
+ return { files, project, configRaw, config, trust, policyRegistration: options.policyRegistration, evaluateAuthorization: options.evaluateAuthorization, ...loadPolicy(files.policyFile, project, options, inspect) };
83
83
  }
84
84
 
85
85
  const POLICY_REGISTRATION = {
@@ -87,28 +87,96 @@ const POLICY_REGISTRATION = {
87
87
  codex: file => `register that file by running claude-mcp-bridge-install with CODEX_BRIDGE_PROJECT_POLICY="${file}" set, then restart Codex Desktop once so this MCP server loads it`,
88
88
  };
89
89
 
90
- function policyActions(state) {
91
- if (!state.policyProblem) return [{ tool: "prepare_bridge_project", cwd: state.project.directory.path }];
90
+ const AUTHORIZATION_MODE_DETAIL = {
91
+ "allowed-roots": "This bridge process authorizes native delivery by CODEX_BRIDGE_ALLOWED_ROOTS.",
92
+ "all-directories": "CODEX_BRIDGE_ALLOWED_ROOTS allows every directory in this bridge process.",
93
+ "pinned-roots": "This hardened deployment authorizes native delivery by its pinned CODEX_BRIDGE_ALLOWED_ROOTS.",
94
+ "no-root-restriction": "This bridge process applies no directory allowlist without a project policy; CODEX_BRIDGE_ALLOWED_ROOTS is not consulted here.",
95
+ };
96
+
97
+ const POLICY_FILE_ONLY = "This result reads only the policy file and does not show what a running bridge enforces. Use inspect_bridge_project in the calling session before treating it as a prerequisite.";
98
+ const EXISTING_TASK_LIMIT = "Existing tasks this bridge did not create need CODEX_BRIDGE_THREAD_POLICY=roots or an allowlisted thread ID.";
99
+ const OUTSIDE_SCOPE_ACTION = { setting: "CODEX_BRIDGE_ALLOWED_ROOTS", ownerDecision: true, then: "This directory is outside this bridge's authorized scope. Ask the user whether it belongs in scope; changing the bridge registration needs their decision and a reconnect. Never widen scope to work around a refusal." };
100
+ const UNTESTED_CONNECTION = { status: "not_tested", ready: null, detail: "Saved configuration does not prove a live sender, target, tool permission or successful round trip." };
101
+ const joinDetail = (...parts) => parts.filter(Boolean).join(" ");
102
+
103
+ const LIVE_CHECKS = {
104
+ "claude-code": {
105
+ senderDestination: "Each native Codex Desktop delivery separately requires the verified Claude sender and the destination to share a directory or registered Git repository. A project grant or allowed root does not replace this. Check codex_bridge_status with this cwd in the calling session.",
106
+ creationTarget: "Creating a new Codex Desktop task separately requires exactly one saved local project at this exact directory. Check codex_bridge_status with this cwd.",
107
+ existingTasks: "Sending to an existing task does not need this match.",
108
+ pinnedExistingTasks: "In this hardened deployment, every native operation on an existing task needs the same match for that task's directory.",
109
+ },
110
+ codex: {
111
+ senderDestination: "Each Claude delivery separately verifies the Codex sender, the recipient session, its permission class and the accounts. A project grant does not replace these checks. Check claude_bridge_status in the calling task.",
112
+ creationTarget: "Creating a new Claude session is confirmed separately through read_claude_creation; this inspection does not open or check one.",
113
+ },
114
+ };
115
+
116
+ function communicationAuthorization(state) {
117
+ const file = state.files.policyFile;
118
+ const live = state.evaluateAuthorization?.(state.project.directory.path);
119
+ const threads = live?.existingTasks ? { threadPolicy: live.threadPolicy, existingTasks: live.existingTasks } : {};
120
+ const taskLimit = live?.existingTasks === "owned-or-allowlisted" ? EXISTING_TASK_LIMIT : "";
121
+ if (live && live.mode !== "project-policy") {
122
+ return { mode: live.mode, status: live.authorized ? "authorized" : "outside_allowed_roots", detail: joinDetail(AUTHORIZATION_MODE_DETAIL[live.mode], taskLimit), ...threads, file: null };
123
+ }
124
+ const decided = state.policyProblem ?? { status: state.authorized ? "authorized" : state.denials.length ? "revoked" : "missing" };
125
+ const detail = joinDetail(decided.detail, taskLimit, live ? "" : POLICY_FILE_ONLY);
126
+ return { mode: live?.mode ?? "not_evaluated", ...decided, ...(detail ? { detail } : {}), ...threads, file };
127
+ }
128
+
129
+ const usesPolicyForNativeDelivery = authorization => authorization.mode === "project-policy" || authorization.mode === "not_evaluated";
130
+
131
+ function suggestedPolicySetup(state) {
92
132
  const suggestedFile = path.join(state.files.home, ".config", "GptClaudeBridge", "projects.json");
93
- if (state.policyProblem.status === "policy_invalid") return [{ setting: "CODEX_BRIDGE_PROJECT_POLICY", file: state.files.policyFile, then: "Repair or restore this policy file from a backup; prepare_bridge_project does not overwrite an unreadable policy. Run inspect_bridge_project again afterwards." }];
94
133
  const registration = POLICY_REGISTRATION[state.policyRegistration ?? "claude-code"];
95
134
  if (!registration) throw new Error(`Unknown project policy registration: ${state.policyRegistration}`);
96
- return [{ setting: "CODEX_BRIDGE_PROJECT_POLICY", suggestedFile, then: `Run bridge-projects prepare "${state.project.directory.path}" --file "${suggestedFile}", ${registration(suggestedFile)}. prepare_bridge_project is available once the setting is loaded.` }];
135
+ return { setting: "CODEX_BRIDGE_PROJECT_POLICY", suggestedFile, then: `Run bridge-projects prepare "${state.project.directory.path}" --file "${suggestedFile}", ${registration(suggestedFile)}. prepare_bridge_project is available once the setting is loaded.` };
136
+ }
137
+
138
+ function projectPolicyState(state, authorization) {
139
+ const status = state.policyProblem?.status === "policy_invalid" ? "invalid" : !state.files.policyFile ? "not_configured" : state.policyRaw === null ? "file_missing" : "configured";
140
+ const canMigrate = status === "not_configured" && authorization.status === "authorized" && !usesPolicyForNativeDelivery(authorization) && authorization.mode !== "pinned-roots";
141
+ return {
142
+ status,
143
+ file: state.files.policyFile,
144
+ requiredForNativeDelivery: authorization.mode === "not_evaluated" ? null : authorization.mode === "project-policy",
145
+ backgroundWorkers: "Independent background workers use their own explicit project grant; check worker_status for them.",
146
+ ...(canMigrate ? { optionalMigration: { ...suggestedPolicySetup(state), detail: "Optional configuration migration for a shared project policy. It is not needed to restore native communication and needs the user's request." } } : {}),
147
+ };
148
+ }
149
+
150
+ function policyActions(state) {
151
+ if (!state.policyProblem) return [{ tool: "prepare_bridge_project", cwd: state.project.directory.path }];
152
+ if (state.policyProblem.status === "policy_invalid") return [{ setting: "CODEX_BRIDGE_PROJECT_POLICY", file: state.files.policyFile, then: "Repair or restore this policy file from a backup; prepare_bridge_project does not overwrite an unreadable policy. Run inspect_bridge_project again afterwards." }];
153
+ return [suggestedPolicySetup(state)];
154
+ }
155
+
156
+ function setupActions(state, authorization) {
157
+ if (usesPolicyForNativeDelivery(authorization)) return policyActions(state);
158
+ return [
159
+ ...(authorization.status === "authorized" ? [] : [OUTSIDE_SCOPE_ACTION]),
160
+ ...(state.trust.trusted ? [] : [{ setting: "workspaceTrust", ownerDecision: true, then: "Claude has not recorded workspace trust for this project. Ask the user to open it in Claude Code and accept the trust prompt." }]),
161
+ ];
97
162
  }
98
163
 
99
164
  function report(state, taskCards, extra = {}) {
100
- const configured = state.trust.trusted && state.authorized;
165
+ const authorization = communicationAuthorization(state);
166
+ const configured = state.trust.trusted && authorization.status === "authorized";
167
+ const liveChecks = LIVE_CHECKS[state.policyRegistration] ?? LIVE_CHECKS["claude-code"];
168
+ const existingTaskMatch = authorization.mode === "pinned-roots" ? liveChecks.pinnedExistingTasks : liveChecks.existingTasks;
101
169
  return {
102
170
  status: configured ? "configured" : "needs_setup",
103
171
  project: { cwd: state.project.directory.path, root: state.project.root.path, kind: state.project.repo ? "git" : "directory", repository: state.project.repo?.common.path ?? null },
104
172
  workspaceTrust: { status: state.trust.trusted ? "configured" : "missing", file: state.files.configFile, key: state.trust.key },
105
- communicationAuthorization: state.policyProblem
106
- ? { ...state.policyProblem, file: state.files.policyFile }
107
- : { status: state.authorized ? "authorized" : state.denials.length ? "revoked" : "missing", file: state.files.policyFile },
108
- connection: { status: "not_tested", ready: null, detail: "Saved configuration does not prove a live sender, target, tool permission or successful round trip." },
173
+ communicationAuthorization: authorization,
174
+ projectPolicy: projectPolicyState(state, authorization),
175
+ connection: UNTESTED_CONNECTION,
176
+ liveChecks: { senderDestination: { status: "not_checked", detail: liveChecks.senderDestination }, creationTarget: { status: "not_checked", detail: joinDetail(liveChecks.creationTarget, existingTaskMatch) } },
109
177
  taskCards: { status: taskCards ? "requires_extension_check" : "not_requested", detail: "The card extension owns its authorization and connection; this tool does not edit its settings or restart it." },
110
178
  nextActions: [
111
- ...(!configured ? policyActions(state) : []),
179
+ ...(!configured ? setupActions(state, authorization) : []),
112
180
  ...(taskCards ? [{ component: "Local Card Bridge Desktop", tool: "list_projects", cwd: state.project.root.path, then: "If absent, use authorize_project for this exact project under the user's onboarding request; verify desktop_bridge_info after saving. Stop on any denial or disconnected extension." }] : []),
113
181
  { tools: ["claude_bridge_status", "codex_bridge_status"], then: "Check in the actual calling sessions, then perform one authorized round trip in each direction. Do not create a new conversation without user authorization." },
114
182
  ],
@@ -116,7 +184,18 @@ function report(state, taskCards, extra = {}) {
116
184
  };
117
185
  }
118
186
 
187
+ function outsidePinnedRoots() {
188
+ return {
189
+ status: "needs_setup",
190
+ communicationAuthorization: { mode: "pinned-roots", status: "outside_allowed_roots", detail: AUTHORIZATION_MODE_DETAIL["pinned-roots"], file: null },
191
+ connection: UNTESTED_CONNECTION,
192
+ nextActions: [OUTSIDE_SCOPE_ACTION],
193
+ };
194
+ }
195
+
119
196
  export function inspectBridgeProject({ cwd, taskCards = false }, options = {}) {
197
+ const requested = options.evaluateAuthorization?.(cwd);
198
+ if (requested?.mode === "pinned-roots" && !requested.authorized) return outsidePinnedRoots();
120
199
  return report(load(cwd, options, { inspect: true }), taskCards);
121
200
  }
122
201
 
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
3
+ import { createHardenedRootPolicy, rootPolicyAccessScope } from "./hardened-root-policy.mjs";
4
4
 
5
5
  const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
6
6
  const SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
@@ -211,6 +211,13 @@ export class BridgeSecurityPolicy {
211
211
  throw new Error(`Working directory is outside CODEX_BRIDGE_ALLOWED_ROOTS: ${cwd}`);
212
212
  }
213
213
 
214
+ effectiveAccessScope(cwd) {
215
+ const threads = { threadPolicy: this.threadPolicy, existingTasks: this.allowAllThreads || this.threadPolicy === "roots" ? "in-scope" : "owned-or-allowlisted" };
216
+ const pinned = rootPolicyAccessScope(this.hardenedRoots, cwd);
217
+ if (pinned) return { ...pinned, ...threads };
218
+ return { mode: this.allowAllRoots ? "all-directories" : "allowed-roots", authorized: this.isCwdAuthorized(cwd), ...threads };
219
+ }
220
+
214
221
  summary() {
215
222
  const projectPolicy = this.hardenedRoots.mode === "project-policy" ? this.hardenedRoots.status() : null;
216
223
  return {
@@ -57,22 +57,62 @@ function sameAccountContext(expected, current) {
57
57
  typeof expected[provider] === "string" && expected[provider] === current[provider]));
58
58
  }
59
59
 
60
- export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync.native, paths = path } = {}) {
60
+ const PROJECT_REJECTION_TEXT = { kind: "its project kind is not local", host: "its host is not local", metadata: "its project ID is missing" };
61
+
62
+ const projectRejections = (project) => [
63
+ project.projectKind !== "local" && "kind",
64
+ project.hostId !== "local" && "host",
65
+ !project.projectId && "metadata",
66
+ ].filter(Boolean);
67
+
68
+ export function classifyDesktopProject(projects, cwd, { canonicalize = realpathSync.native, paths = path } = {}) {
61
69
  const requested = canonicalize(cwd);
62
- const matches = projects.filter((project) => {
63
- if (project.projectKind !== "local" || project.hostId !== "local" || !project.path || !project.projectId) return false;
64
- try {
65
- return paths.relative(requested, canonicalize(project.path)) === "";
66
- } catch {
67
- return false;
70
+ const eligible = [];
71
+ const rejected = new Set();
72
+ let uncomparable = 0;
73
+ for (const project of projects) {
74
+ const remote = (project?.projectKind && project.projectKind !== "local") || (project?.hostId && project.hostId !== "local");
75
+ let saved = null;
76
+ if (typeof project?.path === "string" && project.path) {
77
+ try { saved = canonicalize(project.path); }
78
+ catch { saved = remote ? project.path : null; }
79
+ }
80
+ if (saved === null) {
81
+ if (!remote) uncomparable += 1;
82
+ continue;
68
83
  }
69
- });
70
- if (matches.length !== 1) {
71
- throw new Error(matches.length
72
- ? `Multiple Codex Desktop projects match ${cwd}; keep one saved project for this directory before delegating.`
73
- : `No saved local Codex Desktop project exactly matches ${cwd}. Choose an existing saved project's directory or continue an existing task in this workspace. The bridge will not create a project or substitute a different directory.`);
84
+ if (paths.relative(requested, saved) !== "") continue;
85
+ const reasons = projectRejections(project);
86
+ if (reasons.length) reasons.forEach((reason) => rejected.add(reason));
87
+ else eligible.push(project);
74
88
  }
75
- return matches[0];
89
+ if (eligible.length === 1) return { status: "matched", project: eligible[0], reasons: [], uncomparable };
90
+ if (eligible.length) return { status: "ambiguous", reasons: [], uncomparable };
91
+ return { status: rejected.size ? "rejected" : "absent", reasons: [...rejected], uncomparable };
92
+ }
93
+
94
+ function describeProjectMatch({ status, reasons, uncomparable }) {
95
+ if (status === "matched") return "One saved local project matches this exact directory.";
96
+ if (status === "ambiguous") return "More than one saved local project matches this exact directory.";
97
+ const uncompared = uncomparable ? ` ${uncomparable} saved project${uncomparable === 1 ? " was" : "s were"} not compared because the saved path is missing or no longer resolves.` : "";
98
+ if (status === "rejected") return `A saved project has this exact path but is not eligible: ${reasons.map((reason) => PROJECT_REJECTION_TEXT[reason]).join(", ")}.${uncompared}`;
99
+ return `No saved project has this exact path.${uncompared}`;
100
+ }
101
+
102
+ export function savedProjectStatus(projects, cwd, options) {
103
+ let match;
104
+ try { match = classifyDesktopProject(projects, cwd, options); }
105
+ catch { return { status: "unresolved", reasons: [], uncomparable: 0, detail: "The requested directory could not be resolved, so its saved project was not checked." }; }
106
+ const { project, ...summary } = match;
107
+ return { ...summary, ...(project ? { projectId: project.projectId } : {}), detail: describeProjectMatch(match) };
108
+ }
109
+
110
+ export function matchDesktopProject(projects, cwd, options) {
111
+ const match = classifyDesktopProject(projects, cwd, options);
112
+ if (match.status === "matched") return match.project;
113
+ throw Object.assign(new Error(match.status === "ambiguous"
114
+ ? `Multiple Codex Desktop projects match ${cwd}; keep one saved project for this directory before delegating.`
115
+ : `No saved local Codex Desktop project exactly matches ${cwd}. ${describeProjectMatch(match)} Choose an existing saved project's directory or continue an existing task in this workspace. The bridge will not create a project or substitute a different directory.`), { code: "DESKTOP_PROJECT_UNMATCHED", projectMatch: { status: match.status, reasons: match.reasons, uncomparable: match.uncomparable } });
76
116
  }
77
117
 
78
118
  export const RELAY_DOWN_GUIDANCE = "The Codex Desktop native relay is not listening after waiting for silent recovery. Its Desktop-owned supervisor restarts crashed relay workers without replaying requests. An unloaded native host cannot be cold-started by this bridge. Inspect codex_bridge_status and include its diagnostic in a report. Never open an app or an old, new, empty or failed chat to load a relay.";
@@ -194,7 +234,7 @@ export class DesktopTaskDelivery {
194
234
  }
195
235
  }
196
236
 
197
- async status() {
237
+ async status({ cwd } = {}) {
198
238
  const accountContext = this.accountContext?.();
199
239
  const socketPath = this.relay.status?.({ accountContext })?.socketPath ?? this.relay.socketPath;
200
240
  if (this.accountContext && !accountContext) {
@@ -204,7 +244,7 @@ export class DesktopTaskDelivery {
204
244
  try {
205
245
  const response = await this.request("list_projects", {});
206
246
  if (!Array.isArray(response?.projects)) throw new Error("Desktop returned no project list");
207
- return { available: true, socketPath, localProjects: response.projects.filter((project) => project.projectKind === "local" && project.hostId === "local").length };
247
+ return { available: true, socketPath, localProjects: response.projects.filter((project) => project.projectKind === "local" && project.hostId === "local").length, ...(cwd ? { targetProject: savedProjectStatus(response.projects, cwd) } : {}) };
208
248
  } catch (err) {
209
249
  const diagnostic = bridgeDiagnostic(err, { component: "codex-bridge", operation: "codex_bridge_status", readOnly: true });
210
250
  return { available: false, socketPath, code: diagnostic.code, reason: err.message, diagnostic };