@miraland-labs/conduit-bridge 0.16.18 → 0.16.19

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.12.3` — `ops connect` asks which org unless `--organization` is passed (does **not** silently reuse a leftover `CONDUIT_ORG` from `ops.env`); `ops install --workspace/--repo` persists declared intent without hand-editing the file; stamped **ExecutionClass** required; heartbeat **protocol 2 + preflight**. Prefer the Connect UI **join** command when switching organizations.
5
+ **Package version:** `0.16.19` — `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
6
6
 
7
7
  ## Prerequisites
8
8
 
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { DRIVERS } from "./driver.js";
15
15
  import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, } from "./drivers.js";
16
16
  import { buildWorkspaceBrief } from "./brief.js";
17
17
  import { ensureCheckout } from "./checkout.js";
18
- import { maybeApplyOnShiftIntent } from "./on-shift-apply.js";
18
+ import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-apply.js";
19
19
  import { loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
20
20
  import { pumpExecutionSlots, renewLeases } from "./execution.js";
21
21
  import { applyRunnerToolPath, installRunnerService, uninstallRunnerService } from "./service.js";
@@ -123,7 +123,7 @@ async function join() {
123
123
  console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
124
124
  console.log(`Proposed roles: ${capabilities.join(", ")}`);
125
125
  console.log(`Assignments at once: ${leaseCapacity}`);
126
- console.log(`Fuel source: ${plannedFuel === "local" ? "local subscription" : "Conduit pump"}`
126
+ console.log(`Fuel source: ${plannedFuel === "local" ? "local agent login or environment API key" : "Conduit pump"}`
127
127
  + (!fuelSource && plannedFuel === "local" ? " (auto: only local-fuel agents detected)" : ""));
128
128
  console.log(`Execution drivers: ${Object.keys(DRIVERS).join(", ")} (cursor/pi/kiro/antigravity/grok require local fuel).`);
129
129
  console.log("Use --machine <name> to override the computer label.\n");
@@ -269,7 +269,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
269
269
  const client = new ConduitClient(config);
270
270
  let heartbeatOk = true;
271
271
  try {
272
- await heartbeat(client, config, null, unavailableWorkspacePreflight({ config }));
272
+ await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config }));
273
273
  }
274
274
  catch {
275
275
  heartbeatOk = false;
@@ -283,7 +283,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
283
283
  }
284
284
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
285
285
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
286
- console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
286
+ console.log(`Fuel source: ${config.fuelSource === "local" ? "local agent login or environment API key" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
287
287
  + (fuelAutoLocal ? " (auto: only local-fuel agents detected)" : ""));
288
288
  const localOnly = localFuelOnlyClients(detected);
289
289
  if (config.fuelSource === "conduit" && localOnly.length) {
@@ -373,7 +373,7 @@ async function initOps() {
373
373
  console.log("Works the same on macOS, Linux, and Windows via:");
374
374
  console.log(` ${bridgeUsage("ops", "<connect|install|switch|online|offline|status|disconnect|uninstall>")}`);
375
375
  console.log("");
376
- console.log("1. Edit env once:");
376
+ console.log("1. Set enrollment/bootstrap defaults once:");
377
377
  console.log(` Create folder: ${configDir}`);
378
378
  console.log(` Copy ${pathJoin(target, "env.example")} → ${envPath}`);
379
379
  console.log(` # edit ${envPath}`);
@@ -408,7 +408,7 @@ async function fuelCommand() {
408
408
  config.fuelSource = mode;
409
409
  await saveConfigPrefs(config);
410
410
  // Do not heartbeat here: a workspace-less preflight would overwrite a live runner's ready report.
411
- console.log(`Machine fuel source set to ${mode === "local" ? "local subscription" : "Conduit pump"}. The runner reports it on the next heartbeat.`);
411
+ console.log(`Machine fuel source set to ${mode === "local" ? "local agent login or environment API key" : "Conduit pump"}. The runner reports exact lane provenance on the next heartbeat.`);
412
412
  }
413
413
  async function driversCommand() {
414
414
  const sub = process.argv[3];
@@ -511,14 +511,13 @@ async function runner() {
511
511
  const preflight = workspace
512
512
  ? await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined })
513
513
  : unavailableWorkspacePreflight({ config });
514
- const hb = await heartbeat(client, config, currentBrief, preflight);
514
+ const hb = await heartbeat(client, config, workspace, currentBrief, preflight);
515
515
  if (hb.staleBridge) {
516
516
  console.error(`Bridge update required: running protocol ${BRIDGE_PROTOCOL_VERSION}; control plane requires protocol ${hb.requiredProtocol ?? "unknown"} (minimum Bridge ${hb.minimumVersion ?? "unknown"}). Reinstall the latest Bridge, then restart this runner.`);
517
517
  await new Promise((resolveSleep) => setTimeout(resolveSleep, Math.max(intervalMs, 60_000)));
518
518
  continue;
519
519
  }
520
520
  const applied = await maybeApplyOnShiftIntent({
521
- client,
522
521
  currentWorkspace: workspace,
523
522
  onShift: hb.on_shift,
524
523
  runInstall: async (nextWorkspace, repo) => {
@@ -531,7 +530,7 @@ async function runner() {
531
530
  }
532
531
  await runOps("install", [], {
533
532
  env: env
534
- ? { ...env, CONDUIT_WORKSPACE: nextWorkspace, CONDUIT_REPO: repo || env.CONDUIT_REPO }
533
+ ? { ...env, CONDUIT_WORKSPACE: nextWorkspace, CONDUIT_REPO: repo }
535
534
  : undefined,
536
535
  });
537
536
  },
@@ -540,6 +539,10 @@ async function runner() {
540
539
  console.log("On-shift workspace applied; exiting so the background service restarts on the new checkout.");
541
540
  process.exit(0);
542
541
  }
542
+ const intentProof = buildOnShiftIntentProof({ currentWorkspace: workspace, onShift: hb.on_shift });
543
+ if (intentProof) {
544
+ await heartbeat(client, config, workspace, currentBrief, preflight, intentProof);
545
+ }
543
546
  await renewLeases(client, config);
544
547
  if (workspace && onlineDriverIds(config).length) {
545
548
  // Observation runs before authoring: an owner waiting on an answer should not queue behind
@@ -551,7 +554,7 @@ async function runner() {
551
554
  heartbeat: async () => {
552
555
  const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
553
556
  const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined });
554
- await heartbeat(client, config, currentBrief, preflight);
557
+ await heartbeat(client, config, workspace, currentBrief, preflight);
555
558
  },
556
559
  heartbeatIntervalMs: intervalMs,
557
560
  }, running, {});
@@ -568,18 +571,20 @@ async function runner() {
568
571
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
569
572
  }
570
573
  }
