@miraland-labs/conduit-bridge 0.1.0 → 0.4.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/README.md CHANGED
@@ -5,29 +5,54 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
5
5
  ## Prerequisites
6
6
 
7
7
  - Node.js 20+
8
- - **Claude Code** (`claude` on PATH) for automated execution today
8
+ - One supported agent CLI on PATH:
9
+ - **Claude Code** — `claude` (Conduit pump or Anthropic login)
10
+ - **Codex** — `codex` (Conduit pump or ChatGPT/OpenAI login). Since the July 2026 ChatGPT desktop merge, Bridge also finds the CLI bundled inside ChatGPT.app when `codex` is not on PATH (macOS)
11
+ - **OpenCode** — `opencode` (Conduit pump or local provider login)
12
+ - **Cursor Agent** — `agent` (requires `conduit fuel local` + `CURSOR_API_KEY` / Cursor login)
13
+ - **Kiro CLI** — `kiro-cli` (requires `conduit fuel local` + `KIRO_API_KEY` / `kiro-cli login`)
14
+ - **Antigravity** — `agy` (requires `conduit fuel local` + `GEMINI_API_KEY` / `agy login`)
9
15
  - macOS or Linux for `install-service` (Windows: keep a terminal runner open)
10
16
 
11
17
  ## Install / run
12
18
 
13
- No global install required:
14
-
15
19
  ```bash
16
20
  npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
17
21
  ```
18
22
 
19
- After approval, keep the computer executing work (not heartbeat-only):
23
+ After approval:
20
24
 
21
25
  ```bash
22
26
  npx @miraland-labs/conduit-bridge runner --agent claude-code --workspace /path/to/repo
23
- npx @miraland-labs/conduit-bridge install-service --agent claude-code --workspace /path/to/repo
27
+ npx @miraland-labs/conduit-bridge runner --agent codex --workspace /path/to/repo
28
+ npx @miraland-labs/conduit-bridge runner --agent opencode --workspace /path/to/repo
29
+ npx @miraland-labs/conduit-bridge fuel local # required before cursor / kiro / antigravity
30
+ npx @miraland-labs/conduit-bridge runner --agent cursor --workspace /path/to/repo
31
+ npx @miraland-labs/conduit-bridge runner --agent kiro --workspace /path/to/repo
32
+ npx @miraland-labs/conduit-bridge runner --agent antigravity --workspace /path/to/repo
24
33
  ```
25
34
 
26
- Optional global install creates a `conduit` shim on PATH:
35
+ Persist (macOS/Linux):
27
36
 
28
37
  ```bash
29
- npm install -g @miraland-labs/conduit-bridge
30
- conduit join --url <https://your-conduit> --organization <slug>
38
+ npx @miraland-labs/conduit-bridge install-service --agent <claude-code|codex|cursor|opencode|kiro|antigravity> --workspace /path/to/repo
31
39
  ```
32
40
 
