@miraland-labs/conduit-bridge 0.9.8 → 0.9.10

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
@@ -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.9.8` — heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. It 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.
5
+ **Package version:** `0.9.10` — heartbeat protocol 2 requires a clean workspace plus a ready, versioned driver lane before dispatch. Cursor assignments with `external_network` allow-list `WebFetch(*)` so headless fetches are not auto-rejected. 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
 
package/dist/brief.js CHANGED
@@ -43,6 +43,26 @@ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
43
43
  throw error;
44
44
  }
45
45
  }
46
+ async function fetchOrigin(workspace) {
47
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
48
+ timeout: 120_000,
49
+ windowsHide: true,
50
+ });
51
+ }
52
+ /** Null when commits do not share history (e.g. orphan / force-pushed replacement tip). */
53
+ async function gitMergeBase(workspace, left, right) {
54
+ try {
55
+ const { stdout } = await execFileAsync("git", ["-C", workspace, "merge-base", left, right], {
56
+ timeout: 10_000,
57
+ windowsHide: true,
58
+ });
59
+ const sha = stdout.trim().toLowerCase();
60
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
46
66
  /** True when `sha` is a local commit object, optionally after fetching it from origin. */
47
67
  export async function ensureCommitAvailable(workspace, sha) {
48
68
  const present = async () => {
@@ -67,10 +87,7 @@ export async function ensureCommitAvailable(workspace, sha) {
67
87
  }
68
88
  catch {
69
89
  try {
70
- await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
71
- timeout: 120_000,
72
- windowsHide: true,
73
- });
90
+ await fetchOrigin(workspace);
74
91
  }
75
92
  catch {
76
93
  return false;
@@ -78,6 +95,47 @@ export async function ensureCommitAvailable(workspace, sha) {
78
95
  }
79
96
  return present();
80
97
  }
98
+ /**
99
+ * Choose the commit an attempt worktree starts from for a required initiative base.
100
+ *
101
+ * Never rewrites the source checkout HEAD. Fetches missing objects when needed, prefers the claimed
102
+ * head when it already contains the base (rework), advances to the base when the workspace is merely
103
+ * behind, and falls back to the contract base when history was rewritten (force-push) so the owner
104
+ * does not have to manually reset before Recheck.
105
+ */
106
+ export async function resolveAttemptStartCommit(workspace, requestedBase, claimedHead) {
107
+ if (!requestedBase)
108
+ return claimedHead;
109
+ const base = requestedBase.toLowerCase();
110
+ const claimed = claimedHead.toLowerCase();
111
+ if (await isBaseCommitAncestor(workspace, base, claimed).catch(() => false))
112
+ return claimed;
113
+ if (!(await ensureCommitAvailable(workspace, base).catch(() => false)))
114
+ return null;
115
+ await ensureCommitAvailable(workspace, claimed).catch(() => false);
116
+ if (await isBaseCommitAncestor(workspace, base, claimed).catch(() => false))
117
+ return claimed;
118
+ if (await isBaseCommitAncestor(workspace, claimed, base).catch(() => false))
119
+ return base;
120
+ // Shallow tip fetches can hide ancestry; deepen once before treating history as rewritten.
121
+ try {
122
+ await fetchOrigin(workspace);
123
+ }
124
+ catch {
125
+ /* offline / no origin — continue with objects already present */
126
+ }
127
+ if (await isBaseCommitAncestor(workspace, base, claimed).catch(() => false))
128
+ return claimed;
129
+ if (await isBaseCommitAncestor(workspace, claimed, base).catch(() => false))
130
+ return base;
131
+ // Unrelated histories (orphan / force-pushed default branch): honor the contract base for a fresh
132
+ // attempt worktree. Related-but-diverged tips can carry rework commits — refuse rather than drop them.
133
+ if (!(await ensureCommitAvailable(workspace, base).catch(() => false)))
134
+ return null;
135
+ if (await gitMergeBase(workspace, base, claimed) === null)
136
+ return base;
137
+ return null;
138
+ }
81
139
  async function gitRemoteUrl(workspace) {
82
140
  try {
83
141
  const { common } = await gitDirectories(workspace);
package/dist/driver.js CHANGED
@@ -142,6 +142,7 @@ export function buildAssignmentPrompt(context) {
142
142
  ...(context.grants.includes("branch_create") ? branchCreateCommands : []),
143
143
  ...(context.grants.includes("pr_create") ? prCreateCommands : []),
144
144
  ];
145
+ const hasExternalNetwork = (spec.required_capabilities ?? []).includes("external_network");
145
146
  const languageRule = deliveryLanguageRule(packageContext?.working_language ?? spec.working_language);
146
147
  lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(languageRule ? [languageRule] : []),
147
148
  // Name the executable commands. The shell allow-list is exact, so an agent that guesses at
@@ -152,7 +153,12 @@ export function buildAssignmentPrompt(context) {
152
153
  ? [
153
154
  `- These are the ONLY shell commands you may run: ${runnableCommands.join("; ")}. Anything else is rejected — do not try variations, wrappers, or \`echo\`. Run the relevant ones and report their real output.`,
154
155
  ]