571
- async function heartbeat(client, config, brief, preflight) {
574
+ async function heartbeat(client, config, workspacePath, brief, preflight, intentProof) {
572
575
  const online = onlineDriverIds(config);
573
576
  const status = heartbeatStatusForDrivers(online);
574
577
  const response = await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
575
578
  status,
576
579
  capabilities: config.capabilities,
577
580
  lease_capacity: config.leaseCapacity,
581
+ workspace_path: workspacePath,
578
582
  fuel_source: config.fuelSource === "local" ? "local" : "conduit",
579
583
  bridge_version: bridgeVersion(),
580
584
  bridge_protocol: BRIDGE_PROTOCOL_VERSION,
581
585
  drivers: driversHeartbeatReport(config, config.activeAttempts),
582
586
  preflight,
587
+ ...(intentProof ? { on_shift_intent_proof: intentProof } : {}),
583
588
  ...(brief ? { workspace_brief: brief } : {}),
584
589
  }) });
585
590
  const compatibility = response.bridge_compatibility && typeof response.bridge_compatibility === "object"
package/dist/client.js CHANGED
@@ -44,7 +44,17 @@ export class ConduitClient {
44
44
  // its hash, so the client mints it and re-sends the identical token if it ever retries the
45
45
  // claim (the idempotency key is deterministic per attempt exactly so that replay works).
46
46
  const leaseToken = `lease_${crypto.randomUUID()}${crypto.randomUUID()}`;
47
- const data = await this.request(`/runner/v1/tasks/${taskId}/claim`, { method: "POST", body: JSON.stringify({ attempt_id: attemptId, lease_token: leaseToken, idempotency_key: `bridge:claim:${attemptId}` }) });
47
+ const data = await this.request(`/runner/v1/tasks/${taskId}/claim`, {
48
+ method: "POST",
49
+ body: JSON.stringify({
50
+ attempt_id: attemptId,
51
+ lease_token: leaseToken,
52
+ idempotency_key: `bridge:claim:${attemptId}`,
53
+ ...(extras?.driverId ? { driver_id: extras.driverId } : {}),
54
+ ...(extras?.fuelMode ? { fuel_mode: extras.fuelMode } : {}),
55
+ ...(extras?.fuelProvenance ? { fuel_provenance: extras.fuelProvenance } : {}),
56
+ }),
57
+ });
48
58
  const active = {
49
59
  taskId,
50
60
  attemptId,
@@ -52,6 +62,7 @@ export class ConduitClient {
52
62
  leaseExpiresAt: String(data.lease_expires_at),
53
63
  phase: "claimed",
54
64
  ...(extras?.driverId ? { driverId: extras.driverId } : {}),
65
+ ...(extras?.fuelMode ? { fuelMode: extras.fuelMode } : {}),
55
66
  ...(extras?.executionKind ? { executionKind: extras.executionKind } : {}),
56
67
  ...(extras?.sourceAttemptId ? { sourceAttemptId: extras.sourceAttemptId } : {}),
57
68
  };
package/dist/detect.js CHANGED
@@ -42,7 +42,7 @@ export function probeAgentHealth(detected, env = process.env) {
42
42
  label: "Claude Code",
43
43
  installed: claudeInstalled,
44
44
  authenticated: claudeInstalled,
45
- authMethod: env.ANTHROPIC_API_KEY ? "environment_key" : claudeAuth ? "local_login" : "conduit_pump",
45
+ authMethod: (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) ? "environment_key" : claudeAuth ? "local_login" : "conduit_pump",
46
46
  });
47
47
  const codexInstalled = detectedSet.has("Codex CLI");
48
48
  const codexAuth = hasOpenAiLogin(env);
package/dist/drivers.js CHANGED
@@ -173,6 +173,26 @@ export function resolveDriverFuel(config, driverId) {
173
173
  return lane.fuel;
174
174
  return config.fuelSource === "local" ? "local" : "conduit";
175
175
  }
176
+ /**
177
+ * Credential class the selected lane will actually pass to its agent process. Secret values never
178
+ * leave the machine; only this bounded provenance is reported to the Control Plane.
179
+ */
180
+ export function resolveDriverFuelProvenance(config, driverId, env = process.env, fuelMode = resolveDriverFuel(config, driverId)) {
181
+ if (fuelMode === "conduit")
182
+ return "conduit_gateway";
183
+ const apiKeys = {
184
+ "claude-code": ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
185
+ codex: ["OPENAI_API_KEY", "CODEX_API_KEY"],
186
+ cursor: ["CURSOR_API_KEY"],
187
+ opencode: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"],
188
+ pi: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "XAI_API_KEY"],
189
+ kiro: ["KIRO_API_KEY"],
190
+ antigravity: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
191
+ grok: ["XAI_API_KEY"],
192
+ };
193
+ const names = isSupportedDriverId(driverId) ? apiKeys[driverId] ?? [] : [];
194
+ return names.some((name) => Boolean(env[name]?.trim())) ? "environment_api_key" : "local_login";
195
+ }
176
196
  export function setDriverFuel(config, driverId, fuel) {
177
197
  if (!isSupportedDriverId(driverId))
178
198
  throw new Error(`Unknown driver: ${driverId}`);
@@ -197,7 +217,7 @@ export function advertiseLeaseCapacity(approved, onlineIds) {
197
217
  return 1; // schema/heartbeat min; status standby prevents matching
198
218
  return approved;
199
219
  }
200
- export function driversHeartbeatReport(config, activeAttempts, processOnlineIds) {
220
+ export function driversHeartbeatReport(config, activeAttempts, processOnlineIds, env = process.env) {
201
221
  const processOnline = new Set(processOnlineIds ?? []);
202
222
  const busyByDriver = new Map();
203
223
  for (const active of Object.values(activeAttempts)) {
@@ -212,6 +232,7 @@ export function driversHeartbeatReport(config, activeAttempts, processOnlineIds)
212
232
  state: processOnline.has(lane.id) ? "online" : lane.state,
213
233
  busy: (busyByDriver.get(lane.id) ?? 0) > 0,
214
234
  fuel: lane.fuel,
235
+ fuel_provenance: resolveDriverFuelProvenance(config, lane.id, env),
215
236
  }));
216
237
  }
217
238
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
- * When the contract requires test evidence, Bridge runs one bounded verification
3
- * command and attaches the verbatim result. Agent prose may supplement that run;
2
+ * When the contract requires test evidence, Bridge runs the bounded verification
3
+ * commands and attaches their verbatim results. Agent prose may supplement those runs;
4
4
  * it never substitutes for it.
5
5
  */
6
6
  import { execFile } from "node:child_process";
@@ -31,6 +31,14 @@ export function pickVerificationCommand(commands) {
31
31
  .filter((command) => command && isBoundedVerificationCommand(command));
32
32
  return bounded.find(isPreferentialTestCommand) ?? bounded[0] ?? null;
33
33
  }
34
+ /** Prefer one project-defined aggregate; otherwise witness every distinct bounded command. */
35
+ export function verificationEvidenceCommands(commands) {
36
+ const bounded = [...new Set(commands
37
+ .map((command) => command.trim())
38
+ .filter((command) => command && isBoundedVerificationCommand(command)))];
39
+ const aggregate = bounded.find((command) => /^(?:npm run|pnpm|yarn) verify$|^make check$/.test(command));
40
+ return aggregate ? [aggregate] : bounded;
41
+ }
34
42
  function argvForBoundedCommand(command) {
35
43
  return command.trim().split(/\s+/);
36
44
  }