33
- Detected IDEs (Cursor, VS Code, Codex) are diagnostics only. Execution drivers today: `claude-code` only.
41
+ ## Grant enforcement
42
+
43
+ Every driver enforces the assignment's granted actions with its CLI's native mechanism — never just the prompt:
44
+
45
+ | Driver | Fuel | Mechanism |
46
+ | --- | --- | --- |
47
+ | `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per grant; bounded verification commands |
48
+ | `codex` | pump or local | sandbox `read-only` / `workspace-write`; network enabled only with `pr_create`; resumes by thread id |
49
+ | `opencode` | pump or local | `plan`/`build` agents; per-run `opencode.json` bash deny list (shared `deniedCommands`); `--session` resume; `--format json` |
50
+ | `cursor` | local only | per-run `.cursor/cli.json` allowlist (`approvalMode: allowlist`, shared deny list, no `--force`); read-only runs in `--mode plan` |
51
+ | `kiro` | local only | `--trust-tools=fs_read,fs_write,execute_bash` allowlist; `--no-interactive`; `--resume-id`. Tool-level only — no per-command scoping, so a bash grant trusts `execute_bash` wholesale (Antigravity-tier) |
52
+ | `antigravity` | local only | `-p --mode plan` (read-only) / `--mode accept-edits --sandbox` (write). Mode-level, coarser than per-tool; plain-text output; resume not surfaced |
53
+
54
+ Every driver refuses to start when grants map to nothing, fails closed on missing logins, and shares one denied-command list (merge, force-push, deploy, kubectl, terraform).
55
+
56
+ **Verification status:**
57
+ - `claude-code`, `codex`, `cursor`, `opencode`, `antigravity` — verified end-to-end against their real CLIs (agy 1.1.3: `-p --mode` args, Google login, plain-text output confirmed through the driver).
58
+ - `kiro` — flag surface and login preflight verified against kiro-cli 2.13.0 (`chat --no-interactive --trust-tools --resume-id`; tool names; `whoami`-based login check that avoids the browser-login hang). A full run is **pending a `KIRO_API_KEY`** — kiro-cli requires auth to execute and cannot be exercised offline.
package/dist/cli.js CHANGED
@@ -17,6 +17,8 @@ const BRIDGE_NPX = "npx @miraland-labs/conduit-bridge";
17
17
  function bridgeUsage(...args) {
18
18
  return `${BRIDGE_NPX} ${args.join(" ")}`;
19
19
  }
20
+ /** Single source for the --agent placeholder so CLI help never drifts from DRIVERS. */
21
+ const AGENT_PLACEHOLDER = `<${Object.keys(DRIVERS).join("|")}>`;
20
22
  function parseFuelSource(value) {
21
23
  if (value === undefined)
22
24
  return undefined;
@@ -85,7 +87,7 @@ async function join() {
85
87
  console.log(`Assignments at once: ${leaseCapacity}`);
86
88
  if (fuelSource)
87
89
  console.log(`Fuel source: ${fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
88
- console.log("Execution today requires Claude Code (`claude` on PATH). Other detected clients are diagnostics only.");
90
+ console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/kiro/antigravity require local fuel).`);
89
91
  console.log("Use --machine <name> to override the computer label.\n");
90
92
  const response = await fetch(`${baseUrl}/runner/v1/connect/requests`, {
91
93
  method: "POST",
@@ -193,9 +195,9 @@ async function finishConnection(baseUrl, data, fuelSource) {
193
195
  console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
194
196
  console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
195
197
  console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
196
- console.log(`Execute work: ${bridgeUsage("runner", "--agent", "claude-code", "--workspace", "<repo>")}`);
197
- console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--agent", "claude-code", "--workspace", "<repo>")}`);
198
- console.log(`Flip fuel later: ${bridgeUsage("fuel", "local|conduit")}`);
198
+ console.log(`Execute work: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
199
+ console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
200
+ console.log(`Flip fuel later: ${bridgeUsage("fuel", "local|conduit")} (cursor/kiro/antigravity require local fuel)`);
199
201
  console.log("Windows: keep the runner terminal open — install-service is macOS/Linux only.");
200
202
  if (Object.keys(fuel).length) {
201
203
  console.log(`Fleet fueling: ${Object.keys(fuel).length} project key(s) configured for Conduit /v1`);
@@ -223,7 +225,7 @@ async function installService() {
223
225
  agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, "agent-timeout-minutes": { type: "string" },
224
226
  } });
225
227
  if (!values.agent || !values.workspace) {
226
- throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", "claude-code", "--workspace", "<repository-path>")} (agent + workspace required; heartbeat-only services are not supported)`);
228
+ throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repository-path>")} (agent + workspace required; heartbeat-only services are not supported)`);
227
229
  }
228
230
  if (!DRIVERS[values.agent]) {
229
231
  throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
@@ -279,7 +281,7 @@ async function runner() {
279
281
  const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
280
282
  const fuelLabel = config.fuelSource === "local" ? "local subscription" : "Conduit pump";
281
283
  if (!driver || !workspace) {
282
- console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", "claude-code", "--workspace", "<repo>")}`);
284
+ console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
283
285
  }
284
286
  console.log(`Conduit runner connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
285
287
  for (;;) {
package/dist/client.js CHANGED
@@ -52,13 +52,21 @@ export class ConduitClient {
52
52
  delete this.config.activeAttempts[taskId];
53
53
  await this.persist(this.config);
54
54
  }
55
- /** Rotate and cache a project-scoped gateway fuel key for agent /v1 calls. */
55
+ /**
56
+ * Ensure a project-scoped gateway fuel key for agent /v1 calls.
57
+ * GET creates once (returns secret) or reports provisioned; cache miss after
58
+ * that uses POST …/rotate so heartbeats never rotate on every claim.
59
+ */
56
60
  async ensureFuel(projectId) {
57
61
  const cached = this.config.fuel?.[projectId]?.gatewayKey;
58
62
  if (cached)
59
63
  return cached;
60
- const data = await this.request(`/runner/v1/fuel/${projectId}`);
61
- const gatewayKey = String(data.gateway_secret);
64
+ const peeked = await this.request(`/runner/v1/fuel/${projectId}`);
65
+ let gatewayKey = typeof peeked.gateway_secret === "string" ? peeked.gateway_secret : "";
66
+ if (!gatewayKey) {
67
+ const rotated = await this.request(`/runner/v1/fuel/${projectId}/rotate`, { method: "POST", body: "{}" });
68
+ gatewayKey = String(rotated.gateway_secret);
69
+ }
62
70
  this.config.fuel = { ...this.config.fuel, [projectId]: { gatewayKey } };
63
71
  await this.persist(this.config);
64
72
  return gatewayKey;
package/dist/detect.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { access, constants } from "node:fs/promises";
2
2
  import { delimiter, join } from "node:path";
3
+ /** PATH probes for Connect diagnostics — not proof of a Bridge driver. */
3
4
  const CLIENTS = [
4
- { command: "codex", label: "Codex CLI" },
5
5
  { command: "claude", label: "Claude Code" },
6
+ { command: "codex", label: "Codex CLI" },
7
+ { command: "opencode", label: "OpenCode" },
8
+ { command: "agent", label: "Cursor Agent" },
6
9
  { command: "cursor", label: "Cursor" },
10
+ { command: "kiro-cli", label: "Kiro CLI" },
11
+ { command: "agy", label: "Antigravity" },
7
12
  { command: "code", label: "Visual Studio Code" },
8
13
  ];
9
14
  export async function detectInstalledClients(pathValue = process.env.PATH ?? "", platform = process.platform) {
package/dist/driver.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { z } from "zod";
5
6
  export const evidenceKinds = ["change", "test", "preview", "research", "documentation"];
@@ -20,11 +21,13 @@ export function claudeToolsForGrants(grants, verificationCommands = []) {
20
21
  tools.push("Bash(git push:*)", "Bash(gh pr create:*)");
21
22
  return tools;
22
23
  }
23
- export const claudeDeniedTools = [
24
- "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git reset --hard:*)", "Bash(git push --force:*)",
25
- "Bash(npm run deploy:*)", "Bash(pnpm deploy:*)", "Bash(yarn deploy:*)", "Bash(wrangler deploy:*)",
26
- "Bash(kubectl:*)", "Bash(terraform apply:*)",
24
+ /** Commands no driver may ever run, regardless of grants — one source for every driver's deny list. */
25
+ export const deniedCommands = [
26
+ "git merge", "git rebase", "git reset --hard", "git push --force",
27
+ "npm run deploy", "pnpm deploy", "yarn deploy", "wrangler deploy",
28
+ "kubectl", "terraform apply",
27
29
  ];
30
+ export const claudeDeniedTools = deniedCommands.map((command) => `Bash(${command}:*)`);
28
31
  function isBoundedVerificationCommand(command) {
29
32
  return /^(npm run (verify|typecheck|lint|test|build)|pnpm (verify|typecheck|lint|test|build)|yarn (verify|typecheck|lint|test|build)|cargo (test|check)|go test(?: \.\/\.\.\.)?|make (test|check))$/.test(command);
30
33
  }
@@ -70,7 +73,7 @@ export function buildAssignmentPrompt(context) {
70
73
  }
71
74
  if (rework)
72
75
  lines.push("", `REWORK FEEDBACK — an independent review returned this delivery; address every point\n${rework}`);
73
- lines.push("", "RULES", "- Stay within the change scope and boundaries.", "- Run the relevant verification commands if your permissions allow it, and report their real results.", "- Never merge, deploy, push to protected branches, or touch production.", "- Do not invent evidence. Report unknown when you could not verify a criterion.", "", "When the work is finished, end your reply with exactly one fenced ```json block:", '{"outcome": "one-paragraph summary", "changes": ["path — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "exact criterion text", "status": "met|not_met|unknown"}], "evidence": [{"kind": "change|test|preview|research|documentation", "name": "concise evidence name", "uri": "external URL if one exists", "digest": "optional digest", "details": ["observable result"], "acceptance_criteria": ["exact criterion text supported by this evidence"]}], "assumptions": [], "risks": [], "limitations": [], "head_commit": "full sha of your final commit, omit if none"}');
76
+ lines.push("", "RULES", "- Stay within the change scope and boundaries.", "- Run the relevant verification commands if your permissions allow it, and report their real results.", "- Never merge, deploy, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.", "", "When the work is finished, end your reply with exactly one fenced ```json block:", '{"outcome": "one-paragraph summary", "changes": ["path — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "exact criterion text", "status": "met|not_met|unknown"}], "evidence": [{"kind": "change|test|preview|research|documentation", "name": "concise evidence name", "uri": "external URL if one exists", "digest": "optional digest", "details": ["observable result"], "acceptance_criteria": ["exact criterion text supported by this evidence"]}], "assumptions": [], "risks": [], "limitations": [], "head_commit": "full sha of your final commit, omit if none"}');
74
77
  return lines.join("\n");
75
78
  }
76
79
  /** Parse the agent's final fenced JSON block into a bounded report. */
