@minhspark/codex-mcp-bridge 1.26.10 → 1.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +26 -1
- package/docs/daily-handoffs.md +4 -0
- package/docs/project-onboarding.md +54 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/antigravity-human.mjs +70 -0
- package/src/antigravity-sidecar.mjs +29 -2
- package/src/bridge-readiness.mjs +17 -3
- package/src/claude-bridge.mjs +3 -3
- package/src/hardened-root-policy.mjs +6 -0
- package/src/index.mjs +10 -5
- package/src/native-relay-companion.mjs +1 -1
- package/src/project-onboarding-tools.mjs +1 -1
- package/src/project-onboarding.mjs +93 -14
- package/src/security-policy.mjs +8 -1
- package/src/thread-delivery.mjs +55 -15
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [1.27.1] - 2026-11-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `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.
|
|
10
|
+
- 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.
|
|
11
|
+
- `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.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
### Verification and limits
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- The cause of the reported `No saved local Codex Desktop project` refusal is still unknown. The new status and message will identify it on the next occurrence; this release does not change which projects match.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
## [1.27.0] - 2026-11-05
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- A versioned local Antigravity human-dashboard channel with exact conversation/workspace validation, fixed human sender identity, durable idempotent request receipts and explicit preflight/uncertain delivery outcomes. It requires the app-launched sidecar and never treats native acceptance as a completed reply.
|
|
26
|
+
|
|
27
|
+
### Verification and limits
|
|
28
|
+
|
|
29
|
+
- Unit tests cover preflight, idempotent receipts and refusal paths on Windows, macOS and Linux with Node 22 and 24. Delivery through a live Antigravity sidecar was not exercised before this release.
|
|
30
|
+
- Upgrade with `npm install -g @minhspark/codex-mcp-bridge@latest --allow-scripts=@minhspark/codex-mcp-bridge --foreground-scripts`, then restart Antigravity so its sidecar loads the new channel.
|
|
6
31
|
|
|
7
32
|
## [1.26.10] - 2026-11-03
|
|
8
33
|
|
package/docs/daily-handoffs.md
CHANGED
|
@@ -154,3 +154,7 @@ is still null. Continue with `wait_codex_reply`; do not create again to obtain t
|
|
|
154
154
|
ID. The updated companion retains late creation acknowledgements. If it reports
|
|
155
155
|
unsupported `get_creation_receipt`, reload both bridge workers after installing
|
|
156
156
|
the update; that compatibility check occurs before any new task is dispatched.
|
|
157
|
+
|
|
158
|
+
## Local human dashboards
|
|
159
|
+
|
|
160
|
+
Local human dashboards can use `src/antigravity-human.mjs`: `inspectHumanConversation({ conversationId, cwd })`, `sendHumanMessage({ requestId, conversationId, cwd, prompt })`, and `readHumanReceipt({ requestId, conversationId, cwd })`. The native app must have launched an updated sidecar advertising `humanDispatchVersion: 1`. Requests use an existing desktop conversation UUID, its canonical workspace and a stable request UUID. The sender is fixed to `human-web` / `minh-spark-company`; caller-supplied agent identities are rejected. Reusing a request ID never resends it, and changed request contents are refused. Poll the receipt after an uncertain outcome. Native acceptance does not prove a reply or completion. This channel does not answer permission prompts or create conversations.
|
|
@@ -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
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.
|
|
4
|
+
"version": "1.27.1",
|
|
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.
|
|
28
|
+
const VERSION = "1.27.1";
|
|
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"];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { callAntigravityDesktop } from "./antigravity-ipc.mjs";
|
|
4
|
+
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
export const HUMAN_SENDER = Object.freeze({ provider: "human-web", id: "minh-spark-company" });
|
|
7
|
+
|
|
8
|
+
function invalid(message, code = "ANTIGRAVITY_HUMAN_REQUEST_INVALID") {
|
|
9
|
+
return Object.assign(new Error(message), { code, phase: "validation", deliveryState: "not_sent" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function humanWorkspace(value) {
|
|
13
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) throw invalid("An absolute existing workspace is required");
|
|
14
|
+
try {
|
|
15
|
+
const resolved = fs.realpathSync.native(value);
|
|
16
|
+
if (!fs.statSync(resolved).isDirectory()) throw new Error("not a directory");
|
|
17
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
18
|
+
} catch { throw invalid("The requested workspace is unavailable"); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function humanDeliveryInput(input, operation) {
|
|
22
|
+
if (!input || typeof input !== "object" || !UUID.test(input.conversationId) || input.sender !== undefined) throw invalid("An exact conversation UUID and a fixed human sender are required");
|
|
23
|
+
const out = { conversationId: input.conversationId.toLowerCase(), cwd: humanWorkspace(input.cwd) };
|
|
24
|
+
if (operation !== "inspect") {
|
|
25
|
+
if (typeof input.requestId !== "string" || !UUID.test(input.requestId)) throw invalid("requestId must be a UUID");
|
|
26
|
+
out.requestId = input.requestId.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
if (operation === "send") {
|
|
29
|
+
if (typeof input.prompt !== "string" || !input.prompt.trim() || input.prompt.length > 30000 || input.prompt.includes("\0")) throw invalid("Prompt must contain between 1 and 30000 characters without NUL");
|
|
30
|
+
out.prompt = input.prompt;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function humanReceipt(record, target) {
|
|
36
|
+
if (record === null) return null;
|
|
37
|
+
if (!record || record.requestId !== target.requestId || record.conversationId !== target.conversationId || record.surface !== "desktop" || record.sender?.provider !== HUMAN_SENDER.provider || record.sender?.id !== HUMAN_SENDER.id || humanWorkspace(record.cwd) !== target.cwd) throw invalid("Receipt does not belong to this human delivery", "ANTIGRAVITY_HUMAN_RECEIPT_MISMATCH");
|
|
38
|
+
return record;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function callHuman(operation, input, options = {}) {
|
|
42
|
+
const target = humanDeliveryInput(input, operation);
|
|
43
|
+
const call = options.call ?? ((request) => callAntigravityDesktop(request, { directory: options.directory, timeoutMs: options.timeoutMs ?? 10000 }));
|
|
44
|
+
let status;
|
|
45
|
+
try { status = await call({ operation: "status" }); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (operation === "send") {
|
|
48
|
+
error.deliveryState = "not_sent";
|
|
49
|
+
error.phase = "preflight";
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (status?.available !== true || status.humanDispatchVersion !== 1) throw invalid("The app-launched Antigravity sidecar must be updated to support human dashboard messages", "ANTIGRAVITY_HUMAN_UNSUPPORTED");
|
|
54
|
+
const result = await call({ operation: `human-${operation}`, ...target });
|
|
55
|
+
if (operation !== "inspect") {
|
|
56
|
+
try {
|
|
57
|
+
if (operation === "send" && result === null) throw invalid("No delivery receipt returned");
|
|
58
|
+
return humanReceipt(result, target);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (operation === "send") { error.deliveryState = "unknown"; error.phase = "response"; }
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!result || result.conversationId !== target.conversationId || humanWorkspace(result.cwd) !== target.cwd || result.surface !== "desktop" || result.available !== true || result.status !== "CASCADE_RUN_STATUS_IDLE") throw invalid("Native conversation does not match the requested idle workspace", "ANTIGRAVITY_HUMAN_TARGET_MISMATCH");
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function inspectHumanConversation(input, options) { return callHuman("inspect", input, options); }
|
|
69
|
+
export function sendHumanMessage(input, options) { return callHuman("send", input, options); }
|
|
70
|
+
export function readHumanReceipt(input, options) { return callHuman("receipt", input, options); }
|
|
@@ -6,6 +6,7 @@ import { AntigravityDeliveries } from "./antigravity-deliveries.mjs";
|
|
|
6
6
|
import { listAntigravityConversations, observeAntigravityDelivery, runAgentApi, sanitizeAntigravityMetadata } from "./antigravity-desktop.mjs";
|
|
7
7
|
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
8
8
|
import { readProcessAncestry } from "./claude-sender-context.mjs";
|
|
9
|
+
import { HUMAN_SENDER, humanDeliveryInput, humanReceipt, humanWorkspace } from "./antigravity-human.mjs";
|
|
9
10
|
|
|
10
11
|
export async function createAntigravityParentGuard({ parentPid = process.ppid, inspect = readProcessAncestry } = {}) {
|
|
11
12
|
if (!Number.isSafeInteger(parentPid) || parentPid <= 1 || parentPid > 0xffffffff) throw new Error("Antigravity sidecar parent is unavailable; relaunch it from the native app");
|
|
@@ -34,7 +35,7 @@ export async function createAntigravityParentGuard({ parentPid = process.ppid, i
|
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export async function startAntigravitySidecar({ directory = antigravityBridgeDirectory(), env = process.env, createParentGuard = createAntigravityParentGuard, serve = serveAntigravityDesktop, parentCheckIntervalMs = 5000, onParentLost = () => {}, runNative = runAgentApi, observeDelivery = observeAntigravityDelivery } = {}) {
|
|
38
|
+
export async function startAntigravitySidecar({ directory = antigravityBridgeDirectory(), env = process.env, createParentGuard = createAntigravityParentGuard, serve = serveAntigravityDesktop, parentCheckIntervalMs = 5000, onParentLost = () => {}, list = listAntigravityConversations, runNative = runAgentApi, observeDelivery = observeAntigravityDelivery } = {}) {
|
|
38
39
|
if (!env.ANTIGRAVITY_AGENTAPI_EXE || !env.ANTIGRAVITY_LS_ADDRESS) throw new Error("Antigravity must launch this sidecar. Do not run it as a replacement app server.");
|
|
39
40
|
const assertParent = await createParentGuard();
|
|
40
41
|
const deliveries = new AntigravityDeliveries(path.join(directory, "desktop-deliveries"));
|
|
@@ -59,7 +60,33 @@ export async function startAntigravitySidecar({ directory = antigravityBridgeDir
|
|
|
59
60
|
sidecar = await serve(async (request) => {
|
|
60
61
|
await checkParent();
|
|
61
62
|
if (!request || typeof request !== "object") throw new Error("Invalid sidecar request");
|
|
62
|
-
if (request.operation === "status") return { available: true, transport: "native-agentapi", permissionPolicy: "native-unchanged" };
|
|
63
|
+
if (request.operation === "status") return { available: true, transport: "native-agentapi", permissionPolicy: "native-unchanged", humanDispatchVersion: 1 };
|
|
64
|
+
if (["human-inspect", "human-send", "human-receipt"].includes(request.operation)) {
|
|
65
|
+
const operation = request.operation.slice("human-".length);
|
|
66
|
+
const target = humanDeliveryInput(request, operation);
|
|
67
|
+
if (operation === "receipt") return humanReceipt(deliveries.read(target.requestId), target);
|
|
68
|
+
const inspect = async () => {
|
|
69
|
+
await checkParent();
|
|
70
|
+
const records = (await list({ surface: "desktop", limit: 1000 })).filter((record) => record.conversationId === target.conversationId);
|
|
71
|
+
if (records.length !== 1 || records[0].surface !== "desktop" || humanWorkspace(records[0].cwd) !== target.cwd) throw Object.assign(new Error("Exact Antigravity conversation and workspace are unavailable"), { code: "ANTIGRAVITY_RECIPIENT_MISSING", deliveryState: "not_sent" });
|
|
72
|
+
const record = records[0];
|
|
73
|
+
if (record.status !== "CASCADE_RUN_STATUS_IDLE") throw Object.assign(new Error("Antigravity conversation is busy; no message was sent"), { code: "ANTIGRAVITY_RECIPIENT_BUSY", deliveryState: "not_sent" });
|
|
74
|
+
const metadata = sanitizeAntigravityMetadata(await runNative({ command: "metadata", conversationId: target.conversationId, env }), target.conversationId);
|
|
75
|
+
if (humanWorkspace(metadata.cwd) !== target.cwd || metadata.projectId !== record.projectId) throw Object.assign(new Error("Native Antigravity workspace or project changed"), { code: "ANTIGRAVITY_METADATA_MISMATCH", deliveryState: "not_sent" });
|
|
76
|
+
await checkParent();
|
|
77
|
+
return { ...record, ...metadata, available: true };
|
|
78
|
+
};
|
|
79
|
+
if (operation === "inspect") return inspect();
|
|
80
|
+
return deliveries.dispatch({ ...target, surface: "desktop", sender: HUMAN_SENDER }, async () => {
|
|
81
|
+
await checkParent();
|
|
82
|
+
if (sending.has(target.conversationId)) throw Object.assign(new Error("Another delivery is pending for this conversation"), { deliveryState: "not_sent" });
|
|
83
|
+
sending.add(target.conversationId);
|
|
84
|
+
try {
|
|
85
|
+
await inspect();
|
|
86
|
+
return await runNative({ command: "send", conversationId: target.conversationId, expectedCwd: target.cwd, prompt: target.prompt, env });
|
|
87
|
+
} finally { sending.delete(target.conversationId); }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
63
90
|
if (request.operation === "list") return listAntigravityConversations({ surface: "desktop" });
|
|
64
91
|
if (request.operation === "read") {
|
|
65
92
|
const records = await listAntigravityConversations({ surface: "desktop" });
|
package/src/bridge-readiness.mjs
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { RELAY_DOWN_GUIDANCE } from "./thread-delivery.mjs";
|
|
2
2
|
|
|
3
|
-
|
|
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:
|
|
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
|
}
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
34
|
+
const VERSION = "1.27.1";
|
|
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.
|
|
47
|
+
const VERSION = "1.27.1";
|
|
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
|
|
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
|
|
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;
|
|
@@ -43,7 +43,7 @@ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } fro
|
|
|
43
43
|
|
|
44
44
|
exitForVersionRequest(import.meta.url);
|
|
45
45
|
|
|
46
|
-
const VERSION = "1.
|
|
46
|
+
const VERSION = "1.27.1";
|
|
47
47
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
48
48
|
|
|
49
49
|
function errorResponse(code, message, sent, details) {
|
|
@@ -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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 ?
|
|
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
|
|
package/src/security-policy.mjs
CHANGED
|
@@ -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 {
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -57,22 +57,62 @@ function sameAccountContext(expected, current) {
|
|
|
57
57
|
typeof expected[provider] === "string" && expected[provider] === current[provider]));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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 };
|