@minhspark/codex-mcp-bridge 1.22.5 → 1.22.7
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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +18 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-claude-desktop.mjs +3 -2
- package/scripts/install-codex-mcp.mjs +5 -3
- package/scripts/install-native-relay.mjs +3 -2
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +31 -3
- package/src/install-paths.mjs +54 -0
- package/src/mcp-supervisor.mjs +12 -7
- package/src/native-relay-companion.mjs +1 -1
- package/src/platform.mjs +4 -2
- package/src/project-manager-registration.mjs +3 -2
- package/src/thread-delivery.mjs +104 -18
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.22.7] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Install with Homebrew: `brew install buidangminh23/tap/codex-mcp-bridge`. The formula in [buidangminh23/homebrew-tap](https://github.com/buidangminh23/homebrew-tap) follows each npm release.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Record Homebrew's stable `opt` paths instead of a versioned Cellar keg when the Claude Desktop, Codex and native relay installers and `bridge-projects register-manager` write a client configuration, for both the bridge and Homebrew's Node.js. A keg path broke every registration as soon as `brew upgrade` removed the old keg. Paths outside a Homebrew keg are written exactly as before.
|
|
16
|
+
- Start the supervisor when its path reaches the installation through a symbolic link, such as Homebrew's `opt` alias. The direct-execution check compared the unresolved command-line path with the resolved module path, so the process exited at once without serving MCP.
|
|
17
|
+
- Follow a Homebrew upgrade in a running supervisor: it watches the `opt` alias, reloads the new keg like any other installed update, and forks workers with the stable Node.js path, instead of reporting "Installation is not ready" after the old keg is removed.
|
|
18
|
+
|
|
19
|
+
### Verification
|
|
20
|
+
|
|
21
|
+
- 9 new regression tests: Cellar to `opt` mapping, a custom `HOMEBREW_PREFIX`, the main-module check through a symlink and a respelled path, a running supervisor following a bridge keg switch, and one following a Node.js keg replacement. The supervisor tests fail on v1.22.6 and pass here.
|
|
22
|
+
- End to end on macOS with a local tap: two packed versions installed with brew, the three installers run against an isolated home, and every recorded path was `opt/codex-mcp-bridge` and `opt/node` (the relay keeps Codex Desktop's Node). A bridge launched from the recorded Claude Desktop entry kept its supervisor through `brew upgrade` and `brew cleanup` and reloaded the new keg; fresh launches from the recorded paths answered `initialize`.
|
|
23
|
+
- An independent adversarial review confirmed two defects in the first draft, a main-module regression for respelled paths and a worker Node path resolved too late, and both were fixed before merge.
|
|
24
|
+
|
|
25
|
+
### Limitations
|
|
26
|
+
|
|
27
|
+
- Registrations written from a Homebrew keg by an earlier version still name that keg; run the installers once after upgrading. `codex-native-relay-install` still refuses to rewrite an entry that has `env_vars`, so edit that entry's path by hand.
|
|
28
|
+
|
|
29
|
+
## [1.22.6] - 2026-10-20
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Keep a confirmed Desktop creation identifiable when post-creation verification fails. `delegate_to_codex` and `start_codex_thread` now return the task's threadId, its deliveryId and "Task was accepted; do not resend" instead of a bare allowed-roots error that read as if nothing had been created. The confirmed threadId is kept even when a later account, receipt or scope check fails after the bridge waited for the task's workspace.
|
|
34
|
+
- Treat a filesystem root that Codex Desktop briefly reports as a newly created task's workspace, observed as `/` right after creation following an account switch, as "workspace not yet assigned" when it is not the expected project. Creation keeps observing until its deadline and then returns a pending receipt that keeps the accepted threadId; reply observation keeps polling. The tolerance lasts at most 120 seconds from creation, or from the first such report during reply observation, and the bridge acts on nothing at that path and grants no task access from it during that time.
|
|
35
|
+
- Report a pending creation whose task Desktop already confirmed as `deliveryStatus: accepted` instead of `unconfirmed`.
|
|
36
|
+
|
|
37
|
+
### Security
|
|
38
|
+
|
|
39
|
+
- A root report that outlasts the grace period, any other workspace outside the allowed roots, a different project, an account change or a sender mismatch still fails closed. Sends and plain thread inspection keep the normal allowed-roots refusal for a root workspace; nothing is sent.
|
|
40
|
+
|
|
41
|
+
### Verification
|
|
42
|
+
|
|
43
|
+
- 10 regression tests reproduce the defect on v1.22.5 and pass with the fix: creation confirmation with a brief `/` report, a pending result at the deadline, grace expiry, an outside-root workspace, a later account change, reply observation in both the wait and snapshot paths, and two MCP-level `delegate_to_codex` tests through the relay companion.
|
|
44
|
+
- The full CI matrix passed on Windows, macOS and Linux with Node 22 and 24. A local run outside the sandbox passed 1406 tests with 0 failures.
|
|
45
|
+
- An independent adversarial review found no security regression; its four confirmed correctness findings were fixed before merge.
|
|
46
|
+
|
|
47
|
+
### Upgrade notes
|
|
48
|
+
|
|
49
|
+
- Reconnect the MCP server after updating and confirm the loaded version from the actual runtime before relying on the fix.
|
|
50
|
+
- A creation that is pending because Desktop has not reported its workspace continues with `wait_codex_reply` and the returned deliveryId. Do not resend the prompt or create the task again.
|
|
51
|
+
- Not yet exercised against a live Desktop account switch. A placeholder workspace other than a filesystem root still fails closed as before.
|
|
52
|
+
|
|
7
53
|
## [1.22.5] - 2026-10-19
|
|
8
54
|
|
|
9
55
|
### Fixed
|
package/README.md
CHANGED
|
@@ -27,6 +27,14 @@ npm install -g @minhspark/codex-mcp-bridge@latest
|
|
|
27
27
|
|
|
28
28
|
Requires **Node.js 22+**. In Windows PowerShell, use `npm.cmd` instead of `npm`. Run the same command to update; reconnect MCP servers if their settings or installation paths changed.
|
|
29
29
|
|
|
30
|
+
On macOS or Linux you can install with [Homebrew](https://github.com/buidangminh23/homebrew-tap) instead, which brings its own Node.js:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
brew install buidangminh23/tap/codex-mcp-bridge
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Update with `brew upgrade codex-mcp-bridge`. The installers record Homebrew's stable `opt` paths, so registrations keep working after an upgrade and running bridges reload the new version. Keep one install channel: whichever of npm or Homebrew comes first on `PATH` provides the commands.
|
|
37
|
+
|
|
30
38
|
[Windows](#windows-powershell) · [macOS](#macos-terminal) · [Linux / WSL](#linux--wsl-bash) · [ChatGPT plugin](#chatgpt-plugin)
|
|
31
39
|
|
|
32
40
|
### Windows (PowerShell)
|
|
@@ -93,6 +101,15 @@ claude mcp add --scope user codex-bridge \
|
|
|
93
101
|
-- "$(command -v node)" "$bridge_root/src/mcp-supervisor.mjs" index.mjs
|
|
94
102
|
```
|
|
95
103
|
|
|
104
|
+
For a Homebrew install, use the stable paths instead of `npm root -g` and `command -v node`:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bridge_root="$(brew --prefix)/opt/codex-mcp-bridge/libexec/lib/node_modules/@minhspark/codex-mcp-bridge"
|
|
108
|
+
node_bin="$(brew --prefix)/opt/node/bin/node"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
and end the command with `-- "$node_bin" "$bridge_root/src/mcp-supervisor.mjs" index.mjs`.
|
|
112
|
+
|
|
96
113
|
</details>
|
|
97
114
|
|
|
98
115
|
Separate multiple allowed directories with `;` on Windows or `:` on macOS/Linux.
|
|
@@ -178,7 +195,7 @@ Dispatch expires after two minutes; operation history remains stored. Disconnect
|
|
|
178
195
|
|
|
179
196
|
## Troubleshooting
|
|
180
197
|
|
|
181
|
-
- **Command missing:** open a new terminal and check Node.js 22+ and the global npm directory on PATH.
|
|
198
|
+
- **Command missing:** open a new terminal and check Node.js 22+ and the global npm directory (or Homebrew's `bin`) on PATH.
|
|
182
199
|
- **PowerShell blocks scripts:** use `npm.cmd`, installer `.cmd` launchers, and `codex.exe`.
|
|
183
200
|
- **No sessions or wrong project:** open the intended Claude **Code** session and use the exact project directory saved in Codex Desktop.
|
|
184
201
|
- **Relay unavailable:** check [pipe forwarding](#native-relay-pipe-forwarding), reconnect, and run `native_relay_status` in the task.
|
package/package.json
CHANGED
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.22.
|
|
4
|
+
"version": "1.22.7",
|
|
5
5
|
"description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bui Dang Minh",
|
|
@@ -9,10 +9,11 @@ import { desktopTasksConfigured } from "../src/native-relay.mjs";
|
|
|
9
9
|
import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
10
10
|
import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
|
|
11
11
|
import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
|
|
12
|
+
import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
|
|
12
13
|
|
|
13
14
|
exitForVersionRequest(import.meta.url);
|
|
14
15
|
|
|
15
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
16
17
|
const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
|
|
17
18
|
const reset = process.argv.includes("--reset");
|
|
18
19
|
const forceDesktopTasks = process.argv.includes("--desktop-tasks");
|
|
@@ -29,7 +30,7 @@ if (fullAccess) enableCodexFullAccess();
|
|
|
29
30
|
*/
|
|
30
31
|
const defaultRoots = "*";
|
|
31
32
|
|
|
32
|
-
const nodeBin = process.env.NODE_EXE ??
|
|
33
|
+
const nodeBin = process.env.NODE_EXE ?? stableNodeBin();
|
|
33
34
|
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
34
35
|
|
|
35
36
|
if (!fs.existsSync(nodeBin)) throw new Error(`node executable not found: ${nodeBin}`);
|
|
@@ -10,10 +10,12 @@ import { desktopTasksConfigured } from "../src/native-relay.mjs";
|
|
|
10
10
|
import { codexMcpRegistration } from "../src/codex-mcp-registration.mjs";
|
|
11
11
|
import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
|
|
12
12
|
import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
|
|
13
|
+
import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
|
|
13
14
|
|
|
14
15
|
exitForVersionRequest(import.meta.url);
|
|
15
16
|
|
|
16
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
18
|
+
const nodeBin = process.env.NODE_EXE ?? stableNodeBin();
|
|
17
19
|
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
18
20
|
const serverName = process.env.CLAUDE_BRIDGE_NAME ?? "claude-bridge";
|
|
19
21
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
@@ -39,13 +41,13 @@ const servers = JSON.parse(run(["mcp", "list", "--json"]));
|
|
|
39
41
|
if (!Array.isArray(servers)) throw new Error("Codex returned an invalid MCP inventory; existing configuration was not changed");
|
|
40
42
|
const existing = servers.some((server) => server.name === serverName)
|
|
41
43
|
? JSON.parse(run(["mcp", "get", serverName, "--json"])) : null;
|
|
42
|
-
const registration = codexMcpRegistration({ name: serverName, existing, node:
|
|
44
|
+
const registration = codexMcpRegistration({ name: serverName, existing, node: nodeBin, entry, entryArgs, env: process.env, desktopOnly: desktopTasksConfigured() });
|
|
43
45
|
createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...registration.environment }) });
|
|
44
46
|
run(registration.args);
|
|
45
47
|
|
|
46
48
|
console.log(`platform: ${PLATFORM_LABEL}`);
|
|
47
49
|
console.log(`registered MCP server "${serverName}" with Codex:`);
|
|
48
|
-
console.log(`command: ${
|
|
50
|
+
console.log(`command: ${nodeBin} ${entry} ${entryArgs.join(" ")}`);
|
|
49
51
|
console.log(`environment keys: ${registration.environmentKeys.join(", ")}`);
|
|
50
52
|
console.log("\nReconnect claude-bridge once in the existing Codex Desktop task to load the supervisor. Subsequent compatible installed-source updates reload automatically when the worker is safely idle. Do not create a replacement task.");
|
|
51
53
|
console.log("Verify claude_bridge_status from that task: autoReload must be enabled, runtime state must be current, and the session policy must match this installation. A separate diagnostic process does not verify the app's loaded MCP process.");
|
|
@@ -20,11 +20,12 @@ import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
|
20
20
|
import { stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
|
|
21
21
|
import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
|
|
22
22
|
import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
|
|
23
|
+
import { stableInstallPath } from "../src/install-paths.mjs";
|
|
23
24
|
|
|
24
25
|
exitForVersionRequest(import.meta.url);
|
|
25
26
|
|
|
26
|
-
const VERSION = "1.22.
|
|
27
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const VERSION = "1.22.7";
|
|
28
|
+
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
28
29
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
29
30
|
const entryArgs = ["native-relay-companion.mjs"];
|
|
30
31
|
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
|
|
|
30
30
|
|
|
31
31
|
exitForVersionRequest(import.meta.url);
|
|
32
32
|
|
|
33
|
-
const VERSION = "1.22.
|
|
33
|
+
const VERSION = "1.22.7";
|
|
34
34
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
35
35
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
36
36
|
const FORWARD_MAX_PER_SESSION = 50;
|
package/src/index.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
|
|
|
40
40
|
|
|
41
41
|
exitForVersionRequest(import.meta.url);
|
|
42
42
|
|
|
43
|
-
const VERSION = "1.22.
|
|
43
|
+
const VERSION = "1.22.7";
|
|
44
44
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
45
45
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
46
46
|
|
|
@@ -211,6 +211,25 @@ function threadNameFor({ cwd, prompt, name }) {
|
|
|
211
211
|
);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Once a Desktop creation was dispatched, a later verification failure must
|
|
216
|
+
* never read like a refusal: the caller keeps the task and receipt identity
|
|
217
|
+
* and is told not to resend. Verification still fails closed; only the report
|
|
218
|
+
* changes.
|
|
219
|
+
*/
|
|
220
|
+
function creationVerificationFailure({ created, deliveryId, requestId, err }) {
|
|
221
|
+
const threadId = created.threadId ?? err?.threadId ?? null;
|
|
222
|
+
const lines = threadId
|
|
223
|
+
? ["Codex Desktop accepted the task, but post-creation verification failed.", `threadId: ${threadId}`]
|
|
224
|
+
: ["Codex Desktop creation was dispatched, but its outcome is not yet confirmed."];
|
|
225
|
+
lines.push(...(requestId ? [`requestId: ${requestId}`] : []), `cwd: ${created.cwd}`, ...(deliveryId ? [`deliveryId: ${deliveryId}`] : []), `error: ${err?.message ?? String(err)}`,
|
|
226
|
+
threadId ? "Task was accepted; do not resend. Resolve the verification error, then continue observing with wait_codex_reply and this deliveryId." : "Do not resend or create again. Continue observing with wait_codex_reply and this deliveryId.");
|
|
227
|
+
return {
|
|
228
|
+
...textResult(lines.join("\n"), true),
|
|
229
|
+
structuredContent: { ...failure(err).structuredContent, threadId, deliveryStatus: threadId ? "accepted" : "unconfirmed", ...(deliveryId ? { deliveryId } : {}), nextAction: deliveryId ? "wait_codex_reply" : "none" },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
214
233
|
async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp, waitForReply = true }) {
|
|
215
234
|
const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
|
|
216
235
|
const workspace = resolveWorkspacePath(cwd);
|
|
@@ -222,8 +241,17 @@ async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort
|
|
|
222
241
|
const deliveryId = created.deliveryId;
|
|
223
242
|
let delivered = deliveryId ? replyReceipts.read(deliveryId, desktopOperation.getStore()) : null;
|
|
224
243
|
if (delivered?.creationObservation) {
|
|
225
|
-
|
|
226
|
-
|
|
244
|
+
let resolved;
|
|
245
|
+
try {
|
|
246
|
+
resolved = await desktopTasks.resolveCreation(delivered, { deadline });
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return creationVerificationFailure({ created, deliveryId, requestId, err });
|
|
249
|
+
}
|
|
250
|
+
if (!resolved) {
|
|
251
|
+
const threadId = created.threadId ?? null;
|
|
252
|
+
return replyResult({ ...delivered, threadId, ...(threadId ? { deliveryStatus: "accepted" } : {}) }, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" },
|
|
253
|
+
[...(threadId ? [`threadId: ${threadId}`, "Task was accepted; Codex Desktop has not yet confirmed its workspace or creation receipt."] : []), "Creation acknowledgement pending; do not create again."]);
|
|
254
|
+
}
|
|
227
255
|
delivered = resolved;
|
|
228
256
|
created.threadId = resolved.threadId;
|
|
229
257
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const KEG_PATH = /^(\/.*?)\/Cellar\/([^/]+)\/[^/]+(\/.*)?$/;
|
|
6
|
+
|
|
7
|
+
function resolved(target) {
|
|
8
|
+
try { return fs.realpathSync.native(target); } catch { return null; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Homebrew installs every version of a formula into its own Cellar keg and
|
|
13
|
+
* deletes the old keg on upgrade, while <prefix>/opt/<formula> is retargeted to
|
|
14
|
+
* the current one. A path that outlives this process - written into a client
|
|
15
|
+
* configuration, or watched for an upgrade - therefore has to name the opt
|
|
16
|
+
* alias. A keg path is rewritten only when the alias resolves to the same file
|
|
17
|
+
* right now; every other path is returned unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function stableInstallPath(target, env = process.env) {
|
|
20
|
+
if (process.platform === "win32" || typeof target !== "string") return target;
|
|
21
|
+
const match = KEG_PATH.exec(target);
|
|
22
|
+
if (!match) return target;
|
|
23
|
+
const real = resolved(target);
|
|
24
|
+
if (!real) return target;
|
|
25
|
+
const [, base, formula, rest = ""] = match;
|
|
26
|
+
const prefixes = [...new Set([env.HOMEBREW_PREFIX, base].filter((prefix) => prefix && path.isAbsolute(prefix)))];
|
|
27
|
+
for (const prefix of prefixes) {
|
|
28
|
+
const alias = path.join(prefix, "opt", formula) + rest;
|
|
29
|
+
if (resolved(alias) === real) return alias;
|
|
30
|
+
}
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The node binary to record for later launches. */
|
|
35
|
+
export function stableNodeBin(env = process.env) {
|
|
36
|
+
return stableInstallPath(process.execPath, env);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True when `entry` names the running main module, however it was reached.
|
|
41
|
+
* Node resolves the main module with the JavaScript realpath, which follows
|
|
42
|
+
* symlinks but keeps the spelling of the path as typed (case, 8.3 names,
|
|
43
|
+
* substituted drives), so that is the form compared first.
|
|
44
|
+
*/
|
|
45
|
+
export function isMainModule(entry, moduleUrl) {
|
|
46
|
+
if (!entry) return false;
|
|
47
|
+
const file = path.resolve(entry);
|
|
48
|
+
const module = fileURLToPath(moduleUrl);
|
|
49
|
+
let typed;
|
|
50
|
+
try { typed = fs.realpathSync(file); } catch { typed = file; }
|
|
51
|
+
if (typed === module) return true;
|
|
52
|
+
const canonical = resolved(file);
|
|
53
|
+
return canonical !== null && canonical === resolved(module);
|
|
54
|
+
}
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
|
|
7
7
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
8
|
+
import { isMainModule, stableInstallPath } from "./install-paths.mjs";
|
|
8
9
|
|
|
10
|
+
const WORKER_NODE = stableInstallPath(process.execPath);
|
|
9
11
|
const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
|
|
10
12
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
11
13
|
const MAX_QUEUE = 256;
|
|
@@ -40,7 +42,7 @@ class Worker {
|
|
|
40
42
|
this.retiring = false;
|
|
41
43
|
this.closed = false;
|
|
42
44
|
this.process = fork(path.join(release.directory, "src", entry), [], {
|
|
43
|
-
execPath:
|
|
45
|
+
execPath: WORKER_NODE, execArgv: [], cwd: process.cwd(), windowsHide: true,
|
|
44
46
|
env: { ...env, CODEX_BRIDGE_WORKER: "1", CODEX_BRIDGE_STAGED: "1" },
|
|
45
47
|
stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
|
|
46
48
|
});
|
|
@@ -133,14 +135,16 @@ class Worker {
|
|
|
133
135
|
|
|
134
136
|
export async function runSupervisor(entry, options = {}) {
|
|
135
137
|
if (!ENTRIES.has(entry)) throw new Error("Expected index.mjs, claude-bridge.mjs, or native-relay-companion.mjs");
|
|
136
|
-
const
|
|
138
|
+
const installRoot = stableInstallPath(fs.realpathSync.native(options.root ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")));
|
|
139
|
+
const currentRoot = () => fs.realpathSync.native(installRoot);
|
|
140
|
+
const root = currentRoot();
|
|
137
141
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
138
142
|
process.stdout.write(`${JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version}\n`);
|
|
139
143
|
return;
|
|
140
144
|
}
|
|
141
145
|
const log = (message) => process.stderr.write(`[bridge-supervisor] ${message}\n`);
|
|
142
146
|
const output = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
143
|
-
const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT:
|
|
147
|
+
const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: installRoot };
|
|
144
148
|
const routingConfiguration = () => entry === "native-relay-companion.mjs" ? null : desktopTasksConfigured(env);
|
|
145
149
|
if (entry === "native-relay-companion.mjs" && !env.CODEX_APP_TOOLS_PIPE_PATH) {
|
|
146
150
|
const { resolveNativeToolsPipePath } = await import("./native-relay.mjs");
|
|
@@ -226,9 +230,10 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
226
230
|
};
|
|
227
231
|
const check = async () => {
|
|
228
232
|
if (switching || !ready || stopping || failure) return;
|
|
233
|
+
let source;
|
|
229
234
|
let revision;
|
|
230
235
|
let routing;
|
|
231
|
-
try { revision = sourceRevision(
|
|
236
|
+
try { source = currentRoot(); revision = sourceRevision(source); routing = routingConfiguration(); }
|
|
232
237
|
catch (error) { reason = `Installation is not ready: ${error.message}`; return; }
|
|
233
238
|
if (observed !== revision || observedRouting !== routing) { observed = revision; observedRouting = routing; observedAt = Date.now(); retryAt = 0; }
|
|
234
239
|
if (revision === active.release.revision && routing === active.routingConfiguration) { reason = null; return; }
|
|
@@ -241,7 +246,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
241
246
|
try {
|
|
242
247
|
const state = await previous.control("inspect");
|
|
243
248
|
if (!state.reloadable) { reason = state.reason ?? "Pending deliveries prevent a safe reload"; return; }
|
|
244
|
-
const release = createReleaseSnapshot(
|
|
249
|
+
const release = createReleaseSnapshot(source, { expectedRevision: revision });
|
|
245
250
|
candidate = spawn(release, routing);
|
|
246
251
|
const initialized = await candidate.rpc("initialize", initializeParams);
|
|
247
252
|
if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
|
|
@@ -251,7 +256,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
251
256
|
const catalog = await candidate.rpc("tools/list", {});
|
|
252
257
|
if (!Array.isArray(catalog.tools) || !catalog.tools.length || catalog.nextCursor) throw new Error("The candidate tool catalog is incomplete");
|
|
253
258
|
const oldCatalog = await previous.rpc("tools/list", {});
|
|
254
|
-
if (sourceRevision(
|
|
259
|
+
if (sourceRevision(currentRoot()) !== revision) throw new Error("The installation changed before activation");
|
|
255
260
|
if (routingConfiguration() !== routing) throw new Error("The routing configuration changed before activation");
|
|
256
261
|
quiesced = true;
|
|
257
262
|
const exported = await previous.control("quiesce");
|
|
@@ -339,6 +344,6 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
339
344
|
log(`supervising ${entry}; installed updates activate after pending work completes`);
|
|
340
345
|
}
|
|
341
346
|
|
|
342
|
-
if (
|
|
347
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
343
348
|
await runSupervisor(process.argv[2]);
|
|
344
349
|
}
|
|
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
|
|
|
38
38
|
|
|
39
39
|
exitForVersionRequest(import.meta.url);
|
|
40
40
|
|
|
41
|
-
const VERSION = "1.22.
|
|
41
|
+
const VERSION = "1.22.7";
|
|
42
42
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
43
43
|
|
|
44
44
|
function errorResponse(code, message, sent, details) {
|
package/src/platform.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
|
|
8
|
+
import { stableNodeBin } from "./install-paths.mjs";
|
|
9
|
+
|
|
8
10
|
const execFileAsync = promisify(execFile);
|
|
9
11
|
|
|
10
12
|
export const IS_MACOS = process.platform === "darwin";
|
|
@@ -230,7 +232,7 @@ export function resolveCodexDesktopNodeBin(
|
|
|
230
232
|
]) {
|
|
231
233
|
if (candidate && isRunnable(candidate)) return { path: candidate, source };
|
|
232
234
|
}
|
|
233
|
-
return { path:
|
|
235
|
+
return { path: stableNodeBin(env), source: "process.execPath" };
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
/**
|
|
@@ -393,7 +395,7 @@ export function spawnEnv(extra = {}) {
|
|
|
393
395
|
const systemDirs = IS_WINDOWS
|
|
394
396
|
? []
|
|
395
397
|
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
396
|
-
const merged = [path.dirname(
|
|
398
|
+
const merged = [path.dirname(stableNodeBin()), ...systemDirs, ...(process.env.PATH ?? "").split(separator)]
|
|
397
399
|
.filter(Boolean)
|
|
398
400
|
.filter((dir, index, all) => all.indexOf(dir) === index)
|
|
399
401
|
.join(separator);
|
|
@@ -4,15 +4,16 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { readRegularConfigFile, backupRegularConfigFile } from "./project-policy.mjs";
|
|
7
|
+
import { stableInstallPath, stableNodeBin } from "./install-paths.mjs";
|
|
7
8
|
|
|
8
9
|
// Optional UI adapter discovery. Neither bridge startup nor policy evaluation reads
|
|
9
10
|
// this file. Removing the card extension or this descriptor cannot stop messaging.
|
|
10
|
-
export function registerProjectManager({ policyFile, env = process.env, executable =
|
|
11
|
+
export function registerProjectManager({ policyFile, env = process.env, executable = stableNodeBin(env) } = {}) {
|
|
11
12
|
if (!path.isAbsolute(policyFile ?? "")) throw new Error("Manager policy path must be absolute");
|
|
12
13
|
const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
13
14
|
const file = path.join(home, ".config", "GptClaudeBridge", "management.json");
|
|
14
15
|
const value = { version: 1, node: executable,
|
|
15
|
-
script: fileURLToPath(new URL("../scripts/bridge-projects.mjs", import.meta.url)),
|
|
16
|
+
script: stableInstallPath(fileURLToPath(new URL("../scripts/bridge-projects.mjs", import.meta.url)), env),
|
|
16
17
|
policyFile, configFile: env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(home, ".claude.json") };
|
|
17
18
|
if (!Object.values(value).filter(v => typeof v === "string").every(v => path.isAbsolute(v))) throw new Error("Manager paths must be absolute");
|
|
18
19
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
|
26
26
|
export const NATIVE_BACKEND = "codex-desktop-native";
|
|
27
27
|
export const APP_SERVER_BACKEND = "app-server";
|
|
28
28
|
export const DESKTOP_TOOL_BUDGET_MS = 40000;
|
|
29
|
+
export const WORKSPACE_UNASSIGNED = "DESKTOP_WORKSPACE_UNASSIGNED";
|
|
30
|
+
export const UNASSIGNED_WORKSPACE_GRACE_MS = 120000;
|
|
29
31
|
const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
30
32
|
const METADATA_READ_OPTIONS = { includeOutputs: false, maxOutputCharsPerItem: 1 };
|
|
31
33
|
|
|
@@ -35,6 +37,17 @@ function unavailableWaitRenderer(error) {
|
|
|
35
37
|
/^(Codex app tool request failed\.?|The app tool call ended before producing a result\.?)$/.test(cause.message);
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
function isFilesystemRoot(value) {
|
|
41
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) return false;
|
|
42
|
+
const resolved = path.resolve(value);
|
|
43
|
+
return path.parse(resolved).root === resolved;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function acceptedCreationError(threadId, error) {
|
|
47
|
+
if (error.acceptedCreation) return error;
|
|
48
|
+
return Object.assign(new Error(`Codex Desktop created task ${threadId}, but its post-creation verification failed: ${error.message}. threadId: ${threadId}. Task was accepted; do not resend. Its reply was withheld until the verification error is resolved.`, { cause: error }), { code: "DESKTOP_CREATION_VERIFICATION_FAILED", threadId, acceptedCreation: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
function sameAccountContext(expected, current) {
|
|
39
52
|
return Boolean(expected && current && ["claude", "codex"].every((provider) =>
|
|
40
53
|
typeof expected[provider] === "string" && expected[provider] === current[provider]));
|
|
@@ -75,6 +88,8 @@ export class DesktopTaskDelivery {
|
|
|
75
88
|
this.threadOperations = new Map();
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
#unassignedSince = new Map();
|
|
92
|
+
|
|
78
93
|
senderProjectScope(cwd) {
|
|
79
94
|
if (!this.senderContext) return null;
|
|
80
95
|
const sender = requireClaudeSenderContext(this.senderContext());
|
|
@@ -329,6 +344,15 @@ export class DesktopTaskDelivery {
|
|
|
329
344
|
async resolveCreation(delivered, { deadline = this.now() + DESKTOP_TOOL_BUDGET_MS } = {}) {
|
|
330
345
|
const binding = delivered.creationObservation;
|
|
331
346
|
if (!binding) return delivered;
|
|
347
|
+
let confirmedThreadId = null;
|
|
348
|
+
try {
|
|
349
|
+
return await this.#resolveCreationLoop(delivered, binding, deadline, (threadId) => { confirmedThreadId = threadId; });
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw confirmedThreadId ? acceptedCreationError(confirmedThreadId, error) : error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async #resolveCreationLoop(delivered, binding, deadline, onConfirmed) {
|
|
332
356
|
for (;;) {
|
|
333
357
|
if (this.now() >= deadline) return null;
|
|
334
358
|
this.security.assertCwd(delivered.cwd);
|
|
@@ -345,17 +369,29 @@ export class DesktopTaskDelivery {
|
|
|
345
369
|
const response = row.result, threadId = response?.threadId;
|
|
346
370
|
if (typeof threadId !== "string" || !threadId.trim() || response.hostId !== "local" || response.status === "outcome-unknown" ||
|
|
347
371
|
response.firstTurn && response.firstTurn.status !== "accepted" || receipt.threadId && receipt.threadId !== threadId) throw new Error("Native creation is not confirmed; do not resend");
|
|
372
|
+
onConfirmed(threadId);
|
|
348
373
|
let inspected;
|
|
349
|
-
try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline }); }
|
|
350
|
-
catch (error) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
374
|
+
try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline, tolerance: { since: receipt.startedAt } }); }
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (error.code === WORKSPACE_UNASSIGNED) {
|
|
377
|
+
await this.sleep(Math.min(1000, Math.max(0, deadline - this.now())));
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null;
|
|
381
|
+
throw acceptedCreationError(threadId, error);
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
|
|
385
|
+
if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
|
|
386
|
+
this.security.assertCwd(delivered.cwd);
|
|
387
|
+
this.senderProjectScope(delivered.cwd);
|
|
388
|
+
if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
|
|
389
|
+
if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
|
|
390
|
+
this.security.registerThread(threadId);
|
|
391
|
+
this.security.assertThread(threadId, inspected.thread.cwd);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
throw acceptedCreationError(threadId, error);
|
|
394
|
+
}
|
|
359
395
|
return { threadId, cwd: delivered.cwd, previousTurnId: null, deliveryStatus: "accepted",
|
|
360
396
|
responseObservation: { operation: "create_thread", threadId, previousTurnId: null, expectedCwd: delivered.cwd,
|
|
361
397
|
executorThreadId: envelope.executorThreadId, prompt: binding.args.prompt, accountContext: binding.accountContext } };
|
|
@@ -366,18 +402,18 @@ export class DesktopTaskDelivery {
|
|
|
366
402
|
}
|
|
367
403
|
}
|
|
368
404
|
|
|
369
|
-
async inspect(threadId, cwd, { deadline } = {}) {
|
|
370
|
-
const inspected = await this.#inspectMetadata(threadId, cwd, { deadline });
|
|
405
|
+
async inspect(threadId, cwd, { deadline, tolerateUnassigned = false } = {}) {
|
|
406
|
+
const inspected = await this.#inspectMetadata(threadId, cwd, { deadline, ...(tolerateUnassigned ? { tolerance: {} } : {}) });
|
|
371
407
|
this.security.assertThread(threadId, inspected.thread.cwd);
|
|
372
408
|
return inspected;
|
|
373
409
|
}
|
|
374
410
|
|
|
375
|
-
async #inspectMetadata(threadId, cwd, { deadline } = {}) {
|
|
411
|
+
async #inspectMetadata(threadId, cwd, { deadline, tolerance } = {}) {
|
|
376
412
|
const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline, includeRelayContext: true });
|
|
377
413
|
const response = envelope.result;
|
|
378
414
|
const thread = response?.thread;
|
|
379
415
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
380
|
-
this
|
|
416
|
+
this.#assertReportedWorkspace(threadId, thread.cwd, cwd, tolerance);
|
|
381
417
|
const projectScope = this.senderProjectScope(thread.cwd);
|
|
382
418
|
if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
|
|
383
419
|
throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
|
|
@@ -386,6 +422,39 @@ export class DesktopTaskDelivery {
|
|
|
386
422
|
return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope, scopeBinding, executorThreadId: envelope.executorThreadId ?? null };
|
|
387
423
|
}
|
|
388
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Codex Desktop can briefly report a filesystem root as a task's workspace
|
|
427
|
+
* before it finishes assigning the task's saved project (observed as "/"
|
|
428
|
+
* right after creation, following an account switch). Only observers that
|
|
429
|
+
* pass a tolerance - creation confirmation and reply observation, both of
|
|
430
|
+
* which already know the expected workspace - treat a root that differs from
|
|
431
|
+
* that workspace as "not yet assigned", and only for a bounded grace period
|
|
432
|
+
* measured from creation or from the first such report. During it the bridge
|
|
433
|
+
* acts on nothing at that path, grants no access and keeps observing. After
|
|
434
|
+
* it, and for every other caller or path, the normal fail-closed
|
|
435
|
+
* allowed-roots check applies.
|
|
436
|
+
*/
|
|
437
|
+
#assertReportedWorkspace(threadId, reported, expected, tolerance) {
|
|
438
|
+
if (!isFilesystemRoot(reported)) this.#unassignedSince.delete(threadId);
|
|
439
|
+
else if (tolerance && expected && !this.#isExpectedWorkspace(reported, expected)) {
|
|
440
|
+
const now = this.now();
|
|
441
|
+
if (tolerance.since === undefined && !this.#unassignedSince.has(threadId)) this.#unassignedSince.set(threadId, now);
|
|
442
|
+
const since = tolerance.since ?? this.#unassignedSince.get(threadId);
|
|
443
|
+
if (now - since < UNASSIGNED_WORKSPACE_GRACE_MS) {
|
|
444
|
+
throw Object.assign(new Error(`Codex Desktop has not yet reported task ${threadId}'s project workspace (it currently reports ${reported}); the bridge did not act on that path.`), { code: WORKSPACE_UNASSIGNED, threadId });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
this.security.assertCwd(reported);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#isExpectedWorkspace(reported, expected) {
|
|
451
|
+
try {
|
|
452
|
+
return path.relative(realpathSync.native(expected), realpathSync.native(reported)) === "";
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
389
458
|
async send({ threadId, prompt, cwd, model, effort, name, deadline, onPrepared }) {
|
|
390
459
|
const inspected = await this.inspect(threadId, cwd, { deadline });
|
|
391
460
|
const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(inspected.thread.cwd) : undefined;
|
|
@@ -445,12 +514,12 @@ export class DesktopTaskDelivery {
|
|
|
445
514
|
if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its response was being observed");
|
|
446
515
|
};
|
|
447
516
|
await recheck();
|
|
448
|
-
const initial = await this.inspect(threadId, expectedCwd, { deadline });
|
|
517
|
+
const initial = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
|
|
449
518
|
if (!resolveDispatch && initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
|
|
450
519
|
const read = resolveDispatch ? this.resolveResponse : responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
|
|
451
520
|
const observed = read({ ...responseObservation, threadId, turnId, desktopEvidence: initial });
|
|
452
521
|
await recheck();
|
|
453
|
-
const inspected = await this.inspect(threadId, expectedCwd, { deadline });
|
|
522
|
+
const inspected = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
|
|
454
523
|
if (inspected.latestTurnId !== (resolveDispatch ? initial.latestTurnId : turnId)) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
|
|
455
524
|
await recheck();
|
|
456
525
|
return observed;
|
|
@@ -485,7 +554,7 @@ export class DesktopTaskDelivery {
|
|
|
485
554
|
const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
|
|
486
555
|
const thread = result?.thread;
|
|
487
556
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the observed task's local workspace.");
|
|
488
|
-
this
|
|
557
|
+
this.#assertReportedWorkspace(threadId, thread.cwd, responseObservation?.expectedCwd, {});
|
|
489
558
|
this.security.assertThread(threadId, thread.cwd);
|
|
490
559
|
this.senderProjectScope(thread.cwd);
|
|
491
560
|
if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
|
|
@@ -497,14 +566,17 @@ export class DesktopTaskDelivery {
|
|
|
497
566
|
return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
|
|
498
567
|
cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
|
|
499
568
|
};
|
|
569
|
+
const unassigned = Symbol("workspace-unassigned");
|
|
500
570
|
const observe = async (selectedTurnId, resolveDispatch = false) => {
|
|
501
571
|
try {
|
|
502
572
|
return await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
|
|
503
573
|
} catch (error) {
|
|
504
574
|
if (deadlineError(error)) return null;
|
|
575
|
+
if (error.code === WORKSPACE_UNASSIGNED) return unassigned;
|
|
505
576
|
throw error;
|
|
506
577
|
}
|
|
507
578
|
};
|
|
579
|
+
const pause = () => this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
508
580
|
for (;;) {
|
|
509
581
|
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
510
582
|
let response;
|
|
@@ -514,13 +586,19 @@ export class DesktopTaskDelivery {
|
|
|
514
586
|
}, { deadline: startedAt + timeoutMs });
|
|
515
587
|
} catch (err) {
|
|
516
588
|
if (deadlineError(err)) return expired();
|
|
589
|
+
if (err.code === WORKSPACE_UNASSIGNED) {
|
|
590
|
+
await pause();
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
517
593
|
if (readSnapshots || !unavailableWaitRenderer(err)) throw err;
|
|
518
594
|
readSnapshots = true;
|
|
519
595
|
try {
|
|
520
596
|
response = await readSnapshot();
|
|
521
597
|
} catch (readError) {
|
|
522
598
|
if (deadlineError(readError)) return expired();
|
|
523
|
-
throw readError;
|
|
599
|
+
if (readError.code !== WORKSPACE_UNASSIGNED) throw readError;
|
|
600
|
+
await pause();
|
|
601
|
+
continue;
|
|
524
602
|
}
|
|
525
603
|
}
|
|
526
604
|
const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
|
|
@@ -533,6 +611,10 @@ export class DesktopTaskDelivery {
|
|
|
533
611
|
if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
|
|
534
612
|
const observed = await observe(null, true);
|
|
535
613
|
if (!observed) return expired();
|
|
614
|
+
if (observed === unassigned) {
|
|
615
|
+
await pause();
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
536
618
|
turnId = observed.turnId ?? null;
|
|
537
619
|
if (observed.status !== "pending") {
|
|
538
620
|
return {
|
|
@@ -559,6 +641,10 @@ export class DesktopTaskDelivery {
|
|
|
559
641
|
if (RELEASE_STATUSES.has(status)) {
|
|
560
642
|
const observed = await observe(turn.id);
|
|
561
643
|
if (!observed) return expired();
|
|
644
|
+
if (observed === unassigned) {
|
|
645
|
+
await pause();
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
562
648
|
const responseStatus = observed.status;
|
|
563
649
|
if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
|
|
564
650
|
turnId = null;
|