@@ -112,12 +115,12 @@ export const claudeCodeDriver = {
112
115
  name: "claude-code",
113
116
  async run(input) {
114
117
  const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
115
- if (fuelSource === "local" && !hasLocalVendorLogin()) {
118
+ if (fuelSource === "local" && !hasClaudeLogin()) {
116
119
  return {
117
120
  status: "failed",
118
121
  resultText: null,
119
122
  sessionId: null,
120
- error: "machine set to local fuel but claude-code has no login",
123
+ error: "machine set to local fuel but claude-code has no login (ANTHROPIC_* or ~/.claude)",
121
124
  };
122
125
  }
123
126
  const tools = claudeToolsForGrants(input.grants, input.verificationCommands);
@@ -153,22 +156,484 @@ export const claudeCodeDriver = {
153
156
  return { status: "completed", resultText: message.result ?? "", sessionId };
154
157
  },
155
158
  };
156
- export const DRIVERS = { "claude-code": claudeCodeDriver };
157
- function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit") {
159
+ /** Map Conduit grants Codex sandbox (privileged grants never widen the sandbox). */
160
+ export function codexSandboxForGrants(grants) {
161
+ if (grants.includes("repo_write") || grants.includes("branch_create") || grants.includes("pr_create") || grants.includes("test_run")) {
162
+ return "workspace-write";
163
+ }
164
+ if (grants.includes("repo_read"))
165
+ return "read-only";
166
+ return null;
167
+ }
168
+ /**
169
+ * Codex exec arguments. The sandbox is the enforcement boundary: network stays
170
+ * off inside workspace-write unless the assignment actually holds pr_create.
171
+ */
172
+ export function codexExecArgs(input, sandbox) {
173
+ // exec is non-interactive by design (no approval flag), and `exec resume`
174
+ // takes the sandbox only as a -c override, not --sandbox.
175
+ const args = input.resumeSessionId
176
+ ? ["exec", "resume", "--json", "-c", `sandbox_mode="${sandbox}"`]
177
+ : ["exec", "--json", "--sandbox", sandbox];
178
+ if (sandbox === "workspace-write" && input.grants.includes("pr_create")) {
179
+ args.push("-c", "sandbox_workspace_write.network_access=true");
180
+ }
181
+ if (input.resumeSessionId)
182
+ args.push(input.resumeSessionId);
183
+ args.push("-");
184
+ return args;
185
+ }
186
+ /**
187
+ * Since the July 2026 desktop merge, macOS machines often carry the codex CLI
188
+ * only inside the ChatGPT app bundle rather than on PATH.
189
+ */
190
+ export const CODEX_APP_BUNDLE_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex";
191
+ export function resolveCodexExecutable(env = process.env, platform = process.platform) {
192
+ const onPath = (env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, "codex")));
193
+ if (onPath)
194
+ return "codex";
195
+ if (platform === "darwin" && existsSync(CODEX_APP_BUNDLE_BINARY))
196
+ return CODEX_APP_BUNDLE_BINARY;
197
+ return "codex";
198
+ }
199
+ /** Pull the thread id and final agent message out of `codex exec --json` JSONL events. */
200
+ export function parseCodexJsonl(stdout) {
201
+ let sessionId = null;
202
+ let resultText = null;
203
+ for (const line of stdout.split("\n")) {
204
+ const trimmed = line.trim();
205
+ if (!trimmed.startsWith("{"))
206
+ continue;
207
+ let event;
208
+ try {
209
+ event = JSON.parse(trimmed);
210
+ }
211
+ catch {
212
+ continue;
213
+ }
214
+ if (typeof event.thread_id === "string")
215
+ sessionId = event.thread_id;
216
+ if (event.item?.type === "agent_message" && typeof event.item.text === "string")
217
+ resultText = event.item.text;
218
+ }
219
+ return { sessionId, resultText };
220
+ }
221
+ export const codexDriver = {
222
+ name: "codex",
223
+ async run(input) {
224
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
225
+ if (fuelSource === "local" && !hasOpenAiLogin()) {
226
+ return {
227
+ status: "failed",
228
+ resultText: null,
229
+ sessionId: null,
230
+ error: "machine set to local fuel but codex has no login (OPENAI_API_KEY / CODEX_API_KEY / ~/.codex)",
231
+ };
232
+ }
233
+ const sandbox = codexSandboxForGrants(input.grants);
234
+ if (!sandbox) {
235
+ return {
236
+ status: "failed",
237
+ resultText: null,
238
+ sessionId: null,
239
+ error: "No Bridge-mapped Codex sandbox for active grants; refusing to start agent",
240
+ };
241
+ }
242
+ // Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
243
+ const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs(input, sandbox), input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
244
+ const parsed = parseCodexJsonl(stdout);
245
+ const resultText = parsed.resultText ?? (stdout || null);
246
+ if (code !== 0) {
247
+ return { status: "failed", resultText, sessionId: parsed.sessionId, error: (stderr || stdout || `codex exited with code ${code}`).slice(0, 20_000) };
248
+ }
249
+ return { status: "completed", resultText, sessionId: parsed.sessionId };
250
+ },
251
+ };
252
+ /**
253
+ * Grants → Cursor CLI permissions (project .cursor/cli.json). With
254
+ * approvalMode "allowlist" and no --force, any command outside this list is
255
+ * denied in print mode — the same fail-closed contract as Claude's tool lists.
256
+ */
257
+ export function cursorPermissionsForGrants(grants, verificationCommands = []) {
258
+ const allow = [];
259
+ if (grants.includes("test_run")) {
260
+ allow.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
261
+ }
262
+ if (grants.includes("branch_create")) {
263
+ allow.push("Shell(git status)", "Shell(git diff)", "Shell(git add)", "Shell(git commit)", "Shell(git branch)", "Shell(git switch)");
264
+ }
265
+ if (grants.includes("pr_create"))
266
+ allow.push("Shell(git push)", "Shell(gh pr create)");
267
+ return { allow, deny: deniedCommands.map((command) => `Shell(${command})`) };
268
+ }
269
+ /** Cursor CLI arguments. Read-only assignments run in plan mode — edits are impossible, not just discouraged. */
270
+ export function cursorRunArgs(input) {
271
+ const args = ["-p", "--output-format", "json", "--workspace", input.workspace];
272
+ const canChange = ["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => input.grants.includes(grant));
273
+ if (!canChange)
274
+ args.push("--mode", "plan");
275
+ if (input.resumeSessionId)
276
+ args.push("--resume", input.resumeSessionId);
277
+ args.push(input.prompt);
278
+ return args;
279
+ }
280
+ /** Tolerant parse of `--output-format json`: accept the documented shape, fall back to raw text. */
281
+ export function parseCursorOutput(stdout) {
282
+ try {
283
+ const parsed = JSON.parse(stdout);
284
+ const resultText = typeof parsed.result === "string" ? parsed.result : typeof parsed.text === "string" ? parsed.text : stdout;
285
+ const sessionCandidate = [parsed.session_id, parsed.chat_id, parsed.chatId].find((value) => typeof value === "string");
286
+ return { resultText, sessionId: sessionCandidate ?? null, isError: parsed.is_error === true || parsed.subtype === "error" };
287
+ }
288
+ catch {
289
+ return { resultText: stdout || null, sessionId: null, isError: false };
290
+ }
291
+ }
292
+ export const cursorDriver = {
293
+ name: "cursor",
294
+ async run(input) {
295
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
296
+ // Cursor Agent authenticates with CURSOR_API_KEY / IDE login — not Conduit /v1 Anthropic/OpenAI shims.
297
+ if (fuelSource === "conduit") {
298
+ return {
299
+ status: "failed",
300
+ resultText: null,
301
+ sessionId: null,
302
+ error: "cursor driver requires local fuel (CURSOR_API_KEY or Cursor login). Run: npx @miraland-labs/conduit-bridge fuel local",
303
+ };
304
+ }
305
+ if (!input.grants.includes("repo_read") && !input.grants.includes("repo_write")) {
306
+ return {
307
+ status: "failed",
308
+ resultText: null,
309
+ sessionId: null,
310
+ error: "No Bridge-mapped Cursor access for active grants; refusing to start agent",
311
+ };
312
+ }
313
+ const executable = input.executable ?? "agent";
314
+ // A ~/.cursor directory exists even after logout — ask the CLI itself.
315
+ // `agent status` exits 0 either way, so the text is the signal.
316
+ if (!process.env.CURSOR_API_KEY) {
317
+ const status = await execute(executable, ["status"], input.workspace, 15_000, undefined, "local");
318
+ if (status.code !== 0 || /not logged in/i.test(status.stdout)) {
319
+ return { status: "failed", resultText: null, sessionId: null, error: "cursor has no login (set CURSOR_API_KEY or run `agent login`)" };
320
+ }
321
+ }
322
+ const configured = await withCursorPermissions(input.workspace, cursorPermissionsForGrants(input.grants, input.verificationCommands), () => execute(executable, cursorRunArgs(input), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
323
+ const { code, stdout, stderr } = configured;
324
+ const parsed = parseCursorOutput(stdout);
325
+ if (code !== 0 || parsed.isError) {
326
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `cursor agent exited with code ${code}`).slice(0, 20_000) };
327
+ }
328
+ return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
329
+ },
330
+ };
331
+ /** Write the assignment's permission contract to .cursor/cli.json for the run, restoring whatever was there before. */
332
+ async function withCursorPermissions(workspace, permissions, run) {
333
+ const configDir = join(workspace, ".cursor");
334
+ const configPath = join(configDir, "cli.json");
335
+ let previous = null;
336
+ try {
337
+ previous = await readFile(configPath, "utf8");
338
+ }
339
+ catch {
340
+ previous = null;
341
+ }
342
+ let config = { version: 1 };
343
+ if (previous) {
344
+ try {
345
+ config = { ...JSON.parse(previous) };
346
+ }
347
+ catch {
348
+ config = { version: 1 };
349
+ }
350
+ }
351
+ config.permissions = permissions;
352
+ config.approvalMode = "allowlist";
353
+ await mkdir(configDir, { recursive: true });
354
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
355
+ try {
356
+ return await run();
357
+ }
358
+ finally {
359
+ if (previous === null)
360
+ await rm(configPath, { force: true });
361
+ else
362
+ await writeFile(configPath, previous, "utf8");
363
+ }
364
+ }
365
+ /**
366
+ * OpenCode routes through any OpenAI/Anthropic-compatible endpoint, so it runs
367
+ * on Conduit pump fuel or a local login. Read-only assignments use its `plan`
368
+ * agent (no edit/bash tools); write assignments use `build`.
369
+ */
370
+ export function openCodeAgentForGrants(grants) {
371
+ if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
372
+ return "build";
373
+ if (grants.includes("repo_read"))
374
+ return "plan";
375
+ return null;
376
+ }
377
+ export function openCodeRunArgs(input, agent) {
378
+ const args = ["run", "--format", "json", "--agent", agent];
379
+ // plan can never write, so --auto only widens the already-bounded build agent.
380
+ if (agent === "build")
381
+ args.push("--auto");
382
+ if (input.resumeSessionId)
383
+ args.push("--session", input.resumeSessionId);
384
+ return args;
385
+ }
386
+ /** Shared deny list as OpenCode `permission.bash` rules (enforced even with `--auto`). */
387
+ export function openCodePermissionConfig() {
388
+ const bash = {};
389
+ for (const command of deniedCommands)
390
+ bash[`${command} *`] = "deny";
391
+ return { permission: { bash } };
392
+ }
393
+ /** Write opencode.json permission deny rules for the run, restoring any prior file. */
394
+ async function withOpenCodePermissions(workspace, run) {
395
+ const configPath = join(workspace, "opencode.json");
396
+ let previous = null;
397
+ try {
398
+ previous = await readFile(configPath, "utf8");
399
+ }
400
+ catch {
401
+ previous = null;
402
+ }
403
+ let config = {};
404
+ if (previous) {
405
+ try {
406
+ config = { ...JSON.parse(previous) };
407
+ }
408
+ catch {
409
+ config = {};
410
+ }
411
+ }
412
+ const deny = openCodePermissionConfig();
413
+ const existingPermission = (config.permission && typeof config.permission === "object" && !Array.isArray(config.permission))
414
+ ? { ...config.permission }
415
+ : {};
416
+ const existingBash = (existingPermission.bash && typeof existingPermission.bash === "object" && !Array.isArray(existingPermission.bash))
417
+ ? { ...existingPermission.bash }
418
+ : {};
419
+ config.permission = {
420
+ ...existingPermission,
421
+ bash: { ...existingBash, ...deny.permission.bash },
422
+ };
423
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
424
+ try {
425
+ return await run();
426
+ }
427
+ finally {
428
+ if (previous === null)
429
+ await rm(configPath, { force: true });
430
+ else
431
+ await writeFile(configPath, previous, "utf8");
432
+ }
433
+ }
434
+ /**
435
+ * OpenCode `--format json` streams JSONL events. Verified against opencode 1.18:
436
+ * assistant text arrives as `{type:"text", part:{type:"text", text:"…"}}`, the
437
+ * session id is `sessionID` on every event, and failures are `{type:"error",
438
+ * error:{…}}`. Concatenate text parts so the delivery report's fenced block
439
+ * survives multi-step runs.
440
+ */
441
+ export function parseOpenCodeOutput(stdout) {
442
+ const texts = [];
443
+ let sessionId = null;
444
+ let isError = false;
445
+ let errorMessage = null;
446
+ const whole = stdout.trim();
447
+ for (const line of whole.split("\n")) {
448
+ const trimmed = line.trim();
449
+ if (!trimmed.startsWith("{"))
450
+ continue;
451
+ let event;
452
+ try {
453
+ event = JSON.parse(trimmed);
454
+ }
455
+ catch {
456
+ continue;
457
+ }
458
+ const session = [event.sessionID, event.session_id].find((value) => typeof value === "string");
459
+ if (typeof session === "string")
460
+ sessionId = session;
461
+ if (event.type === "text" && event.part?.type === "text" && typeof event.part.text === "string")
462
+ texts.push(event.part.text);
463
+ if (event.type === "error") {
464
+ isError = true;
465
+ const err = event.error;
466
+ errorMessage = typeof err === "string" ? err : (typeof err?.data?.message === "string" ? err.data.message : typeof err?.message === "string" ? err.message : null);
467
+ }
468
+ }
469
+ const resultText = texts.length ? texts.join("\n") : (isError && errorMessage ? errorMessage : (whole || null));
470
+ return { resultText, sessionId, isError };
471
+ }
472
+ export const openCodeDriver = {
473
+ name: "opencode",
474
+ async run(input) {
475
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
476
+ if (fuelSource === "local" && !hasOpenCodeLogin()) {
477
+ return { status: "failed", resultText: null, sessionId: null, error: "machine set to local fuel but opencode has no login (auth.json / provider key in ~/.local/share/opencode or ~/.config/opencode)" };
478
+ }
479
+ const agent = openCodeAgentForGrants(input.grants);
480
+ if (!agent) {
481
+ return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped OpenCode agent for active grants; refusing to start agent" };
482
+ }
483
+ const args = openCodeRunArgs(input, agent);
484
+ args.push(input.prompt);
485
+ const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
486
+ const parsed = parseOpenCodeOutput(stdout);
487
+ if (code !== 0 || parsed.isError) {
488
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `opencode exited with code ${code}`).slice(0, 20_000) };
489
+ }
490
+ return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
491
+ },
492
+ };
493
+ /**
494
+ * Kiro CLI grant map (verified flag surface against kiro-cli 2.13.0).
495
+ * `--trust-tools` takes a comma-separated allowlist of tool NAMES; anything
496
+ * outside it prompts, which in `--no-interactive` mode is a hard stop — the
497
+ * fail-closed contract. Tool names confirmed from the CLI help: fs_read,
498
+ * fs_write, execute_bash. Note the coarseness: kiro-cli's --trust-tools has no
499
+ * per-command scoping, so any bash-requiring grant trusts execute_bash wholesale
500
+ * (Antigravity-tier, not Claude-tier). Fine-grained command allowlisting would
501
+ * need a kiro agent config file — deferred until it can be verified with a login.
502
+ */
503
+ export function kiroTrustedTools(grants) {
504
+ const tools = [];
505
+ if (grants.includes("repo_read"))
506
+ tools.push("fs_read");
507
+ if (grants.includes("repo_write"))
508
+ tools.push("fs_write");
509
+ if (["test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
510
+ tools.push("execute_bash");
511
+ return tools;
512
+ }
513
+ export function kiroChatArgs(input, trustedTools) {
514
+ const args = ["chat", "--no-interactive"];
515
+ if (input.resumeSessionId)
516
+ args.push("--resume-id", input.resumeSessionId);
517
+ // Fail closed: an empty allowlist would trust nothing, but we already refused
518
+ // upstream; passing the explicit list keeps ungranted tools blocked.
519
+ args.push("--trust-tools", trustedTools.join(","));
520
+ return args;
521
+ }
522
+ export const kiroDriver = {
523
+ name: "kiro",
524
+ async run(input) {
525
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
526
+ // Kiro authenticates with KIRO_API_KEY or Builder ID login — not Conduit /v1 shims.
527
+ if (fuelSource === "conduit") {
528
+ return { status: "failed", resultText: null, sessionId: null, error: "kiro driver requires local fuel (KIRO_API_KEY or `kiro-cli login`). Run: npx @miraland-labs/conduit-bridge fuel local" };
529
+ }
530
+ const trusted = kiroTrustedTools(input.grants);
531
+ if (!trusted.length) {
532
+ return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Kiro tools for active grants; refusing to start agent" };
533
+ }
534
+ const executable = input.executable ?? "kiro-cli";
535
+ // ~/.kiro exists even when logged out, so ask the CLI. `whoami` exits non-zero
536
+ // and prints "Not logged in" when unauthenticated — check before the run so we
537
+ // never hang on the interactive browser login in --no-interactive mode.
538
+ if (!hasKiroLogin()) {
539
+ const who = await execute(executable, ["whoami"], input.workspace, 15_000, undefined, "local");
540
+ if (who.code !== 0 || /not logged in/i.test(who.stdout + who.stderr)) {
541
+ return { status: "failed", resultText: null, sessionId: null, error: "kiro has no login (set KIRO_API_KEY or run `kiro-cli login`)" };
542
+ }
543
+ }
544
+ const args = kiroChatArgs(input, trusted);
545
+ args.push(input.prompt);
546
+ const { code, stdout, stderr } = await execute(executable, args, input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
547
+ if (code !== 0) {
548
+ return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `kiro-cli exited with code ${code}`).slice(0, 20_000) };
549
+ }
550
+ return { status: "completed", resultText: stdout, sessionId: null };
551
+ },
552
+ };
553
+ /**
554
+ * Antigravity's `agy` CLI (verified against agy 1.1.3): non-interactive runs use
555
+ * `-p`/`--print` with `--mode plan` (read-only) or `--mode accept-edits`. Its
556
+ * enforcement is mode-level, not per-tool — coarser than Claude, the Cursor tier.
557
+ * Write mode adds `--sandbox` for terminal restrictions. Output is plain text.
558
+ */
559
+ export function antigravityModeForGrants(grants) {
560
+ if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
561
+ return "accept-edits";
562
+ if (grants.includes("repo_read"))
563
+ return "plan";
564
+ return null;
565
+ }
566
+ export function antigravityRunArgs(input, mode) {
567
+ const args = ["-p", "--mode", mode];
568
+ if (mode === "accept-edits")
569
+ args.push("--sandbox");
570
+ args.push(input.prompt);
571
+ return args;
572
+ }
573
+ export const antigravityDriver = {
574
+ name: "antigravity",
575
+ async run(input) {
576
+ const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
577
+ // Antigravity authenticates with a Google login / GEMINI_API_KEY — not Conduit /v1 shims.
578
+ if (fuelSource === "conduit") {
579
+ return { status: "failed", resultText: null, sessionId: null, error: "antigravity driver requires local fuel (GEMINI_API_KEY or `agy` Google login). Run: npx @miraland-labs/conduit-bridge fuel local" };
580
+ }
581
+ if (!hasAntigravityLogin()) {
582
+ return { status: "failed", resultText: null, sessionId: null, error: "antigravity has no login (set GEMINI_API_KEY or sign in with `agy`)" };
583
+ }
584
+ const mode = antigravityModeForGrants(input.grants);
585
+ if (!mode) {
586
+ return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
587
+ }
588
+ // agy print mode emits plain text and does not surface a resumable id, so rework resume is not wired.
589
+ const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
590
+ if (code !== 0) {
591
+ return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `agy exited with code ${code}`).slice(0, 20_000) };
592
+ }
593
+ return { status: "completed", resultText: stdout, sessionId: null };
594
+ },
595
+ };
596
+ /** Stable driver ids shown in Connect / CLI. Order is product preference, not exclusivity. */
597
+ export const SUPPORTED_AGENTS = [
598
+ { id: "claude-code", label: "Claude Code", executableHint: "claude" },
599
+ { id: "codex", label: "Codex (ChatGPT)", executableHint: "codex" },
600
+ { id: "cursor", label: "Cursor Agent", executableHint: "agent" },
601
+ { id: "opencode", label: "OpenCode", executableHint: "opencode" },
602
+ { id: "kiro", label: "Kiro CLI", executableHint: "kiro-cli" },
603
+ { id: "antigravity", label: "Antigravity (agy)", executableHint: "agy" },
604
+ ];
605
+ export const DRIVERS = {
606
+ "claude-code": claudeCodeDriver,
607
+ codex: codexDriver,
608
+ cursor: cursorDriver,
609
+ opencode: openCodeDriver,
610
+ kiro: kiroDriver,
611
+ antigravity: antigravityDriver,
612
+ };
613
+ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit", stdinText) {
158
614
  return new Promise((resolve, reject) => {
159
- const child = spawn(executable, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: boundedEnvironment(fuel, fuelSource) });
615
+ const child = spawn(executable, args, {
616
+ cwd,
617
+ stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
618
+ env: boundedEnvironment(fuel, fuelSource),
619
+ });
160
620
  let stdout = "";
161
621
  let stderr = "";
162
622
  const timer = setTimeout(() => { child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 10_000).unref(); }, timeoutMs);