155
- : ["- 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."]), ...(mustCommit
156
+ : ["- 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."]),
157
+ // Mirror .cursor/cli.json WebFetch(*) — without naming it, agents fall back to WebSearch (not
158
+ // allow-listable) or invent status/title after "User Rejected" on unlisted fetches.
159
+ ...(hasExternalNetwork
160
+ ? ["- This assignment includes external_network: WebFetch is allow-listed. Use WebFetch for live HTTP status and page content; do not invent origin responses. Prefer WebFetch over WebSearch."]
161
+ : []), ...(mustCommit
156
162
  ? [
157
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.",
158
164
  ...(mustOpenPr
@@ -430,11 +436,16 @@ export const codexDriver = {
430
436
  },
431
437
  };
432
438
  /**
433
- * Grants → Cursor CLI permissions (project .cursor/cli.json).
439
+ * Grants + capabilities → Cursor CLI permissions (project .cursor/cli.json).
434
440
  * Current Cursor Agent schema accepts only `permissions.{allow,deny}` (no
435
441
  * `version` / `approvalMode`). Outside the allow list is denied without --force.
442
+ *
443
+ * `external_network` maps to WebFetch(*) — without it, headless `-p` runs get
444
+ * "User Rejected" on every fetch (no interactive approval). Deliberately not
445
+ * `--force`/`--yolo`: that would also auto-allow unlisted Shell commands.
446
+ * Codex's parallel is sandbox_workspace_write.network_access=true.
436
447
  */
437
- export function cursorPermissionsForGrants(grants, verificationCommands = []) {
448
+ export function cursorPermissionsForGrants(grants, verificationCommands = [], capabilities = []) {
438
449
  const allow = [];
439
450
  if (grants.includes("test_run")) {
440
451
  allow.push(...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
@@ -443,6 +454,9 @@ export function cursorPermissionsForGrants(grants, verificationCommands = []) {
443
454
  allow.push(...branchCreateCommands.map((command) => `Shell(${command})`));
444
455
  if (grants.includes("pr_create"))
445
456
  allow.push(...prCreateCommands.map((command) => `Shell(${command})`));
457
+ // Cursor docs: WebFetch(domainOrPattern); WebFetch(*) auto-approves any domain.
458
+ if (capabilities.includes("external_network"))
459
+ allow.push("WebFetch(*)");
446
460
  return { allow, deny: deniedCommands.map((command) => `Shell(${command})`) };
447
461
  }
448
462
  /**
@@ -538,7 +552,7 @@ export const cursorDriver = {
538
552
  // local contract instead of making either version fail every attempt worktree.
539
553
  const help = await execute(executable, ["--help"], input.workspace, 15_000, undefined, "local");
540
554
  const trustWorkspace = help.code === 0 && cursorSupportsWorkspaceTrust(`${help.stdout}\n${help.stderr}`);
541
- const configured = await withCursorPermissions(input.workspace, cursorPermissionsForGrants(input.grants, input.verificationCommands), () => execute(executable, cursorRunArgs({ ...input, trustWorkspace }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
555
+ const configured = await withCursorPermissions(input.workspace, cursorPermissionsForGrants(input.grants, input.verificationCommands, input.capabilities ?? []), () => execute(executable, cursorRunArgs({ ...input, trustWorkspace }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
542
556
  const { code, stdout, stderr } = configured;
543
557
  const parsed = parseCursorOutput(stdout);
544
558
  if (code !== 0 || parsed.isError) {
package/dist/execution.js CHANGED
@@ -5,7 +5,7 @@ import { redactSecrets } from "./config.js";
5
5
  import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, parseAgentReport, pickModelCandidate, 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
- import { buildWorkspaceBrief, ensureCommitAvailable, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
8
+ import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
9
9
  import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
10
10
  /** Feedback text for changes_requested summaries (plain string or `{ feedback }`). */
11
11
  function changesRequestedFeedback(summary) {
@@ -230,7 +230,7 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
230
230
  rejection = "workspace_head_changed";
231
231
  }
232
232
  if (!rejection && assignment.requested_base_commit && assignment.claimed_head) {
233
- const startHead = await resolveStartHead(workspace, assignment.requested_base_commit, assignment.claimed_head);
233
+ const startHead = await resolveAttemptStartCommit(workspace, assignment.requested_base_commit, assignment.claimed_head);
234
234
  if (!startHead)
235
235
  rejection = "base_not_ancestor";
236
236
  }
@@ -246,25 +246,6 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
246
246
  await client.claim(assignment.id, assignment.attempt_id, driverId ? { driverId } : undefined);
247
247
  return assignment.id;
248
248
  }
249
- /**
250
- * When a sibling merge advances task.base_commit past the machine checkout, the required base is a
251
- * descendant of claimed_head — not an ancestor. Fetch it and start from the advanced base instead of looping.
252
- */
253
- async function resolveStartHead(workspace, requestedBase, claimedHead) {
254
- if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
255
- return claimedHead;
256
- await ensureCommitAvailable(workspace, requestedBase).catch(() => false);
257
- if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
258
- return claimedHead;
259
- // Workspace is behind the required base (dependent package after merge).
260
- if (await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false))
261
- return requestedBase;
262
- if (await ensureCommitAvailable(workspace, requestedBase).catch(() => false)
263
- && await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false)) {
264
- return requestedBase;
265
- }
266
- return null;
267
- }
268
249
  export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
269
250
  if (Object.keys(config.activeAttempts).length)
270
251
  return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
@@ -304,7 +285,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
304
285
  }
305
286
  let worktreeStart = startCommit;
306
287
  if (executionContract.requested_base_commit) {
307
- const resolved = await resolveStartHead(workspace, executionContract.requested_base_commit, startCommit);
288
+ const resolved = await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, startCommit);
308
289
  if (!resolved) {
309
290
  await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
310
291
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
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",
5
5
  "type": "module",
6
6
  "bin": {