@miraland-labs/conduit-bridge 0.9.12 → 0.10.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 +10 -6
- package/dist/cli.js +4 -4
- package/dist/detect.js +3 -2
- package/dist/driver.js +154 -122
- package/dist/drivers.js +2 -1
- package/dist/execution-class.js +278 -0
- package/dist/execution.js +30 -2
- package/dist/ops.js +1 -1
- package/dist/preflight.js +4 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
|
|
4
4
|
|
|
5
|
-
**Package version:** `0.
|
|
5
|
+
**Package version:** `0.10.0` — **ExecutionClass** permission policy (`observe` | `observe_network` | `publish_artifact` | `verify` | `mutate_repo`) compiled once from the assignment contract and projected into every driver, so prompts match enforcement without a growing publish-command allow-list. Artifact evidence digests still normalize bare 64-hex to `sha256:<hex>`. New **Experimental Pi** lane (`pi` from `@earendil-works/pi-coding-agent`, local fuel); `observe_network` is refused on Pi (no fetch-without-bash). Heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. Attempt worktrees fetch/start from the initiative base when the source tip is behind or history was rewritten, without resetting the source checkout. Also includes **ops** helpers for macOS / Linux / Windows, LaunchAgent/systemd **PATH** for `~/.local/bin`, disconnect/disengage, multi-driver lanes, shared slots **1–4**, worktrees, and optional `--ensure-checkout`. Protocol 1 heartbeats remain compatible for presence but cannot receive work.
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -12,6 +12,7 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
|
|
|
12
12
|
- **Codex** — `codex`
|
|
13
13
|
- **OpenCode** — `opencode`
|
|
14
14
|
- **Cursor Agent** — `agent` (local fuel)
|
|
15
|
+
- **Pi** — `pi` (local fuel, Experimental; pin `@earendil-works/pi-coding-agent`)
|
|
15
16
|
- **Kiro CLI** — `kiro-cli` (local fuel)
|
|
16
17
|
- **Antigravity** — `agy` (local fuel)
|
|
17
18
|
|
|
@@ -77,13 +78,16 @@ repository delivery with PR/evidence and a successful rework cycle. All other la
|
|
|
77
78
|
|
|
78
79
|
## Grant enforcement
|
|
79
80
|
|
|
81
|
+
Assignments compile to an **ExecutionClass**, then each driver projects that class:
|
|
82
|
+
|
|
80
83
|
| Driver | Fuel | Mechanism |
|
|
81
84
|
| --- | --- | --- |
|
|
82
|
-
| `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per
|
|
83
|
-
| `codex` | pump or local | sandbox `read-only` / `workspace-write` |
|
|
84
|
-
| `opencode` | pump or local | per-run
|
|
85
|
-
| `cursor` | local only | per-run `.cursor/cli.json`
|
|
85
|
+
| `claude-code` | pump or local | `--allowedTools` / `--disallowedTools` per class |
|
|
86
|
+
| `codex` | pump or local | sandbox `read-only` / `workspace-write` (+ network when class requires) |
|
|
87
|
+
| `opencode` | pump or local | plan/build agent + per-run bash deny list |
|
|
88
|
+
| `cursor` | local only | per-run `.cursor/cli.json` + `--force` when class needs open tools |
|
|
89
|
+
| `pi` | local only | `--tools` class map (`observe_network` refused) |
|
|
86
90
|
| `kiro` | local only | `--trust-tools` allowlist |
|
|
87
91
|
| `antigravity` | local only | `-p --mode plan` / `accept-edits` |
|
|
88
92
|
|
|
89
|
-
Cursor/Kiro/Antigravity require local fuel (`ops install` sets this for you).
|
|
93
|
+
Cursor/Pi/Kiro/Antigravity require local fuel (`ops install` sets this for you).
|
package/dist/cli.js
CHANGED
|
@@ -131,8 +131,8 @@ async function join() {
|
|
|
131
131
|
console.log(`Proposed roles: ${capabilities.join(", ")}`);
|
|
132
132
|
console.log(`Assignments at once: ${leaseCapacity}`);
|
|
133
133
|
console.log(`Fuel source: ${plannedFuel === "local" ? "local subscription" : "Conduit pump"}`
|
|
134
|
-
+ (!fuelSource && plannedFuel === "local" ? " (auto: only
|
|
135
|
-
console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/kiro/antigravity require local fuel).`);
|
|
134
|
+
+ (!fuelSource && plannedFuel === "local" ? " (auto: only local-fuel agents detected)" : ""));
|
|
135
|
+
console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/pi/kiro/antigravity require local fuel).`);
|
|
136
136
|
console.log("Use --machine <name> to override the computer label.\n");
|
|
137
137
|
const response = await fetch(`${baseUrl}/runner/v1/connect/requests`, {
|
|
138
138
|
method: "POST",
|
|
@@ -246,10 +246,10 @@ async function finishConnection(baseUrl, data, fuelSource) {
|
|
|
246
246
|
console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
|
|
247
247
|
console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
|
|
248
248
|
console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
|
|
249
|
-
+ (fuelAutoLocal ? " (auto: only
|
|
249
|
+
+ (fuelAutoLocal ? " (auto: only local-fuel agents detected)" : ""));
|
|
250
250
|
const localOnly = localFuelOnlyClients(detected);
|
|
251
251
|
if (config.fuelSource === "conduit" && localOnly.length) {
|
|
252
|
-
console.log(`Note: ${localOnly.join(", ")} require local fuel per lane (set automatically for cursor/kiro/antigravity).`);
|
|
252
|
+
console.log(`Note: ${localOnly.join(", ")} require local fuel per lane (set automatically for cursor/pi/kiro/antigravity).`);
|
|
253
253
|
}
|
|
254
254
|
console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
|
|
255
255
|
console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
|
package/dist/detect.js
CHANGED
|
@@ -7,6 +7,7 @@ const CLIENTS = [
|
|
|
7
7
|
{ command: "opencode", label: "OpenCode" },
|
|
8
8
|
{ command: "agent", label: "Cursor Agent" },
|
|
9
9
|
{ command: "cursor", label: "Cursor" },
|
|
10
|
+
{ command: "pi", label: "Pi" },
|
|
10
11
|
{ command: "kiro-cli", label: "Kiro CLI" },
|
|
11
12
|
{ command: "agy", label: "Antigravity" },
|
|
12
13
|
{ command: "code", label: "Visual Studio Code" },
|
|
@@ -14,13 +15,13 @@ const CLIENTS = [
|
|
|
14
15
|
/** Agents Conduit /v1 pump fuel can drive today. */
|
|
15
16
|
const PUMP_CAPABLE_LABELS = new Set(["Claude Code", "Codex CLI", "OpenCode"]);
|
|
16
17
|
/** Agents that only accept vendor login — Bridge refuses Conduit pump for these drivers. */
|
|
17
|
-
const LOCAL_FUEL_ONLY_LABELS = new Set(["Cursor Agent", "Cursor", "Kiro CLI", "Antigravity"]);
|
|
18
|
+
const LOCAL_FUEL_ONLY_LABELS = new Set(["Cursor Agent", "Cursor", "Pi", "Kiro CLI", "Antigravity"]);
|
|
18
19
|
export function localFuelOnlyClients(detected) {
|
|
19
20
|
return detected.filter((label) => LOCAL_FUEL_ONLY_LABELS.has(label));
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* Default join fuel when `--fuel` is omitted.
|
|
23
|
-
* Pump when any pump-capable client is present; local when only
|
|
24
|
+
* Pump when any pump-capable client is present; local when only local-fuel agents are.
|
|
24
25
|
*/
|
|
25
26
|
export function suggestFuelSource(detected) {
|
|
26
27
|
const hasPumpCapable = detected.some((label) => PUMP_CAPABLE_LABELS.has(label));
|
package/dist/driver.js
CHANGED
|
@@ -3,6 +3,8 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectKiroTools, projectPi, resolveExecutionClass, } from "./execution-class.js";
|
|
7
|
+
export { branchCreateCommands, deniedCommands, isBoundedVerificationCommand, prCreateCommands, resolveExecutionClass, } from "./execution-class.js";
|
|
6
8
|
function deliveryLanguageRule(language) {
|
|
7
9
|
if (language === "zh") {
|
|
8
10
|
return "- Write every human-facing delivery packet string (outcome, changes, verification, assumptions, risks, limitations, criterion text) in Chinese (Simplified). Keep JSON keys and enum token values in English.";
|
|
@@ -45,39 +47,20 @@ export function tierForRisk(risk) {
|
|
|
45
47
|
export function isSafeModelName(value) {
|
|
46
48
|
return /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/.test(value);
|
|
47
49
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Shell commands each grant authorizes — one source of truth mapped per driver
|
|
50
|
-
* (`Bash(cmd:*)` for Claude, `Shell(cmd)` for Cursor) so the two cannot drift.
|
|
51
|
-
* rev-parse/log are read-only but required: the delivery contract reports the
|
|
52
|
-
* committed head sha, so a committing agent must be able to read it.
|
|
53
|
-
*/
|
|
54
|
-
export const branchCreateCommands = ["git status", "git diff", "git add", "git commit", "git branch", "git switch", "git rev-parse", "git log"];
|
|
55
|
-
export const prCreateCommands = ["git push", "gh pr create"];
|
|
56
|
-
/** Grants → Claude Code tool allow-list. Privileged grants are never mapped. */
|
|
50
|
+
/** Grants → Claude Code tool allow-list (mutate_repo mapping). Privileged grants are never mapped. */
|
|
57
51
|
export function claudeToolsForGrants(grants, verificationCommands = []) {
|
|
58
|
-
|
|
59
|
-
if (grants.includes("repo_read"))
|
|
60
|
-
tools.push("Read", "Glob", "Grep");
|
|
61
|
-
if (grants.includes("repo_write"))
|
|
62
|
-
tools.push("Edit", "Write");
|
|
63
|
-
if (grants.includes("test_run")) {
|
|
64
|
-
tools.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`));
|
|
65
|
-
}
|
|
66
|
-
if (grants.includes("branch_create"))
|
|
67
|
-
tools.push(...branchCreateCommands.map((command) => `Bash(${command}:*)`));
|
|
68
|
-
if (grants.includes("pr_create"))
|
|
69
|
-
tools.push(...prCreateCommands.map((command) => `Bash(${command}:*)`));
|
|
70
|
-
return tools;
|
|
52
|
+
return projectClaude("mutate_repo", { grants, verificationCommands }).allowedTools;
|
|
71
53
|
}
|
|
72
|
-
/** Commands no driver may ever run, regardless of grants — one source for every driver's deny list. */
|
|
73
|
-
export const deniedCommands = [
|
|
74
|
-
"git merge", "git rebase", "git reset --hard", "git push --force",
|
|
75
|
-
"npm run deploy", "pnpm deploy", "yarn deploy", "wrangler deploy",
|
|
76
|
-
"kubectl", "terraform apply",
|
|
77
|
-
];
|
|
78
54
|
export const claudeDeniedTools = deniedCommands.map((command) => `Bash(${command}:*)`);
|
|
79
|
-
function
|
|
80
|
-
|
|
55
|
+
function resolveRunClass(input) {
|
|
56
|
+
if (input.executionClass)
|
|
57
|
+
return input.executionClass;
|
|
58
|
+
return resolveExecutionClass({
|
|
59
|
+
grants: input.grants,
|
|
60
|
+
capabilities: input.capabilities,
|
|
61
|
+
deliverable: input.deliverable,
|
|
62
|
+
workRole: input.workRole,
|
|
63
|
+
});
|
|
81
64
|
}
|
|
82
65
|
export function buildAssignmentPrompt(context) {
|
|
83
66
|
const packageContext = context.workPackage;
|
|
@@ -90,9 +73,6 @@ export function buildAssignmentPrompt(context) {
|
|
|
90
73
|
const changeScope = packageContext?.change_scope?.length ? packageContext.change_scope : spec.change_scope;
|
|
91
74
|
const evidence = packageContext?.required_evidence?.length ? packageContext.required_evidence : spec.required_evidence;
|
|
92
75
|
const rework = packageContext?.rework_feedback ?? context.reworkFeedback;
|
|
93
|
-
const labeledArtifact = (packageContext?.deliverable ?? spec.deliverable) === "artifact";
|
|
94
|
-
// Research is discovery in the delivery report — never a published content-pack artifact.
|
|
95
|
-
const artifact = labeledArtifact && role !== "research";
|
|
96
76
|
const lines = [
|
|
97
77
|
`You are completing one delegated Conduit assignment as the ${role} role. Work only inside the current workspace.`,
|
|
98
78
|
"",
|
|
@@ -135,48 +115,27 @@ export function buildAssignmentPrompt(context) {
|
|
|
135
115
|
lines.push("", `REWORK FEEDBACK — an independent review returned this delivery; address every point\n${rework}`);
|
|
136
116
|
const mustCommit = context.grants.includes("repo_write") && context.grants.includes("branch_create");
|
|
137
117
|
const mustOpenPr = mustCommit && context.grants.includes("pr_create") && Boolean(changeScope?.length);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
];
|
|
145
|
-
const hasExternalNetwork = (spec.required_capabilities ?? []).includes("external_network");
|
|
118
|
+
const executionClass = context.executionClass ?? resolveExecutionClass({
|
|
119
|
+
grants: context.grants,
|
|
120
|
+
capabilities: spec.required_capabilities,
|
|
121
|
+
deliverable: packageContext?.deliverable ?? spec.deliverable,
|
|
122
|
+
workRole: role,
|
|
123
|
+
});
|
|
146
124
|
const languageRule = deliveryLanguageRule(packageContext?.working_language ?? spec.working_language);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
: []), ...(mustCommit
|
|
162
|
-
? [
|
|
163
|
-
"- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) — do not create a new branch — and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected.",
|
|
164
|
-
...(mustOpenPr
|
|
165
|
-
? [
|
|
166
|
-
"- This assignment includes pr_create: after committing, `git push -u origin HEAD` and `gh pr create` against the repository default branch, then set pull_request_url to that PR URL. Conduit merge-on-accept lands the PR when the owner Accepts — a head_commit without a PR leaves main unchanged and breaks later packages.",
|
|
167
|
-
]
|
|
168
|
-
: ["- You may commit locally; opening a pull request is outside this assignment's grants."]),
|
|
169
|
-
]
|
|
170
|
-
: artifact
|
|
171
|
-
? [
|
|
172
|
-
// repo_write without branch_create: the agent may write, but the output is not repository
|
|
173
|
-
// content. `.conduit/` is already in .git/info/exclude, so this path is ignored by git and
|
|
174
|
-
// `git status` stays clean — which is the point for a content pack, and mandatory for
|
|
175
|
-
// anything binary.
|
|
176
|
-
"- This assignment delivers an artifact, not repository content. Write your output under `.conduit/artifacts/` — that path is excluded from git, so do not commit anything and do not report a head_commit.",
|
|
177
|
-
"- Publish the artifact to its destination and report each output in your evidence with the published HTTPS URL and digest exactly as `sha256:` plus the 64-character hex hash (bare hex from shasum is normalized, but prefer the prefixed form). An artifact nobody can fetch is not a delivery.",
|
|
178
|
-
]
|
|
179
|
-
: ["- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead."]), "- Never merge, deploy, force-push, 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.",
|
|
125
|
+
const classRules = executionClassPromptRules({
|
|
126
|
+
executionClass,
|
|
127
|
+
grants: context.grants,
|
|
128
|
+
verificationCommands: context.verificationCommands,
|
|
129
|
+
capabilities: spec.required_capabilities,
|
|
130
|
+
mustCommit,
|
|
131
|
+
mustOpenPr,
|
|
132
|
+
});
|
|
133
|
+
lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(languageRule ? [languageRule] : []), ...classRules, "- Never merge, deploy, force-push, 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.",
|
|
134
|
+
// The reviewer requests changes when "verification output is only summarized (not verbatim)"
|
|
135
|
+
// (REVIEWER_PROMPT). Only the bounded-shell rule said to report real output, so publish_artifact
|
|
136
|
+
// — the class the reviewer scrutinises hardest — summarized, and every criterion came back
|
|
137
|
+
// unsupported. Stated once here so it holds for every class rather than per branch.
|
|
138
|
+
"- Paste the actual command output into the evidence details: the HTTP status line, the digest command's output, the fetched body. A summary of what a command printed (\"returned HTTP 200\", \"digest matches\") is treated as unsupported and returns the delivery. Writing the proof to a file does not count — only the delivery packet is reviewed, so the output must be in the evidence entry itself.",
|
|
180
139
|
// A met claim with nothing backing it is rejected server-side ("Met acceptance criteria require
|
|
181
140
|
// mapped evidence"). Observed live: six criteria marked met, evidence mapped to four, whole
|
|
182
141
|
// delivery lost. Say it here rather than let the agent discover it by failing.
|
|
@@ -320,10 +279,14 @@ export const claudeCodeDriver = {
|
|
|
320
279
|
error: "machine set to local fuel but claude-code has no login (ANTHROPIC_* or ~/.claude)",
|
|
321
280
|
};
|
|
322
281
|
}
|
|
323
|
-
const
|
|
282
|
+
const executionClass = resolveRunClass(input);
|
|
283
|
+
const projected = projectClaude(executionClass, {
|
|
284
|
+
grants: input.grants,
|
|
285
|
+
verificationCommands: input.verificationCommands,
|
|
286
|
+
});
|
|
324
287
|
// Fail closed: omitting --allowedTools would leave only the deny-list and
|
|
325
288
|
// broaden the agent beyond the grant contract (privileged grants map to none).
|
|
326
|
-
if (!
|
|
289
|
+
if (!projected.allowedTools.length) {
|
|
327
290
|
return {
|
|
328
291
|
status: "failed",
|
|
329
292
|
resultText: null,
|
|
@@ -336,9 +299,9 @@ export const claudeCodeDriver = {
|
|
|
336
299
|
args.push("--model", input.model);
|
|
337
300
|
if (input.resumeSessionId)
|
|
338
301
|
args.push("--resume", input.resumeSessionId);
|
|
339
|
-
args.push("--allowedTools",
|
|
340
|
-
args.push("--disallowedTools",
|
|
341
|
-
if (
|
|
302
|
+
args.push("--allowedTools", projected.allowedTools.join(","));
|
|
303
|
+
args.push("--disallowedTools", projected.disallowedTools.join(","));
|
|
304
|
+
if (projected.acceptEdits)
|
|
342
305
|
args.push("--permission-mode", "acceptEdits");
|
|
343
306
|
const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
|
|
344
307
|
let message = null;
|
|
@@ -366,7 +329,7 @@ export function codexSandboxForGrants(grants) {
|
|
|
366
329
|
}
|
|
367
330
|
/**
|
|
368
331
|
* Codex exec arguments. The sandbox is the enforcement boundary: network stays
|
|
369
|
-
* off inside workspace-write unless the
|
|
332
|
+
* off inside workspace-write unless the class/capabilities require it.
|
|
370
333
|
*/
|
|
371
334
|
export function codexExecArgs(input, sandbox) {
|
|
372
335
|
// exec is non-interactive by design (no approval flag), and `exec resume`
|
|
@@ -376,7 +339,9 @@ export function codexExecArgs(input, sandbox) {
|
|
|
376
339
|
: ["exec", "--json", "--sandbox", sandbox];
|
|
377
340
|
if (input.model)
|
|
378
341
|
args.push("-m", input.model);
|
|
379
|
-
|
|
342
|
+
const networkAccess = input.networkAccess
|
|
343
|
+
?? (sandbox === "workspace-write" && input.capabilities?.includes("external_network"));
|
|
344
|
+
if (sandbox === "workspace-write" && networkAccess) {
|
|
380
345
|
args.push("-c", "sandbox_workspace_write.network_access=true");
|
|
381
346
|
}
|
|
382
347
|
if (input.resumeSessionId)
|
|
@@ -431,8 +396,7 @@ export const codexDriver = {
|
|
|
431
396
|
error: "machine set to local fuel but codex has no login (OPENAI_API_KEY / CODEX_API_KEY / ~/.codex)",
|
|
432
397
|
};
|
|
433
398
|
}
|
|
434
|
-
|
|
435
|
-
if (!sandbox) {
|
|
399
|
+
if (!hasMappedRepoAccess(input.grants)) {
|
|
436
400
|
return {
|
|
437
401
|
status: "failed",
|
|
438
402
|
resultText: null,
|
|
@@ -440,8 +404,10 @@ export const codexDriver = {
|
|
|
440
404
|
error: "No Bridge-mapped Codex sandbox for active grants; refusing to start agent",
|
|
441
405
|
};
|
|
442
406
|
}
|
|
407
|
+
const executionClass = resolveRunClass(input);
|
|
408
|
+
const projected = projectCodex(executionClass, { capabilities: input.capabilities });
|
|
443
409
|
// Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
|
|
444
|
-
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);
|
|
410
|
+
const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
|
|
445
411
|
const parsed = parseCodexJsonl(stdout);
|
|
446
412
|
const resultText = parsed.resultText ?? (stdout || null);
|
|
447
413
|
if (code !== 0) {
|
|
@@ -451,38 +417,19 @@ export const codexDriver = {
|
|
|
451
417
|
},
|
|
452
418
|
};
|
|
453
419
|
/**
|
|
454
|
-
* Grants + capabilities → Cursor CLI permissions
|
|
420
|
+
* Grants + capabilities → Cursor CLI permissions via ExecutionClass.
|
|
455
421
|
* Current Cursor Agent schema accepts only `permissions.{allow,deny}` (no
|
|
456
422
|
* `version` / `approvalMode`). Outside the allow list is denied without --force.
|
|
457
423
|
*
|
|
458
|
-
* `
|
|
424
|
+
* `observe_network` / networked mutate / publish_artifact use `--force` (see
|
|
459
425
|
* cursorRunArgs). Field evidence: allow-listing WebFetch alone still yields
|
|
460
|
-
* "User Rejected" under headless `-p`; `--force` is required for fetch
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* sandbox_workspace_write.network_access=true.
|
|
426
|
+
* "User Rejected" under headless `-p`; `--force` is required for fetch and for
|
|
427
|
+
* publish shell under open class policy. Research-only networked assignments
|
|
428
|
+
* deny Shell(*) and Write(**).
|
|
464
429
|
*/
|
|
465
|
-
export function cursorPermissionsForGrants(grants, verificationCommands = [], capabilities = []) {
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
allow.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
|
|
469
|
-
}
|
|
470
|
-
if (grants.includes("branch_create"))
|
|
471
|
-
allow.push(...branchCreateCommands.map((command) => `Shell(${command})`));
|
|
472
|
-
if (grants.includes("pr_create"))
|
|
473
|
-
allow.push(...prCreateCommands.map((command) => `Shell(${command})`));
|
|
474
|
-
// Cursor docs: WebFetch(domainOrPattern); WebFetch(*) auto-approves any domain.
|
|
475
|
-
if (capabilities.includes("external_network"))
|
|
476
|
-
allow.push("WebFetch(*)");
|
|
477
|
-
const deny = deniedCommands.map((command) => `Shell(${command})`);
|
|
478
|
-
// --force opens anything not denied; lock shell/write when the assignment has no mutate/shell grants.
|
|
479
|
-
if (capabilities.includes("external_network")
|
|
480
|
-
&& !grants.includes("repo_write")
|
|
481
|
-
&& !grants.includes("test_run")
|
|
482
|
-
&& !grants.includes("branch_create")
|
|
483
|
-
&& !grants.includes("pr_create")) {
|
|
484
|
-
deny.push("Shell(*)", "Write(**)");
|
|
485
|
-
}
|
|
430
|
+
export function cursorPermissionsForGrants(grants, verificationCommands = [], capabilities = [], deliverable = "repository", workRole) {
|
|
431
|
+
const executionClass = resolveExecutionClass({ grants, capabilities, deliverable, workRole });
|
|
432
|
+
const { allow, deny } = projectCursor(executionClass, { grants, verificationCommands, capabilities });
|
|
486
433
|
return { allow, deny };
|
|
487
434
|
}
|
|
488
435
|
/**
|
|
@@ -501,8 +448,13 @@ export function cursorRunArgs(input) {
|
|
|
501
448
|
const args = ["-p", "--output-format", "json", "--workspace", input.workspace];
|
|
502
449
|
if (input.trustWorkspace)
|
|
503
450
|
args.push("--trust");
|
|
504
|
-
|
|
505
|
-
|
|
451
|
+
const executionClass = resolveRunClass(input);
|
|
452
|
+
const force = input.force ?? projectCursor(executionClass, {
|
|
453
|
+
grants: input.grants,
|
|
454
|
+
capabilities: input.capabilities,
|
|
455
|
+
}).force;
|
|
456
|
+
// Headless WebFetch / open publish shell require --force (Cursor CLI 2026.07+).
|
|
457
|
+
if (force)
|
|
506
458
|
args.push("--force");
|
|
507
459
|
if (input.model)
|
|
508
460
|
args.push("--model", input.model);
|
|
@@ -581,7 +533,13 @@ export const cursorDriver = {
|
|
|
581
533
|
// local contract instead of making either version fail every attempt worktree.
|
|
582
534
|
const help = await execute(executable, ["--help"], input.workspace, 15_000, undefined, "local");
|
|
583
535
|
const trustWorkspace = help.code === 0 && cursorSupportsWorkspaceTrust(`${help.stdout}\n${help.stderr}`);
|
|
584
|
-
const
|
|
536
|
+
const executionClass = resolveRunClass(input);
|
|
537
|
+
const projected = projectCursor(executionClass, {
|
|
538
|
+
grants: input.grants,
|
|
539
|
+
verificationCommands: input.verificationCommands,
|
|
540
|
+
capabilities: input.capabilities ?? [],
|
|
541
|
+
});
|
|
542
|
+
const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
|
|
585
543
|
const { code, stdout, stderr } = configured;
|
|
586
544
|
const parsed = parseCursorOutput(stdout);
|
|
587
545
|
if (code !== 0 || parsed.isError) {
|
|
@@ -617,10 +575,17 @@ async function withCursorPermissions(workspace, permissions, run) {
|
|
|
617
575
|
}
|
|
618
576
|
/**
|
|
619
577
|
* OpenCode routes through any OpenAI/Anthropic-compatible endpoint, so it runs
|
|
620
|
-
* on Conduit pump fuel or a local login.
|
|
621
|
-
* agent (no edit/bash tools); write
|
|
578
|
+
* on Conduit pump fuel or a local login. Observe classes use its `plan`
|
|
579
|
+
* agent (no edit/bash tools); write/publish/verify use `build`.
|
|
622
580
|
*/
|
|
623
|
-
|
|
581
|
+
function hasMappedRepoAccess(grants) {
|
|
582
|
+
return ["repo_read", "repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant));
|
|
583
|
+
}
|
|
584
|
+
export function openCodeAgentForGrants(grants, executionClass) {
|
|
585
|
+
if (!hasMappedRepoAccess(grants))
|
|
586
|
+
return null;
|
|
587
|
+
if (executionClass)
|
|
588
|
+
return projectCoarseMode(executionClass) === "plan" ? "plan" : "build";
|
|
624
589
|
if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
|
|
625
590
|
return "build";
|
|
626
591
|
if (grants.includes("repo_read"))
|
|
@@ -729,7 +694,8 @@ export const openCodeDriver = {
|
|
|
729
694
|
if (fuelSource === "local" && !hasOpenCodeLogin()) {
|
|
730
695
|
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)" };
|
|
731
696
|
}
|
|
732
|
-
const
|
|
697
|
+
const executionClass = resolveRunClass(input);
|
|
698
|
+
const agent = openCodeAgentForGrants(input.grants, executionClass);
|
|
733
699
|
if (!agent) {
|
|
734
700
|
return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped OpenCode agent for active grants; refusing to start agent" };
|
|
735
701
|
}
|
|
@@ -753,7 +719,11 @@ export const openCodeDriver = {
|
|
|
753
719
|
* (Antigravity-tier, not Claude-tier). Fine-grained command allowlisting would
|
|
754
720
|
* need a kiro agent config file — deferred until it can be verified with a login.
|
|
755
721
|
*/
|
|
756
|
-
export function kiroTrustedTools(grants) {
|
|
722
|
+
export function kiroTrustedTools(grants, executionClass) {
|
|
723
|
+
if (!hasMappedRepoAccess(grants))
|
|
724
|
+
return [];
|
|
725
|
+
if (executionClass)
|
|
726
|
+
return projectKiroTools(executionClass);
|
|
757
727
|
const tools = [];
|
|
758
728
|
if (grants.includes("repo_read"))
|
|
759
729
|
tools.push("fs_read");
|
|
@@ -780,7 +750,8 @@ export const kiroDriver = {
|
|
|
780
750
|
if (fuelSource === "conduit") {
|
|
781
751
|
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" };
|
|
782
752
|
}
|
|
783
|
-
const
|
|
753
|
+
const executionClass = resolveRunClass(input);
|
|
754
|
+
const trusted = kiroTrustedTools(input.grants, executionClass);
|
|
784
755
|
if (!trusted.length) {
|
|
785
756
|
return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Kiro tools for active grants; refusing to start agent" };
|
|
786
757
|
}
|
|
@@ -809,7 +780,11 @@ export const kiroDriver = {
|
|
|
809
780
|
* enforcement is mode-level, not per-tool — coarser than Claude, the Cursor tier.
|
|
810
781
|
* Write mode adds `--sandbox` for terminal restrictions. Output is plain text.
|
|
811
782
|
*/
|
|
812
|
-
export function antigravityModeForGrants(grants) {
|
|
783
|
+
export function antigravityModeForGrants(grants, executionClass) {
|
|
784
|
+
if (!hasMappedRepoAccess(grants))
|
|
785
|
+
return null;
|
|
786
|
+
if (executionClass)
|
|
787
|
+
return projectCoarseMode(executionClass) === "plan" ? "plan" : "accept-edits";
|
|
813
788
|
if (["repo_write", "test_run", "branch_create", "pr_create"].some((grant) => grants.includes(grant)))
|
|
814
789
|
return "accept-edits";
|
|
815
790
|
if (grants.includes("repo_read"))
|
|
@@ -834,7 +809,8 @@ export const antigravityDriver = {
|
|
|
834
809
|
if (!hasAntigravityLogin()) {
|
|
835
810
|
return { status: "failed", resultText: null, sessionId: null, error: "antigravity has no login (set GEMINI_API_KEY or sign in with `agy`)" };
|
|
836
811
|
}
|
|
837
|
-
const
|
|
812
|
+
const executionClass = resolveRunClass(input);
|
|
813
|
+
const mode = antigravityModeForGrants(input.grants, executionClass);
|
|
838
814
|
if (!mode) {
|
|
839
815
|
return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
|
|
840
816
|
}
|
|
@@ -846,12 +822,67 @@ export const antigravityDriver = {
|
|
|
846
822
|
return { status: "completed", resultText: stdout, sessionId: null };
|
|
847
823
|
},
|
|
848
824
|
};
|
|
825
|
+
/**
|
|
826
|
+
* Pi (`@earendil-works/pi-coding-agent`) — Experimental local-fuel lane.
|
|
827
|
+
* Class tools via `--tools`; observe_network fails closed (no fetch-without-bash).
|
|
828
|
+
*/
|
|
829
|
+
export function piRunArgs(input) {
|
|
830
|
+
const args = ["--mode", "json", "-p", "--no-session", "--tools", input.tools];
|
|
831
|
+
if (input.model)
|
|
832
|
+
args.push("--model", input.model);
|
|
833
|
+
args.push(input.prompt);
|
|
834
|
+
return args;
|
|
835
|
+
}
|
|
836
|
+
export const piDriver = {
|
|
837
|
+
name: "pi",
|
|
838
|
+
async run(input) {
|
|
839
|
+
const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
|
|
840
|
+
if (fuelSource === "conduit") {
|
|
841
|
+
return {
|
|
842
|
+
status: "failed",
|
|
843
|
+
resultText: null,
|
|
844
|
+
sessionId: null,
|
|
845
|
+
error: "pi driver requires local fuel (provider API key or Pi login). Run: npx @miraland-labs/conduit-bridge fuel local",
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
if (!hasMappedRepoAccess(input.grants)) {
|
|
849
|
+
return {
|
|
850
|
+
status: "failed",
|
|
851
|
+
resultText: null,
|
|
852
|
+
sessionId: null,
|
|
853
|
+
error: "No Bridge-mapped Pi access for active grants; refusing to start agent",
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
const executionClass = resolveRunClass(input);
|
|
857
|
+
const projected = projectPi(executionClass);
|
|
858
|
+
if ("error" in projected) {
|
|
859
|
+
return { status: "failed", resultText: null, sessionId: null, error: projected.error };
|
|
860
|
+
}
|
|
861
|
+
const executable = input.executable ?? "pi";
|
|
862
|
+
const version = await execute(executable, ["--version"], input.workspace, 15_000, undefined, "local");
|
|
863
|
+
if (version.code !== 0 || !(version.stdout || version.stderr).trim()) {
|
|
864
|
+
return { status: "failed", resultText: null, sessionId: null, error: "pi preflight could not verify the installed CLI version" };
|
|
865
|
+
}
|
|
866
|
+
const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local");
|
|
867
|
+
const parsed = parsePiJsonl(stdout);
|
|
868
|
+
if (code !== 0) {
|
|
869
|
+
return {
|
|
870
|
+
status: "failed",
|
|
871
|
+
resultText: parsed.resultText,
|
|
872
|
+
sessionId: parsed.sessionId,
|
|
873
|
+
error: (stderr || parsed.resultText || `pi exited with code ${code}`).slice(0, 20_000),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
877
|
+
},
|
|
878
|
+
};
|
|
849
879
|
/** Stable driver ids shown in Connect / CLI. Order is product preference, not exclusivity. */
|
|
850
880
|
export const SUPPORTED_AGENTS = [
|
|
851
881
|
{ id: "claude-code", label: "Claude Code", executableHint: "claude" },
|
|
852
882
|
{ id: "codex", label: "Codex (ChatGPT)", executableHint: "codex" },
|
|
853
883
|
{ id: "cursor", label: "Cursor Agent", executableHint: "agent" },
|
|
854
884
|
{ id: "opencode", label: "OpenCode", executableHint: "opencode" },
|
|
885
|
+
{ id: "pi", label: "Pi", executableHint: "pi" },
|
|
855
886
|
{ id: "kiro", label: "Kiro CLI", executableHint: "kiro-cli" },
|
|
856
887
|
{ id: "antigravity", label: "Antigravity (agy)", executableHint: "agy" },
|
|
857
888
|
];
|
|
@@ -860,6 +891,7 @@ export const DRIVERS = {
|
|
|
860
891
|
codex: codexDriver,
|
|
861
892
|
cursor: cursorDriver,
|
|
862
893
|
opencode: openCodeDriver,
|
|
894
|
+
pi: piDriver,
|
|
863
895
|
kiro: kiroDriver,
|
|
864
896
|
antigravity: antigravityDriver,
|
|
865
897
|
};
|
package/dist/drivers.js
CHANGED
|
@@ -11,10 +11,11 @@ const LABEL_TO_DRIVER = {
|
|
|
11
11
|
OpenCode: "opencode",
|
|
12
12
|
"Cursor Agent": "cursor",
|
|
13
13
|
Cursor: "cursor",
|
|
14
|
+
Pi: "pi",
|
|
14
15
|
"Kiro CLI": "kiro",
|
|
15
16
|
Antigravity: "antigravity",
|
|
16
17
|
};
|
|
17
|
-
const LOCAL_FUEL_ONLY_DRIVERS = new Set(["cursor", "kiro", "antigravity"]);
|
|
18
|
+
const LOCAL_FUEL_ONLY_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity"]);
|
|
18
19
|
export function isSupportedDriverId(id) {
|
|
19
20
|
return Object.prototype.hasOwnProperty.call(DRIVERS, id);
|
|
20
21
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutionClass — one permission policy compiled from the assignment contract,
|
|
3
|
+
* then projected into each driver. Ends per-command allow-list republish churn.
|
|
4
|
+
*/
|
|
5
|
+
/** Bounded verification commands a `test_run` grant may execute. */
|
|
6
|
+
export function isBoundedVerificationCommand(command) {
|
|
7
|
+
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);
|
|
8
|
+
}
|
|
9
|
+
/** Shell commands each grant authorizes — mapped per driver so they cannot drift. */
|
|
10
|
+
export const branchCreateCommands = [
|
|
11
|
+
"git status", "git diff", "git add", "git commit", "git branch", "git switch", "git rev-parse", "git log",
|
|
12
|
+
];
|
|
13
|
+
export const prCreateCommands = ["git push", "gh pr create"];
|
|
14
|
+
/** Commands no driver may ever run, regardless of grants. */
|
|
15
|
+
export const deniedCommands = [
|
|
16
|
+
"git merge", "git rebase", "git reset --hard", "git push --force",
|
|
17
|
+
"npm run deploy", "pnpm deploy", "yarn deploy", "wrangler deploy",
|
|
18
|
+
"kubectl", "terraform apply",
|
|
19
|
+
];
|
|
20
|
+
/** Land commands denied for publish_artifact / observe* (delivery gate still enforces). */
|
|
21
|
+
export const landCommands = ["git commit", "git push", "gh pr create"];
|
|
22
|
+
export const EXECUTION_CLASSES = [
|
|
23
|
+
"observe",
|
|
24
|
+
"observe_network",
|
|
25
|
+
"publish_artifact",
|
|
26
|
+
"verify",
|
|
27
|
+
"mutate_repo",
|
|
28
|
+
];
|
|
29
|
+
export function resolveExecutionClass(input) {
|
|
30
|
+
const grants = input.grants;
|
|
31
|
+
const capabilities = input.capabilities ?? [];
|
|
32
|
+
const deliverable = input.deliverable ?? "repository";
|
|
33
|
+
const workRole = input.workRole ?? null;
|
|
34
|
+
const hasWrite = grants.includes("repo_write");
|
|
35
|
+
const hasLand = grants.includes("branch_create") || grants.includes("pr_create");
|
|
36
|
+
const hasTest = grants.includes("test_run");
|
|
37
|
+
const hasRead = grants.includes("repo_read");
|
|
38
|
+
const external = capabilities.includes("external_network");
|
|
39
|
+
if (deliverable === "artifact" && workRole !== "research" && hasWrite)
|
|
40
|
+
return "publish_artifact";
|
|
41
|
+
if (external && !hasWrite && !hasLand && !hasTest)
|
|
42
|
+
return "observe_network";
|
|
43
|
+
// verify before observe: repo_read+test_run is the usual verify shape and must keep shell.
|
|
44
|
+
if (hasTest && !hasWrite && !hasLand)
|
|
45
|
+
return "verify";
|
|
46
|
+
if (!hasWrite && !hasLand && !hasTest && (hasRead || workRole === "research"))
|
|
47
|
+
return "observe";
|
|
48
|
+
return "mutate_repo";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Live HTTP is a property of the assignment's capabilities, not of its class.
|
|
52
|
+
*
|
|
53
|
+
* Every surface used to enumerate classes here, and the lists disagreed: the prompt promised WebFetch
|
|
54
|
+
* whenever the capability was present, Cursor granted it only for `observe_network`/`publish_artifact`/
|
|
55
|
+
* `mutate_repo`, Claude never granted it, and Codex granted it by capability. A `verify` assignment
|
|
56
|
+
* with `external_network` was therefore told to fetch by a prompt no driver could honour. One
|
|
57
|
+
* predicate, read by all of them, is what keeps that from recurring for the next class added.
|
|
58
|
+
*
|
|
59
|
+
* `observe_network` implies the capability by construction (`resolveExecutionClass` only returns it
|
|
60
|
+
* when `external_network` is present); it stays in the test so a spec that loses its capability list
|
|
61
|
+
* in transit still fails toward the class it was stamped with.
|
|
62
|
+
*/
|
|
63
|
+
export function allowsExternalFetch(executionClass, capabilities = []) {
|
|
64
|
+
return executionClass === "observe_network" || capabilities.includes("external_network");
|
|
65
|
+
}
|
|
66
|
+
export function executionClassPromptRules(input) {
|
|
67
|
+
const { executionClass, grants, verificationCommands = [], capabilities = [], mustCommit, mustOpenPr, } = input;
|
|
68
|
+
// Derived, never passed in: a caller-supplied `artifact` flag let the prompt order a publish that
|
|
69
|
+
// the class had already denied. The class is the single source of truth for both halves.
|
|
70
|
+
const artifact = executionClass === "publish_artifact";
|
|
71
|
+
const rules = [];
|
|
72
|
+
const grantShell = [
|
|
73
|
+
...(grants.includes("test_run") ? verificationCommands.filter(isBoundedVerificationCommand) : []),
|
|
74
|
+
...(grants.includes("branch_create") ? branchCreateCommands : []),
|
|
75
|
+
...(grants.includes("pr_create") ? prCreateCommands : []),
|
|
76
|
+
];
|
|
77
|
+
if (executionClass === "publish_artifact") {
|
|
78
|
+
rules.push("- You have shell authority to write under `.conduit/artifacts/` and publish the artifact (HTTPS URL + sha256 digest). Do not invent command wrappers — publish with ordinary tools (for example gh gist, curl, shasum). Do not commit or open a pull request.");
|
|
79
|
+
}
|
|
80
|
+
else if (executionClass === "observe" || executionClass === "observe_network") {
|
|
81
|
+
rules.push("- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown.");
|
|
82
|
+
}
|
|
83
|
+
else if (grantShell.length) {
|
|
84
|
+
rules.push(`- These are the ONLY shell commands you may run: ${grantShell.join("; ")}. Anything else is rejected — do not try variations, wrappers, or \`echo\`. Run the relevant ones and report their real output.`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
rules.push("- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown.");
|
|
88
|
+
}
|
|
89
|
+
if (allowsExternalFetch(executionClass, capabilities)) {
|
|
90
|
+
rules.push("- This assignment includes external_network: WebFetch is allow-listed. Use WebFetch (or the driver's live-fetch tool) for live HTTP status and page content; do not invent origin responses. Prefer WebFetch over WebSearch.");
|
|
91
|
+
}
|
|
92
|
+
if (mustCommit) {
|
|
93
|
+
rules.push("- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) — do not create a new branch — and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected.");
|
|
94
|
+
if (mustOpenPr) {
|
|
95
|
+
rules.push("- This assignment includes pr_create: after committing, `git push -u origin HEAD` and `gh pr create` against the repository default branch, then set pull_request_url to that PR URL. Conduit merge-on-accept lands the PR when the owner Accepts — a head_commit without a PR leaves main unchanged and breaks later packages.");
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
rules.push("- You may commit locally; opening a pull request is outside this assignment's grants.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (artifact) {
|
|
102
|
+
rules.push("- This assignment delivers an artifact, not repository content. Write your output under `.conduit/artifacts/` — that path is excluded from git, so do not commit anything and do not report a head_commit.", "- Publish the artifact to its destination and report each output in your evidence with the published HTTPS URL and digest exactly as `sha256:` plus the 64-character hex hash (bare hex from shasum is normalized, but prefer the prefixed form). An artifact nobody can fetch is not a delivery.");
|
|
103
|
+
}
|
|
104
|
+
else if (executionClass === "mutate_repo" && grants.includes("repo_write")) {
|
|
105
|
+
// Write without land grants: tools may edit files, but commit/PR stay outside the contract.
|
|
106
|
+
rules.push("- You may create and modify files in the workspace. Do not commit or open a pull request — this assignment has no branch_create/pr_create authority. A delivery reporting head_commit or pull_request_url will be rejected.");
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
rules.push("- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead.");
|
|
110
|
+
}
|
|
111
|
+
return rules;
|
|
112
|
+
}
|
|
113
|
+
export function projectCursor(executionClass, input) {
|
|
114
|
+
const grants = input.grants;
|
|
115
|
+
const verificationCommands = input.verificationCommands ?? [];
|
|
116
|
+
const capabilities = input.capabilities ?? [];
|
|
117
|
+
const deny = [
|
|
118
|
+
...deniedCommands.map((command) => `Shell(${command})`),
|
|
119
|
+
...((executionClass === "publish_artifact" || executionClass === "observe" || executionClass === "observe_network")
|
|
120
|
+
? landCommands.map((command) => `Shell(${command})`)
|
|
121
|
+
: []),
|
|
122
|
+
];
|
|
123
|
+
if (executionClass === "observe" || executionClass === "observe_network") {
|
|
124
|
+
deny.push("Shell(*)", "Write(**)");
|
|
125
|
+
}
|
|
126
|
+
const allow = [];
|
|
127
|
+
if (allowsExternalFetch(executionClass, capabilities))
|
|
128
|
+
allow.push("WebFetch(*)");
|
|
129
|
+
if (executionClass === "mutate_repo" || executionClass === "verify") {
|
|
130
|
+
if (grants.includes("test_run")) {
|
|
131
|
+
allow.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
|
|
132
|
+
}
|
|
133
|
+
if (executionClass === "mutate_repo") {
|
|
134
|
+
if (grants.includes("branch_create"))
|
|
135
|
+
allow.push(...branchCreateCommands.map((command) => `Shell(${command})`));
|
|
136
|
+
if (grants.includes("pr_create"))
|
|
137
|
+
allow.push(...prCreateCommands.map((command) => `Shell(${command})`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Headless WebFetch and the open publish shell are the two things Cursor will not do unprompted.
|
|
141
|
+
const force = allowsExternalFetch(executionClass, capabilities) || executionClass === "publish_artifact";
|
|
142
|
+
return { allow, deny, force };
|
|
143
|
+
}
|
|
144
|
+
export function projectClaude(executionClass, input) {
|
|
145
|
+
const grants = input.grants;
|
|
146
|
+
const verificationCommands = input.verificationCommands ?? [];
|
|
147
|
+
// allowedTools is a whitelist, so an unnamed tool is a denied tool: Claude honoured no
|
|
148
|
+
// external_network assignment at all until this was added, whatever the prompt said.
|
|
149
|
+
const fetchTools = allowsExternalFetch(executionClass, input.capabilities) ? ["WebFetch"] : [];
|
|
150
|
+
const landDeny = landCommands.map((command) => `Bash(${command}:*)`);
|
|
151
|
+
const nucleusDeny = deniedCommands.map((command) => `Bash(${command}:*)`);
|
|
152
|
+
const disallowedTools = [
|
|
153
|
+
...nucleusDeny,
|
|
154
|
+
...((executionClass === "publish_artifact" || executionClass === "observe" || executionClass === "observe_network")
|
|
155
|
+
? landDeny
|
|
156
|
+
: []),
|
|
157
|
+
];
|
|
158
|
+
if (executionClass === "observe" || executionClass === "observe_network") {
|
|
159
|
+
return { allowedTools: ["Read", "Glob", "Grep", ...fetchTools], disallowedTools, acceptEdits: false };
|
|
160
|
+
}
|
|
161
|
+
if (executionClass === "publish_artifact") {
|
|
162
|
+
return {
|
|
163
|
+
allowedTools: ["Read", "Glob", "Grep", "Edit", "Write", "Bash", ...fetchTools],
|
|
164
|
+
disallowedTools,
|
|
165
|
+
acceptEdits: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (executionClass === "verify") {
|
|
169
|
+
return {
|
|
170
|
+
allowedTools: [
|
|
171
|
+
"Read",
|
|
172
|
+
...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`),
|
|
173
|
+
...fetchTools,
|
|
174
|
+
],
|
|
175
|
+
disallowedTools,
|
|
176
|
+
acceptEdits: false,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// mutate_repo — current grant mapping
|
|
180
|
+
const tools = [];
|
|
181
|
+
if (grants.includes("repo_read"))
|
|
182
|
+
tools.push("Read", "Glob", "Grep");
|
|
183
|
+
if (grants.includes("repo_write"))
|
|
184
|
+
tools.push("Edit", "Write");
|
|
185
|
+
if (grants.includes("test_run")) {
|
|
186
|
+
tools.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`));
|
|
187
|
+
}
|
|
188
|
+
if (grants.includes("branch_create"))
|
|
189
|
+
tools.push(...branchCreateCommands.map((command) => `Bash(${command}:*)`));
|
|
190
|
+
if (grants.includes("pr_create"))
|
|
191
|
+
tools.push(...prCreateCommands.map((command) => `Bash(${command}:*)`));
|
|
192
|
+
tools.push(...fetchTools);
|
|
193
|
+
return {
|
|
194
|
+
allowedTools: tools,
|
|
195
|
+
disallowedTools,
|
|
196
|
+
acceptEdits: grants.includes("repo_write"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export function projectCodex(executionClass, input) {
|
|
200
|
+
const capabilities = input.capabilities ?? [];
|
|
201
|
+
const networkAccess = allowsExternalFetch(executionClass, capabilities);
|
|
202
|
+
// Read-only is the sandbox for the one class that may not write; everything else needs the
|
|
203
|
+
// workspace. Network is the shared predicate, so Codex cannot drift from Cursor and Claude again.
|
|
204
|
+
if (executionClass === "observe")
|
|
205
|
+
return { sandbox: "read-only", networkAccess };
|
|
206
|
+
return { sandbox: "workspace-write", networkAccess };
|
|
207
|
+
}
|
|
208
|
+
/** Coarse mode for OpenCode / Antigravity. */
|
|
209
|
+
export function projectCoarseMode(executionClass) {
|
|
210
|
+
if (executionClass === "observe" || executionClass === "observe_network")
|
|
211
|
+
return "plan";
|
|
212
|
+
return "build";
|
|
213
|
+
}
|
|
214
|
+
export function projectKiroTools(executionClass) {
|
|
215
|
+
if (executionClass === "observe" || executionClass === "observe_network")
|
|
216
|
+
return ["fs_read"];
|
|
217
|
+
if (executionClass === "verify")
|
|
218
|
+
return ["fs_read", "execute_bash"];
|
|
219
|
+
return ["fs_read", "fs_write", "execute_bash"];
|
|
220
|
+
}
|
|
221
|
+
export function projectPi(executionClass) {
|
|
222
|
+
switch (executionClass) {
|
|
223
|
+
case "observe":
|
|
224
|
+
return { tools: "read,grep,find,ls" };
|
|
225
|
+
case "observe_network":
|
|
226
|
+
return {
|
|
227
|
+
error: "Pi cannot enforce observe_network; use Cursor/Codex or add OS sandbox later",
|
|
228
|
+
};
|
|
229
|
+
case "publish_artifact":
|
|
230
|
+
case "mutate_repo":
|
|
231
|
+
return { tools: "read,write,edit,bash,grep,find,ls" };
|
|
232
|
+
case "verify":
|
|
233
|
+
return { tools: "read,bash,grep,find,ls" };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Extract final assistant text from `pi --mode json` JSONL. */
|
|
237
|
+
export function parsePiJsonl(stdout) {
|
|
238
|
+
let resultText = null;
|
|
239
|
+
let sessionId = null;
|
|
240
|
+
const texts = [];
|
|
241
|
+
for (const line of stdout.split("\n")) {
|
|
242
|
+
const trimmed = line.trim();
|
|
243
|
+
if (!trimmed.startsWith("{"))
|
|
244
|
+
continue;
|
|
245
|
+
let event;
|
|
246
|
+
try {
|
|
247
|
+
event = JSON.parse(trimmed);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const session = [event.sessionId, event.session_id].find((value) => typeof value === "string");
|
|
253
|
+
if (typeof session === "string")
|
|
254
|
+
sessionId = session;
|
|
255
|
+
if (event.type === "message_end" || event.type === "agent_end" || event.type === "assistant") {
|
|
256
|
+
const content = event.message?.content ?? event.message?.text ?? event.text ?? event.result;
|
|
257
|
+
if (typeof content === "string" && content.trim())
|
|
258
|
+
resultText = content;
|
|
259
|
+
else if (Array.isArray(content)) {
|
|
260
|
+
const joined = content
|
|
261
|
+
.map((part) => (typeof part === "string" ? part : (part && typeof part === "object" && "text" in part ? String(part.text) : "")))
|
|
262
|
+
.filter(Boolean)
|
|
263
|
+
.join("\n");
|
|
264
|
+
if (joined)
|
|
265
|
+
resultText = joined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (event.type === "text" && typeof event.text === "string")
|
|
269
|
+
texts.push(event.text);
|
|
270
|
+
}
|
|
271
|
+
if (!resultText && texts.length)
|
|
272
|
+
resultText = texts.join("\n");
|
|
273
|
+
if (!resultText && stdout.trim()) {
|
|
274
|
+
// Fall back to whole stdout so parseAgentReport can still find a fenced block.
|
|
275
|
+
resultText = stdout;
|
|
276
|
+
}
|
|
277
|
+
return { resultText, sessionId };
|
|
278
|
+
}
|
package/dist/execution.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ConduitRequestError } from "./client.js";
|
|
4
4
|
import { redactSecrets } from "./config.js";
|
|
5
|
-
import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
|
|
5
|
+
import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, pickModelCandidate, resolveExecutionClass, tierForRisk } from "./driver.js";
|
|
6
6
|
import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
|
|
7
7
|
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
|
|
8
8
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
@@ -326,7 +326,32 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
326
326
|
}
|
|
327
327
|
// Same list the driver hands to the permission contract, so the prompt states exactly what is
|
|
328
328
|
// executable rather than leaving the agent to guess and hit rejections.
|
|
329
|
-
const
|
|
329
|
+
const executionClass = resolveExecutionClass({
|
|
330
|
+
grants,
|
|
331
|
+
capabilities: spec.required_capabilities,
|
|
332
|
+
deliverable,
|
|
333
|
+
workRole: workPackage?.work_role ?? spec.work_role,
|
|
334
|
+
});
|
|
335
|
+
if (typeof spec.execution_class === "string" && spec.execution_class !== executionClass) {
|
|
336
|
+
console.error(`Assignment ${taskId}: stamped execution_class=${spec.execution_class} differs from Bridge-derived ${executionClass}; using Bridge-derived class for launch`);
|
|
337
|
+
}
|
|
338
|
+
// An artifact deliverable that does not classify as publish_artifact cannot write the file it is
|
|
339
|
+
// being asked to publish, so the run is doomed before it starts. Refuse here rather than spend an
|
|
340
|
+
// agent run and fail at the delivery gate with a missing artifact and no stated cause. Reachable
|
|
341
|
+
// when a control plane older than the artifact-grant normalization dispatches such a package —
|
|
342
|
+
// exactly the window between publishing Bridge and deploying the control plane.
|
|
343
|
+
if (artifactDelivery && executionClass !== "publish_artifact") {
|
|
344
|
+
const detail = `Artifact deliverable resolved to execution class ${executionClass}, which cannot write or publish it. The work package needs the repo_write grant.`;
|
|
345
|
+
console.error(`Assignment ${taskId}: ${detail}`);
|
|
346
|
+
// Not retryable: retrying cannot add a grant, so this must reach the owner as a plan correction
|
|
347
|
+
// rather than burn attempts. Same terminal path every other launch failure uses.
|
|
348
|
+
await queueTerminal(client, taskId, {
|
|
349
|
+
action: "fail",
|
|
350
|
+
body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:artifact-class` },
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [], executionClass });
|
|
330
355
|
const resuming = Boolean(options.forceResumeSessionId);
|
|
331
356
|
await client.attemptRequest(taskId, "progress", {
|
|
332
357
|
phase: "changing",
|
|
@@ -383,6 +408,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
383
408
|
grants,
|
|
384
409
|
capabilities: spec.required_capabilities ?? [],
|
|
385
410
|
verificationCommands: liveBrief?.verification ?? [],
|
|
411
|
+
deliverable,
|
|
412
|
+
workRole: workPackage?.work_role ?? spec.work_role,
|
|
413
|
+
executionClass,
|
|
386
414
|
resumeSessionId,
|
|
387
415
|
timeoutMs,
|
|
388
416
|
model: selection.model,
|
package/dist/ops.js
CHANGED
|
@@ -11,7 +11,7 @@ import { describePreflightIssue, runBridgePreflight } from "./preflight.js";
|
|
|
11
11
|
export const OPS_VERBS = [
|
|
12
12
|
"connect", "install", "online", "offline", "status", "doctor", "disconnect", "uninstall",
|
|
13
13
|
];
|
|
14
|
-
const LOCAL_FUEL_DRIVERS = new Set(["cursor", "kiro", "antigravity"]);
|
|
14
|
+
const LOCAL_FUEL_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity"]);
|
|
15
15
|
export function defaultOpsEnvPath(home = homedir()) {
|
|
16
16
|
return join(home, ".config", "conduit", "ops.env");
|
|
17
17
|
}
|
package/dist/preflight.js
CHANGED
|
@@ -21,6 +21,7 @@ const DRIVER_COMMAND = {
|
|
|
21
21
|
codex: "codex",
|
|
22
22
|
cursor: "agent",
|
|
23
23
|
opencode: "opencode",
|
|
24
|
+
pi: "pi",
|
|
24
25
|
kiro: "kiro-cli",
|
|
25
26
|
antigravity: "agy",
|
|
26
27
|
};
|
|
@@ -48,6 +49,9 @@ async function localAuthenticationReady(driver, workspace, run) {
|
|
|
48
49
|
const status = await run(executableFor(driver), ["whoami"], workspace);
|
|
49
50
|
return status.code === 0 && !/not logged in|not authenticated|login required/i.test(`${status.stdout}\n${status.stderr}`);
|
|
50
51
|
}
|
|
52
|
+
// Pi stores provider auth in its own config; CLI has no stable whoami — version probe is enough.
|
|
53
|
+
if (driver === "pi")
|
|
54
|
+
return true;
|
|
51
55
|
return false;
|
|
52
56
|
}
|
|
53
57
|
export async function runBridgePreflight(input, deps = {}) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"conduit": "dist/cli.js"
|