163
- child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
164
- child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
623
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
624
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
165
625
  child.on("error", (error) => { clearTimeout(timer); reject(error); });
166
626
  child.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr: stderr.slice(0, 20_000) }); });
627
+ if (stdinText !== undefined && child.stdin) {
628
+ child.stdin.write(stdinText);
629
+ child.stdin.end();
630
+ }
167
631
  });
168
632
  }
169
633
  const LOCAL_VENDOR_ENV = [
170
634
  "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
171
- "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE",
635
+ "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE", "CODEX_API_KEY",
636
+ "CURSOR_API_KEY", "KIRO_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
172
637
  ];
173
638
  function boundedEnvironment(fuel, fuelSource = "conduit") {
174
639
  // Proxy and TLS variables stay: fueled agents must reach Conduit's /v1 on
@@ -183,17 +648,53 @@ function boundedEnvironment(fuel, fuelSource = "conduit") {
183
648
  env.ANTHROPIC_BASE_URL = v1;
184
649
  env.OPENAI_API_KEY = fuel.gatewayKey;
185
650
  env.OPENAI_BASE_URL = v1;
651
+ env.CODEX_API_KEY = fuel.gatewayKey;
186
652
  }
187
653
  return env;
188
654
  }
189
- /** True when local fuel can use a host vendor login (env key or Claude credentials dir). */
190
- export function hasLocalVendorLogin(env = process.env) {
191
- if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.OPENAI_API_KEY)
655
+ export function hasClaudeLogin(env = process.env) {
656
+ if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN)
657
+ return true;
658
+ const home = env.HOME;
659
+ return Boolean(home && existsSync(join(home, ".claude")));
660
+ }
661
+ export function hasOpenAiLogin(env = process.env) {
662
+ if (env.OPENAI_API_KEY || env.CODEX_API_KEY)
663
+ return true;
664
+ const home = env.HOME;
665
+ return Boolean(home && existsSync(join(home, ".codex", "auth.json")));
666
+ }
667
+ export function hasCursorLogin(env = process.env) {
668
+ if (env.CURSOR_API_KEY)
669
+ return true;
670
+ const home = env.HOME;
671
+ return Boolean(home && (existsSync(join(home, ".cursor")) || existsSync(join(home, ".config", "cursor"))));
672
+ }
673
+ export function hasOpenCodeLogin(env = process.env) {
674
+ if (env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY || env.OPENROUTER_API_KEY)
192
675
  return true;
193
676
  const home = env.HOME;
194
677
  if (!home)
195
678
  return false;
196
- return existsSync(join(home, ".claude"));
679
+ return existsSync(join(home, ".local", "share", "opencode", "auth.json")) || existsSync(join(home, ".config", "opencode"));
680
+ }
681
+ export function hasKiroLogin(env = process.env) {
682
+ // Only KIRO_API_KEY is a reliable headless signal: ~/.kiro exists even when
683
+ // logged out (the IDE creates it), so a login is confirmed at spawn via
684
+ // `kiro-cli whoami` rather than a directory probe.
685
+ return Boolean(env.KIRO_API_KEY);
686
+ }
687
+ export function hasAntigravityLogin(env = process.env) {
688
+ if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY)
689
+ return true;
690
+ const home = env.HOME;
691
+ // agy 1.1.3 stores its Google session state under ~/.gemini (shared with the IDE).
692
+ return Boolean(home && (existsSync(join(home, ".gemini")) || existsSync(join(home, ".antigravity"))));
693
+ }
694
+ /** True when local fuel can use a host vendor login for at least one supported agent. */
695
+ export function hasLocalVendorLogin(env = process.env) {
696
+ return hasClaudeLogin(env) || hasOpenAiLogin(env) || hasCursorLogin(env)
697
+ || hasOpenCodeLogin(env) || hasKiroLogin(env) || hasAntigravityLogin(env);
197
698
  }
198
699
  /** Exported for tests — builds the stripped process env with optional Conduit fuel. */
199
700
  export function agentProcessEnv(fuel, fuelSource = "conduit") {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.1.0",
4
- "description": "Conduit Bridge CLI — join, connect, and run local agent work for a Conduit organization",
3
+ "version": "0.4.1",
4
+ "description": "Conduit Bridge CLI — join, connect, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conduit": "dist/cli.js"