@@ -118,11 +126,29 @@ async function defaultRunCommand(command, workspace) {
118
126
  * Reuse only criteria the agent already mapped onto a test-kind evidence entry (including thin ones
119
127
  * we are replacing). Empty mapping lets validateDeliveryReport reject met-without-evidence.
120
128
  */
121
- export function criteriaForTestEvidence(report) {
129
+ function verificationCommandNames(command) {
130
+ const normalized = command.trim().replace(/\s+/g, " ").toLowerCase();
131
+ const names = new Set([normalized]);
132
+ if (!isBoundedVerificationCommand(normalized))
133
+ return names;
134
+ const script = normalized.match(/^(?:npm|pnpm|yarn) run ([^\s]+)(?:\s|$)/)?.[1]
135
+ ?? normalized.match(/^(?:pnpm|yarn) ([^\s]+)(?:\s|$)/)?.[1]
136
+ ?? normalized.match(/^npm ([^\s]+)(?:\s|$)/)?.[1];
137
+ if (script)
138
+ names.add(script);
139
+ return names;
140
+ }
141
+ export function criteriaForTestEvidence(report, command) {
142
+ const testEvidence = report.evidence.filter((item) => item.kind === "test");
143
+ const commandNames = command ? verificationCommandNames(command) : null;
144
+ const sources = commandNames
145
+ ? testEvidence.filter((item) => {
146
+ const evidenceNames = verificationCommandNames(item.name);
147
+ return [...commandNames].some((name) => evidenceNames.has(name));
148
+ })
149
+ : testEvidence;
122
150
  const mapped = new Set();
123
- for (const item of report.evidence) {
124
- if (item.kind !== "test")
125
- continue;
151
+ for (const item of sources) {
126
152
  for (const criterion of item.acceptance_criteria ?? []) {
127
153
  if (criterion)
128
154
  mapped.add(criterion);
@@ -131,42 +157,52 @@ export function criteriaForTestEvidence(report) {
131
157
  return [...mapped];
132
158
  }
133
159
  /**
134
- * If test evidence is required, run one bounded verification command in the attempt workspace
135
- * and attach verbatim stdout/stderr with a Bridge witness marker.
160
+ * If test evidence is required, run the bounded verification commands in the attempt workspace
161
+ * and attach verbatim stdout/stderr with Bridge witness markers.
136
162
  */
137
163
  export async function ensureTestEvidence(input) {
138
164
  if (!needsTestEvidence(input.report, input.spec, input.grants))
139
165
  return input.report;
140
- const command = pickVerificationCommand(input.verificationCommands);
141
- if (!command) {
166
+ const commands = verificationEvidenceCommands(input.verificationCommands);
167
+ if (commands.length === 0) {
142
168
  throw new Error("Agent report is missing required evidence: test (no bounded verification command available for Bridge to run)");
143
169
  }
144
170
  const run = input.runCommand ?? defaultRunCommand;
145
- const result = await run(command, input.workspace);
146
- const stdoutLines = result.stdout.trim() ? result.stdout.trim().split("\n") : [];
147
- const stderrLines = result.stderr.trim() ? result.stderr.trim().split("\n") : [];
148
- if (result.code !== 0) {
149
- // Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
150
- const failedDetails = [
171
+ const witnessed = [];
172
+ for (const command of commands) {
173
+ const result = await run(command, input.workspace);
174
+ const stdoutLines = result.stdout.trim() ? result.stdout.trim().split("\n") : [];
175
+ const stderrLines = result.stderr.trim() ? result.stderr.trim().split("\n") : [];
176
+ if (result.code !== 0) {
177
+ // Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
178
+ const failedDetails = [
179
+ `$ ${command}`,
180
+ ...stdoutLines,
181
+ ...stderrLines,
182
+ `exit ${result.code}`,
183
+ ].map((line) => line.slice(0, 4_000)).slice(0, 100);
184
+ throw new Error(`Agent report: Verification failed (${command}): ${failedDetails.join("\n").slice(0, 2_000)}`);
185
+ }
186
+ // Empty exit-0 is not proof — a no-op recipe (`@true`) must not clear required_evidence: test.
187
+ if (stdoutLines.length === 0 && stderrLines.length === 0) {
188
+ throw new Error(`Agent report: Verification produced no output for test evidence (${command}). A bounded test command must print observable stdout or stderr.`);
189
+ }
190
+ const details = [
151
191
  `$ ${command}`,
152
192
  ...stdoutLines,
153
193
  ...stderrLines,
154
194
  `exit ${result.code}`,
155
195
  ].map((line) => line.slice(0, 4_000)).slice(0, 100);
156
- throw new Error(`Agent report: Verification failed (${command}): ${failedDetails.join("\n").slice(0, 2_000)}`);
157
- }
158
- // Empty exit-0 is not proof — a no-op recipe (`@true`) must not clear required_evidence: test.
159
- if (stdoutLines.length === 0 && stderrLines.length === 0) {
160
- throw new Error(`Agent report: Verification produced no output for test evidence (${command}). A bounded test command must print observable stdout or stderr.`);
161
- }
162
- const details = [
163
- `$ ${command}`,
164
- ...stdoutLines,
165
- ...stderrLines,
166
- `exit ${result.code}`,
167
- ].map((line) => line.slice(0, 4_000)).slice(0, 100);
168
- if (details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN) {
169
- throw new Error(`Agent report: Verification produced insufficient output for test evidence (${command})`);
196
+ if (details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN) {
197
+ throw new Error(`Agent report: Verification produced insufficient output for test evidence (${command})`);
198
+ }
199
+ witnessed.push({
200
+ kind: "test",
201
+ name: command,
202
+ details,
203
+ acceptance_criteria: criteriaForTestEvidence(input.report, command),
204
+ witness: "bridge",
205
+ });
170
206
  }
171
207
  // Keep agent test prose as supplement; Bridge witness is the authoritative entry.
172
208
  const withoutThinTest = input.report.evidence.filter((item) => {
@@ -176,18 +212,14 @@ export async function ensureTestEvidence(input) {
176
212
  });
177
213
  return {
178
214
  ...input.report,
179
- verification: input.report.verification.includes(command)
180
- ? input.report.verification
181
- : [...input.report.verification, `${command} — exit ${result.code}`],
215
+ verification: [
216
+ ...input.report.verification,
217
+ ...commands.filter((command) => !input.report.verification.some((item) => item.startsWith(`${command} —`)))
218
+ .map((command) => `${command} — exit 0`),
219
+ ],
182
220
  evidence: [
183
221
  ...withoutThinTest,
184
- {
185
- kind: "test",
186
- name: command,
187
- details,
188
- acceptance_criteria: criteriaForTestEvidence(input.report),
189
- witness: "bridge",
190
- },
222
+ ...witnessed,
191
223
  ],
192
224
  };
193
225
  }
package/dist/execution.js CHANGED
@@ -5,7 +5,7 @@ import { ConduitRequestError } from "./client.js";
5
5
  import { redactSecrets, saveDriverQuota } from "./config.js";
6
6
  import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
7
7
  import { assertClassFloor } from "./execution-class.js";
8
- import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
8
+ import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis } from "./drivers.js";
9
9
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
10
10
  import { execFile } from "node:child_process";
11
11
  import { promisify } from "node:util";
@@ -608,8 +608,12 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
608
608
  return null;
609
609
  }
610
610
  console.log(`Claiming ${assignment.execution_kind} assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
611
+ const fuelMode = selectedDriverId ? resolveDriverFuel(config, selectedDriverId) : undefined;
612
+ const fuelProvenance = selectedDriverId ? resolveDriverFuelProvenance(config, selectedDriverId) : undefined;
611
613
  await client.claim(assignment.id, assignment.attempt_id, {
612
614
  ...(selectedDriverId ? { driverId: selectedDriverId } : {}),
615
+ ...(fuelMode ? { fuelMode } : {}),
616
+ ...(fuelProvenance ? { fuelProvenance } : {}),
613
617
  executionKind: assignment.execution_kind,
614
618
  ...(assignment.source_attempt_id ? { sourceAttemptId: assignment.source_attempt_id } : {}),
615
619
  });
@@ -909,7 +913,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
909
913
  ?? "unknown";
910
914
  if (!active.driverId && driverId !== "unknown")
911
915
  await client.updateAttempt(taskId, { driverId });
912
- const fuelSource = resolveDriverFuel(config, driverId);
916
+ const fuelSource = active.fuelMode ?? resolveDriverFuel(config, driverId);
917
+ const fuelProvenance = resolveDriverFuelProvenance(config, driverId, process.env, fuelSource);
913
918
  let fuel;
914
919
  if (fuelSource === "conduit") {
915
920
  const gatewayKey = await client.ensureFuel(task.project_id);
@@ -918,6 +923,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
918
923
  const selection = await resolveAssignmentModel(driver, config, spec, taskId, attemptWorkspace, fuel);
919
924
  await client.attemptRequest(taskId, "model-selection", {
920
925
  risk: selection.risk, tier: selection.tier, model: selection.model ?? null, driver: driver.name,
926
+ fuel_mode: fuelSource, fuel_provenance: fuelProvenance,
921
927
  ...(selection.model ? {} : { note: "cli-default" }), idempotency_key: `model:${active.attemptId}`,
922
928
  });
923
929
  // Persist the start *after* model selection, so audit history proves the agent ran with the model
@@ -1683,7 +1689,7 @@ function parseTaskSpec(value) {
1683
1689
  }
1684
1690
  }
1685
1691
  async function prepareDelivery(client, attemptId, taskId, report) {
1686
- const fuelMode = client.attempt(taskId).fuelMode === "local" ? "local" : "conduit";
1692
+ const fuelMode = client.attempt(taskId).fuelMode;
1687
1693
  const evidence = [];
1688
1694
  const evidenceByCriterion = new Map();
1689
1695
  for (const [index, item] of report.evidence.entries()) {
@@ -1717,7 +1723,7 @@ async function prepareDelivery(client, attemptId, taskId, report) {
1717
1723
  return {
1718
1724
  action: "complete",
1719
1725
  body: {
1720
- delivery_packet: { ...body, acceptance_results: acceptanceResults, ...(head_commit ? { head_commit } : {}), ...(pull_request_url ? { pull_request_url } : {}), references, fuel_mode: fuelMode },
1726
+ delivery_packet: { ...body, acceptance_results: acceptanceResults, ...(head_commit ? { head_commit } : {}), ...(pull_request_url ? { pull_request_url } : {}), references, ...(fuelMode ? { fuel_mode: fuelMode } : {}) },
1721
1727
  evidence,
1722
1728
  idempotency_key: `bridge:complete:${attemptId}`,
1723
1729
  },
@@ -1,15 +1,31 @@
1
1
  /**
2
- * Phase 2: when Connect queued a workspace intent, Bridge applies it once
3
- * (ops.env + install/restart) then acks. Ack runs only after install succeeds so a
4
- * failed apply leaves the intent queued for the next heartbeat (manual ops switch remains fallback).
5
- * After a successful restart, currentWorkspace matches the target so we do not re-apply.
2
+ * When Connect queues a workspace intent, Bridge applies it without rewriting the
3
+ * machine-wide ops.env. The rebound runner's matching heartbeat clears the intent;
4
+ * installation alone is not proof that the new process is actually bound.
6
5
  */
7
6
  import { resolve } from "node:path";
8
- import { homedir } from "node:os";
9
- import { defaultOpsEnvPath, expandOpsValue, loadOpsEnv, writeOpsEnvFile } from "./ops.js";
7
+ import { expandOpsValue } from "./ops.js";
10
8
  function pathsEqual(left, right) {
11
9
  return resolve(expandOpsValue(left)) === resolve(expandOpsValue(right));
12
10
  }
11
+ /** Prove a historical path alias only after this Bridge resolves it to its current workspace. */
12
+ export function buildOnShiftIntentProof(input) {
13
+ const intent = input.onShift?.on_shift_intent;
14
+ if (input.onShift?.binding_status !== "bound"
15
+ || !input.currentWorkspace
16
+ || !intent?.workspace_path?.trim()
17
+ || !intent.intent_at)
18
+ return null;
19
+ const current = resolve(expandOpsValue(input.currentWorkspace));
20
+ if (!pathsEqual(current, intent.workspace_path))
21
+ return null;
22
+ return {
23
+ workspace_path: intent.workspace_path,
24
+ resolved_workspace_path: current,
25
+ repository_url: intent.repository_url,
26
+ intent_at: intent.intent_at,
27
+ };
28
+ }
13
29
  /**
14
30
  * If control-plane intent names a different workspace than this runner, apply and return true
15
31
  * (caller should exit so install-service / LaunchAgent restarts).
@@ -20,36 +36,17 @@ export async function maybeApplyOnShiftIntent(input) {
20
36
  return false;
21
37
  const target = resolve(expandOpsValue(intentPath));
22
38
  const current = input.currentWorkspace ? resolve(expandOpsValue(input.currentWorkspace)) : null;
23
- if (current && pathsEqual(current, target)) {
24
- // Already on the declared path — leave intent for Bound clear on heartbeat.
25
- return false;
26
- }
27
39
  const repo = (input.onShift?.on_shift_intent?.repository_url
28
40
  ?? input.onShift?.expected_repository
29
41
  ?? "").trim();
30
- if (input.writeEnv) {
31
- input.writeEnv(target, repo);
32
- }
33
- else {
34
- let envPath = defaultOpsEnvPath(homedir());
35
- try {
36
- envPath = loadOpsEnv().loadedFrom ?? envPath;
37
- }
38
- catch {
39
- // First apply may create ops.env at the default path.
40
- }
41
- writeOpsEnvFile(envPath, {
42
- CONDUIT_WORKSPACE: target,
43
- ...(repo ? { CONDUIT_REPO: repo } : {}),
44
- });
42
+ if (current && pathsEqual(current, target)) {
43
+ // A matching path is sufficient only when there is no repository target, or when the
44
+ // control plane already observed the target fingerprint. Same path + different repo must
45
+ // still run checkout/install; path-only equality caused stale-project loops.
46
+ if (!repo || input.onShift?.binding_status === "bound")
47
+ return false;
45
48
  }
46
49
  console.log(`Applying on-shift workspace intent: ${target}${repo ? ` (${repo})` : ""}`);
47
50
  await input.runInstall(target, repo);
48
- // Ack only after install succeeds. A rebound service that already matches the target
49
- // short-circuits above and will not re-apply; Bound clears any leftover intent.
50
- await input.client.request("/runner/v1/machine/ack-on-shift-intent", {
51
- method: "POST",
52
- body: "{}",
53
- });
54
51
  return true;
55
52
  }
package/dist/ops.js CHANGED
@@ -13,7 +13,7 @@ import { ensureCheckout } from "./checkout.js";
13
13
  import { detectInstalledClients, probeAgentHealth } from "./detect.js";
14
14
  import { driverIdsFromDetectedLabels } from "./drivers.js";
15
15
  import { BRIDGE_PROTOCOL_VERSION, describePreflightIssue, runBridgePreflight } from "./preflight.js";
16
- import { applyRunnerToolPath, runnerServiceWorkspaceWarnings } from "./service.js";
16
+ import { applyRunnerToolPath, readActiveRunnerServiceWorkspace, readStoredRunnerServiceOptions, runnerServiceWorkspaceWarnings } from "./service.js";
17
17
  import { bridgeVersion } from "./version.js";
18
18
  import { bootstrapManagedWorkspace } from "./workspace-bootstrap.js";
19
19
  export const OPS_VERBS = [
@@ -88,7 +88,10 @@ export function loadOpsEnv(home = homedir(), cwd = process.cwd()) {
88
88
  }
89
89
  const pick = (key, fallback) => {
90
90
  const fromEnv = process.env[key];
91
- if (fromEnv !== undefined && fromEnv !== "")
91
+ // An explicit empty value clears a stale file default (notably CONDUIT_REPO when a new
92
+ // project has no declared repository yet). Process authority must not resurrect old project
93
+ // context from ops.env.
94
+ if (fromEnv !== undefined)
92
95
  return expandOpsValue(fromEnv, home);
93
96
  if (fileValues[key])
94
97
  return fileValues[key];
@@ -236,37 +239,49 @@ export async function runOps(verb, argv = [], deps = {}) {
236
239
  if (env.loadedFrom) {
237
240
  console.log(`Env: ${env.loadedFrom}`);
238
241
  console.log(`URL: ${env.CONDUIT_URL || "(unset)"}`);
239
- console.log(`Workspace: ${env.CONDUIT_WORKSPACE || "(unset)"}`);
240
- console.log(`Repo: ${env.CONDUIT_REPO || "(none)"}`);
242
+ console.log(`Bootstrap workspace: ${env.CONDUIT_WORKSPACE || "(unset)"}`);
243
+ console.log(`Bootstrap repo: ${env.CONDUIT_REPO || "(none)"}`);
241
244
  console.log(`Drivers: ${env.CONDUIT_DRIVERS || "(auto-detect installed agents)"}`);
242
245
  console.log(`Roles: ${env.CONDUIT_ROLES}`);
243
- if (env.CONDUIT_WORKSPACE) {
244
- for (const warning of runnerServiceWorkspaceWarnings(resolve(expandOpsValue(env.CONDUIT_WORKSPACE)))) {
245
- console.warn(`WARNING: ${warning}`);
246
- }
247
- }
248
246
  console.log("");
249
247
  }
250
248
  else {
251
249
  console.log("No ops.env loaded (showing drivers only).\n");
252
250
  }
251
+ console.log(`Active runner workspace: ${await (deps.readActiveRunnerServiceWorkspace ?? readActiveRunnerServiceWorkspace)() ?? "(not installed)"}`);
253
252
  runBridge(["drivers"]);
254
253
  return;
255
254
  }
256
255
  if (verb === "doctor") {
257
- requireOpsEnv(env);
258
- if (!env.CONDUIT_WORKSPACE)
259
- throw new Error(`Set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
256
+ const { values } = parseArgs({
257
+ args: argv,
258
+ options: {
259
+ workspace: { type: "string" },
260
+ repo: { type: "string" },
261
+ "skip-service-alignment": { type: "boolean" },
262
+ },
263
+ allowPositionals: false,
264
+ });
265
+ const stored = await (deps.readStoredRunnerServiceOptions ?? readStoredRunnerServiceOptions)();
266
+ const workspaceRaw = values.workspace?.trim() || stored?.workspace || env.CONDUIT_WORKSPACE;
267
+ const expectedRepository = values.repo !== undefined
268
+ ? values.repo.trim()
269
+ : stored?.ensureCheckout ?? env.CONDUIT_REPO;
270
+ requireOpsEnv({ ...env, CONDUIT_WORKSPACE: workspaceRaw });
271
+ if (!workspaceRaw)
272
+ throw new Error(`Install a runner service or set a bootstrap CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
260
273
  applyRunnerToolPath();
261
274
  const config = await (deps.loadBridgeConfig ?? loadConfig)();
262
- const workspace = resolve(expandOpsValue(env.CONDUIT_WORKSPACE));
263
- for (const warning of runnerServiceWorkspaceWarnings(workspace)) {
264
- console.warn(`WARNING: ${warning}`);
275
+ const workspace = resolve(expandOpsValue(workspaceRaw));
276
+ if (!values["skip-service-alignment"]) {
277
+ for (const warning of runnerServiceWorkspaceWarnings(workspace)) {
278
+ console.warn(`WARNING: ${warning}`);
279
+ }
265
280
  }
266
281
  const report = await (deps.preflight ?? runBridgePreflight)({
267
282
  config,
268
283
  workspace,
269
- expectedRepository: env.CONDUIT_REPO || undefined,
284
+ expectedRepository: expectedRepository || undefined,
270
285
  });
271
286
  const installed = await detectInstalledClients();
272
287
  const health = probeAgentHealth(installed, process.env);
@@ -287,8 +302,8 @@ export async function runOps(verb, argv = [], deps = {}) {
287
302
  console.log("");
288
303
  }
289
304
  if (report.ready) {
290
- const serviceWarnings = runnerServiceWorkspaceWarnings(workspace);
291
- if (serviceWarnings.length) {
305
+ const serviceWarnings = values["skip-service-alignment"] ? [] : runnerServiceWorkspaceWarnings(workspace);
306
+ if (serviceWarnings.length > 0) {
292
307
  for (const warning of serviceWarnings)
293
308
  console.warn(`WARNING: ${warning}`);
294
309
  throw new Error("Background runner service workspace does not match ops.env");
@@ -306,8 +321,8 @@ export async function runOps(verb, argv = [], deps = {}) {
306
321
  runBridge(["drivers", verb, ...drivers]);
307
322
  return;
308
323
  }
309
- // `ops install --workspace <path> [--repo <url>]` persists declared intent into ops.env so the
310
- // Connect UI path works without hand-editing a config file first.
324
+ // Explicit install arguments are active-run inputs, not machine-wide defaults. Enrollment may
325
+ // seed ops.env once; later project changes must not mutate it.
311
326
  let installEnv = env;
312
327
  let installArgv = argv;
313
328
  if (verb === "install") {
@@ -323,20 +338,14 @@ export async function runOps(verb, argv = [], deps = {}) {
323
338
  const declaredWorkspace = values.workspace?.trim();
324
339
  const declaredRepo = values.repo?.trim();
325
340
  if (declaredWorkspace || declaredRepo) {
326
- const envPath = env.loadedFrom ?? defaultOpsEnvPath();
327
- writeOpsEnvFile(envPath, {
328
- ...(declaredWorkspace ? { CONDUIT_WORKSPACE: declaredWorkspace } : {}),
329
- ...(declaredRepo ? { CONDUIT_REPO: declaredRepo } : {}),
330
- });
331
- console.log(`Saved ${[
332
- declaredWorkspace ? `workspace ${declaredWorkspace}` : "",
333
- declaredRepo ? `repo ${declaredRepo}` : "",
334
- ].filter(Boolean).join(" and ")} to ${envPath}`);
335
341
  installEnv = {
336
342
  ...env,
337
- ...(declaredWorkspace ? { CONDUIT_WORKSPACE: declaredWorkspace } : {}),
343
+ ...(declaredWorkspace ? {
344
+ CONDUIT_WORKSPACE: declaredWorkspace,
345
+ // A new explicit workspace must not inherit the prior bootstrap repository.
346
+ CONDUIT_REPO: declaredRepo ?? "",
347
+ } : {}),
338
348
  ...(declaredRepo ? { CONDUIT_REPO: declaredRepo } : {}),
339
- loadedFrom: env.loadedFrom ?? envPath,
340
349
  };
341
350
  }
342
351
  }
@@ -355,13 +364,26 @@ export async function runOps(verb, argv = [], deps = {}) {
355
364
  if (!projectId) {
356
365
  throw new Error("Usage: ops switch --project <project-id> [--workspace <path>] [--repo <url>]");
357
366
  }
358
- const workspaceRaw = values.workspace?.trim() || env.CONDUIT_WORKSPACE;
367
+ const declaredWorkspace = values.workspace?.trim();
368
+ const declaredRepo = values.repo?.trim();
369
+ const [activeWorkspace, stored] = await Promise.all([
370
+ (deps.readActiveRunnerServiceWorkspace ?? readActiveRunnerServiceWorkspace)(),
371
+ (deps.readStoredRunnerServiceOptions ?? readStoredRunnerServiceOptions)(),
372
+ ]);
373
+ const workspaceRaw = declaredWorkspace || activeWorkspace || stored?.workspace || env.CONDUIT_WORKSPACE;
359
374
  if (!workspaceRaw)
360
375
  throw new Error(`Set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()} or pass --workspace`);
361
376
  const workspace = resolve(expandOpsValue(workspaceRaw));
362
- const repo = (values.repo?.trim() || env.CONDUIT_REPO || "").trim();
363
- const envPath = env.loadedFrom ?? defaultOpsEnvPath();
364
- // Control plane first — do not rewrite ops.env if affinity replace fails.
377
+ const storedWorkspace = stored?.workspace ? resolve(expandOpsValue(stored.workspace)) : null;
378
+ const repo = (declaredRepo ?? (declaredWorkspace
379
+ ? ""
380
+ : storedWorkspace === workspace
381
+ ? stored?.ensureCheckout ?? ""
382
+ : activeWorkspace || stored?.workspace
383
+ ? ""
384
+ : env.CONDUIT_REPO)).trim();
385
+ // Control plane first. Its intent is the target authority; the installed runner command is
386
+ // the local runtime authority. ops.env remains enrollment/bootstrap input only.
365
387
  const config = await (deps.loadBridgeConfig ?? loadConfig)();
366
388
  const client = new ConduitClient(config);
367
389
  const result = await client.request("/runner/v1/machine/switch-project", {
@@ -373,29 +395,17 @@ export async function runOps(verb, argv = [], deps = {}) {
373
395
  }),
374
396
  });
375
397
  console.log(`Control plane on-shift: ${String(result.project_id ?? projectId)} (${String(result.binding_status ?? "pending")})`);
376
- writeOpsEnvFile(envPath, {
377
- CONDUIT_WORKSPACE: workspace,
378
- ...(repo ? { CONDUIT_REPO: repo } : {}),
379
- });
380
- console.log(`Updated ${envPath}`);
381
398
  console.log(`On-shift workspace: ${workspace}${repo ? ` (${repo})` : ""}`);
382
399
  // Same install path as ops install — restart runner against the new checkout.
383
- const switched = loadOpsEnv();
384
400
  await runOps("install", [], {
385
401
  ...deps,
386
402
  env: {
387
- ...switched,
403
+ ...env,
388
404
  CONDUIT_WORKSPACE: workspace,
389
- CONDUIT_REPO: repo || switched.CONDUIT_REPO,
405
+ CONDUIT_REPO: repo,
390
406
  },
391
407
  });
392
- // Clear queued intent only after install succeeds so a failed apply can retry via heartbeat.
393
- try {
394
- await client.request("/runner/v1/machine/ack-on-shift-intent", { method: "POST", body: "{}" });
395
- }
396
- catch {
397
- // Older control planes omit ack — local ops.env still wins.
398
- }
408
+ // The target runner heartbeat clears the queued intent only after it proves Bound.
399
409
  return;
400
410
  }
401
411
  if (verb === "connect") {
@@ -466,29 +476,32 @@ export async function runOps(verb, argv = [], deps = {}) {
466
476
  * intent and Bridge clones into <root>/<owner>/<repo> and restarts on it.
467
477
  */
468
478
  const managed = values.managed !== undefined;
469
- const managedRoot = managed ? (values.managed?.trim() || DEFAULT_MANAGED_ROOT) : "";
470
- const workspace = managed ? managedRoot : (values.workspace?.trim() || env.CONDUIT_WORKSPACE);
471
- const repo = managed ? "" : (values.repo?.trim() || env.CONDUIT_REPO);
479
+ const managedRoot = managed ? resolve(expandOpsValue(values.managed?.trim() || DEFAULT_MANAGED_ROOT)) : "";
480
+ const declaredWorkspace = values.workspace?.trim();
481
+ const declaredRepo = values.repo?.trim();
482
+ const workspace = managed ? managedRoot : (declaredWorkspace || env.CONDUIT_WORKSPACE);
483
+ const repo = managed ? "" : (declaredRepo ?? (declaredWorkspace ? "" : env.CONDUIT_REPO));
472
484
  const machine = values.machine?.trim();
473
485
  const envPath = env.loadedFrom ?? defaultOpsEnvPath();
474
486
  writeOpsEnvFile(envPath, {
475
487
  CONDUIT_URL: url,
476
488
  ...(workspace ? { CONDUIT_WORKSPACE: workspace } : {}),
477
- ...(repo ? { CONDUIT_REPO: repo } : {}),
478
- ...(managed ? { CONDUIT_MANAGED_ROOT: managedRoot } : {}),
489
+ CONDUIT_REPO: repo,
490
+ CONDUIT_MANAGED_ROOT: managedRoot,
479
491
  });
492
+ const enrollmentEnv = {
493
+ ...env,
494
+ CONDUIT_URL: url,
495
+ CONDUIT_WORKSPACE: workspace,
496
+ CONDUIT_REPO: repo,
497
+ CONDUIT_MANAGED_ROOT: managedRoot,
498
+ loadedFrom: envPath,
499
+ };
480
500
  // Resolve the local lane before redeeming the single-use token. A missing coding agent is a
481
501
  // local prerequisite, not an enrollment failure, and must leave the token available to retry.
482
502
  // `--no-install` remains the explicit credential-only escape hatch.
483
503
  const installDrivers = !values["no-install"] && workspace
484
- ? await (deps.resolveInstallDrivers ?? resolveInstallDrivers)({
485
- ...env,
486
- CONDUIT_URL: url,
487
- CONDUIT_WORKSPACE: workspace,
488
- CONDUIT_REPO: repo,
489
- CONDUIT_MANAGED_ROOT: managedRoot,
490
- loadedFrom: envPath,
491
- }, [])
504
+ ? await (deps.resolveInstallDrivers ?? resolveInstallDrivers)(enrollmentEnv, [])
492
505
  : [];
493
506
  if (managed)
494
507
  mkdirSync(resolve(expandOpsValue(managedRoot)), { recursive: true });
@@ -519,8 +532,7 @@ export async function runOps(verb, argv = [], deps = {}) {
519
532
  return;
520
533
  }
521
534
  if (workspace) {
522
- const refreshedEnv = loadOpsEnv();
523
- await runOps("install", installDrivers, { ...deps, env: refreshedEnv });
535
+ await runOps("install", installDrivers, { ...deps, env: enrollmentEnv });
524
536
  }
525
537
  return;
526
538
  }
@@ -566,7 +578,12 @@ export async function runOps(verb, argv = [], deps = {}) {
566
578
  && !installEnv.CONDUIT_REPO);
567
579
  if (!waitingOnManagedAssignment) {
568
580
  // Prove the exact local environment before installing a service that advertises availability.
569
- runBridge(["ops", "doctor"]);
581
+ runBridge([
582
+ "ops", "doctor",
583
+ "--workspace", workspace,
584
+ "--repo", installEnv.CONDUIT_REPO,
585
+ "--skip-service-alignment",
586
+ ]);
570
587
  }
571
588
  const installArgs = ["install-service", "--workspace", workspace];
572
589
  if (installEnv.CONDUIT_REPO)
package/dist/preflight.js CHANGED
@@ -16,9 +16,9 @@ function modelsFingerprint(config) {
16
16
  }).join(";");
17
17
  return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
18
18
  }
19
- // Protocol 5 makes the lease-token claim contract explicit. Older Bridges may report a green
20
- // preflight but cannot claim against the current control plane, so they must be admitted as stale.
21
- export const BRIDGE_PROTOCOL_VERSION = 7;
19
+ // Protocol 8 reports the resolved lane fuel and credential provenance in heartbeat and claim, so
20
+ // the control plane can freeze what will actually run before the agent spends tokens.
21
+ export const BRIDGE_PROTOCOL_VERSION = 8;
22
22
  const execFileAsync = promisify(execFile);
23
23
  async function defaultCommandRunner(command, args, cwd) {
24
24
  try {
@@ -181,7 +181,7 @@ export function describePreflightIssue(issue) {
181
181
  // Named before the fallback: an exhausted lane fell through to "requires local fuel", which sends
182
182
  // the operator to change a fuel setting when the lane is configured correctly and simply spent.
183
183
  if (issue.code === "driver_quota_exhausted") {
184
- return "Every online agent lane has spent its subscription allowance; work resumes when a window refills, or bring another lane online";
184
+ return "Every online local-fuel lane is unavailable; work resumes when a reported provider allowance recovers, or bring another lane online";
185
185
  }
186
186
  return `Agent lane requires local fuel${lane}`;
187
187
  }
package/dist/service.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
2
2
  import { homedir, platform } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { existsSync, realpathSync } from "node:fs";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  export const SERVICE_LABEL = "io.miraland.conduit-runner";
@@ -95,7 +95,7 @@ ${argsXml}
95
95
  `;
96
96
  }
97
97
  export function systemdUserUnit(programArguments, workingDirectory, pathEnv = runnerServicePath()) {
98
- const execStart = programArguments.map(escapeSystemd).join(" ");
98
+ const execStart = programArguments.map(escapeSystemdExecArgument).join(" ");
99
99
  return `[Unit]
100
100
  Description=Conduit Bridge runner
101
101
  After=network-online.target
@@ -103,8 +103,9 @@ Wants=network-online.target
103
103
 
104
104
  [Service]
105
105
  Type=simple
106
- WorkingDirectory=${workingDirectory}
107
- Environment=PATH=${escapeSystemd(pathEnv)}
106
+ WorkingDirectory=${escapeSystemdUnitValue(workingDirectory)}
107
+ Environment=PATH=${escapeSystemdUnitValue(pathEnv)}
108
+ Environment=CONDUIT_RUNNER_SERVICE=systemd
108
109
  ExecStart=${execStart}
109
110
  Restart=always
110
111
  RestartSec=5
@@ -116,10 +117,16 @@ WantedBy=default.target
116
117
  function escapeXml(value) {
117
118
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
118
119
  }
119
- function escapeSystemd(value) {
120
- if (/[\s"'\\]/.test(value))
121
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
122
- return value;
120
+ function escapeSystemdUnitValue(value) {
121
+ // Unit values expand % specifiers. Environment= and WorkingDirectory= keep $ literal.
122
+ const literal = value.replaceAll("%", "%%");
123
+ if (/[\s"'\\]/.test(literal))
124
+ return `"${literal.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
125
+ return literal;
126
+ }
127
+ function escapeSystemdExecArgument(value) {
128
+ // Exec*= additionally expands $ environment references; $$ passes one literal dollar.
129
+ return escapeSystemdUnitValue(value.replaceAll("$", () => "$$"));
123
130
  }
124
131
  function launchAgentsPath() {
125
132
  return join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
@@ -137,52 +144,53 @@ export function parseRunnerWorkspaceFromProgramArguments(args) {
137
144
  }
138
145
  export function parseRunnerWorkspaceFromExecStart(execStart) {
139
146
  const match = /--workspace(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/.exec(execStart);
140
- return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
147
+ const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
148
+ return value?.replaceAll("%%", "%").replaceAll("$$", "$") ?? null;
141
149
  }
142
- function dropInOverridesExecStart(content) {
143
- return /^\s*ExecStart\s*=/m.test(content);
150
+ /** Parse the effective drop-ins reported by systemd without reading their contents. */
151
+ export function parseSystemdDropInPaths(value) {
152
+ return value.trim().split(/\s+/).filter((path) => path.endsWith(".conf"));
144
153
  }
145
- /** Drop-in fragments that override ExecStart can silently hijack ops install — strip them. */
146
- export async function removeExecStartOverridesFromSystemdDropIns(home = homedir()) {
154
+ /** Any effective drop-in can change workspace, identity, fuel, PATH, or restart behaviour. */
155
+ export async function systemdDropInConflicts(home = homedir()) {
156
+ const conflicts = new Set();
147
157
  const dir = systemdDropInDir(home);
148
- if (!existsSync(dir))
149
- return [];
150
- const removed = [];
151
- for (const name of await readdir(dir)) {
152
- if (!name.endsWith(".conf"))
153
- continue;
154
- const path = join(dir, name);
155
- const content = await readFile(path, "utf8");
156
- if (!dropInOverridesExecStart(content))
157
- continue;
158
- const kept = content.split(/\r?\n/).filter((line) => {
159
- const trimmed = line.trim();
160
- return trimmed && !trimmed.startsWith("#") && !/^ExecStart\s*=/.test(trimmed);
161
- });
162
- if (kept.length === 0) {
163
- await unlink(path);
158
+ if (existsSync(dir)) {
159
+ for (const name of await readdir(dir)) {
160
+ if (name.endsWith(".conf"))
161
+ conflicts.add(name);
164
162
  }
165
- else {
166
- const next = content.split(/\r?\n/).filter((line) => !/^\s*ExecStart\s*=/.test(line)).join("\n").trimEnd();
167
- await writeFile(path, next ? `${next}\n` : "", { mode: 0o644 });
168
- if (!next)
169
- await unlink(path).catch(() => undefined);
163
+ }
164
+ if (platform() === "linux") {
165
+ const effective = spawnSync("systemctl", ["--user", "show", "conduit-runner.service", "--property=DropInPaths", "--value"], { encoding: "utf8" });
166
+ if (effective.status === 0) {
167
+ for (const path of parseSystemdDropInPaths(effective.stdout))
168
+ conflicts.add(basename(path));
170
169
  }
171
- removed.push(name);
172
170
  }
173
- return removed;
171
+ return [...conflicts].sort();
174
172
  }
175
- export async function readStoredRunnerServiceWorkspace(home = homedir()) {
173
+ export async function readStoredRunnerServiceOptions() {
176
174
  try {
177
175
  const stored = JSON.parse(await readFile(serviceStatePath(), "utf8"));
178
- return stored.options.workspace
179
- ?? parseRunnerWorkspaceFromProgramArguments(stored.programArguments)
180
- ?? null;
176
+ return {
177
+ ...stored.options,
178
+ workspace: stored.options.workspace
179
+ ?? parseRunnerWorkspaceFromProgramArguments(stored.programArguments)
180
+ ?? undefined,
181
+ };
181
182
  }
182
183
  catch {
183
184
  return null;
184
185
  }
185
186
  }
187
+ export async function readStoredRunnerServiceWorkspace() {
188
+ return (await readStoredRunnerServiceOptions())?.workspace ?? null;
189
+ }
190
+ /** Installed supervisor command is the active workspace authority; service.json is fallback. */
191
+ export async function readActiveRunnerServiceWorkspace(home = homedir()) {
192
+ return readEffectiveLinuxRunnerWorkspace(home) ?? await readStoredRunnerServiceWorkspace();
193
+ }
186
194
  /** Effective workspace from the installed supervisor (systemd user unit on Linux). */
187
195
  export function readEffectiveLinuxRunnerWorkspace(home = homedir()) {
188
196
  if (platform() !== "linux")
@@ -264,6 +272,21 @@ function sleepMs(ms) {
264
272
  export function runningInsideRunnerLaunchAgent() {
265
273
  return process.env.XPC_SERVICE_NAME === SERVICE_LABEL;
266
274
  }
275
+ /** True when install-service was invoked by the systemd runner it is replacing. */
276
+ export function runningInsideRunnerSystemd(env = process.env) {
277
+ return env.CONDUIT_RUNNER_SERVICE === "systemd";
278
+ }
279
+ /**
280
+ * Reloading a unit does not replace an already-running ExecStart. Interactive installs restart
281
+ * explicitly; the resident runner must return to its caller and exit normally, after which
282
+ * Restart=always starts the rewritten unit.
283
+ */
284
+ export function systemdActivationCommands(insideRunner) {
285
+ return [
286
+ ["--user", "enable", "conduit-runner.service"],
287
+ ...(!insideRunner ? [["--user", "restart", "conduit-runner.service"]] : []),
288
+ ];
289
+ }
267
290
  /**
268
291
  * Boot out (ignore miss), bootstrap with retries, then enable + kickstart.
269
292
  * Returns whether `launchctl print` sees the service.
@@ -336,16 +359,18 @@ export async function installRunnerService(options = {}) {
336
359
  return { path: plistPath, platform: "darwin" };
337
360
  }
338
361
  const unitPath = systemdUnitPath();
339
- const removedDropIns = await removeExecStartOverridesFromSystemdDropIns();
340
- if (removedDropIns.length) {
341
- console.warn(`Removed stale systemd drop-in(s) that overrode ExecStart: ${removedDropIns.join(", ")}. ` +
342
- "Those files can silently point the runner at the wrong checkout after ops switch.");
362
+ const dropIns = await systemdDropInConflicts();
363
+ if (dropIns.length) {
364
+ throw new Error(`Refusing to install with conduit-runner.service drop-in(s): ${dropIns.join(", ")}. ` +
365
+ "Review and migrate or remove them, then run ops install again.");
343
366
  }
344
367
  await mkdir(dirname(unitPath), { recursive: true });
345
368
  await mkdir(linuxLogDir(), { recursive: true });
346
369
  await writeFile(unitPath, systemdUserUnit(programArguments, configDir()), { mode: 0o644 });
347
370
  run("systemctl", ["--user", "daemon-reload"]);
348
- run("systemctl", ["--user", "enable", "--now", "conduit-runner.service"]);
371
+ for (const args of systemdActivationCommands(runningInsideRunnerSystemd())) {
372
+ run("systemctl", args);
373
+ }
349
374
  await writeFile(serviceStatePath(), `${JSON.stringify({ platform: "linux", programArguments, options, installed_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
350
375
  return { path: unitPath, platform: "linux" };
351
376
  }
package/ops/env.example CHANGED
@@ -1,4 +1,5 @@
1
- # Configuration (not scripts). Copy to ~/.config/conduit/ops.env and edit once.
1
+ # Enrollment/bootstrap defaults. Copy to ~/.config/conduit/ops.env and edit once.
2
+ # Project switches come from Conduit and the installed runner command; do not edit this file per project.
2
3
  # Same file works on macOS, Linux, and Windows ($HOME / %USERPROFILE% / ~ are expanded).
3
4
  #
4
5
  # Cross-platform commands (any OS):
@@ -6,13 +7,14 @@
6
7
  # npx @miraland-labs/conduit-bridge@latest ops install
7
8
  # Local shortcuts after init-ops: ~/conduit/ops/*.sh (Mac/Linux) or *.cmd (Windows)
8
9
  #
9
- # Replace the placeholders below with YOUR org, local checkout, and (optional) git remote.
10
+ # Replace the placeholders below with initial connection defaults.
10
11
  CONDUIT_URL=https://api.conduit.miraland.io
11
12
  # Leave CONDUIT_ORG unset so `ops connect` asks "Join which org?".
12
13
  # Or pass once: ops connect --organization your-org-slug
13
14
  # Prefer the Connect UI join command (includes the org you are signed into).
14
15
  # CONDUIT_ORG=your-org-slug
15
- CONDUIT_WORKSPACE=$HOME/path/to/your-repo
16
+ # Optional bootstrap checkout for the first install. Later switches do not rewrite these values.
17
+ # CONDUIT_WORKSPACE=$HOME/path/to/your-repo
16
18
  # CONDUIT_REPO=https://github.com/your-org/your-repo.git
17
19
  # Leave CONDUIT_DRIVERS unset to auto-detect the coding agents installed on this computer.
18
20
  # Pin explicitly only when you want a subset, e.g.:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.18",
3
+ "version": "0.16.19",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {