@miraland-labs/conduit-bridge 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
- **Current npm:** `0.8.0` — **multi-driver lanes** on one Connect computer (`drivers online|offline`), shared concurrent slots **1–4** (sum across online IDEs, not per IDE), per-attempt worktrees, optional `--ensure-checkout`, safe resume.
5
+ **Current npm:** `0.9.1` — **disconnect/disengage**, join org prompt when `--organization` is omitted, multi-driver lanes (`drivers online|offline`), shared concurrent slots **1–4**, worktrees, optional `--ensure-checkout`.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -20,6 +20,14 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
20
20
 
21
21
  ```bash
22
22
  npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
23
+ # If --organization is omitted, the CLI asks: Join which org?
24
+ ```
25
+
26
+ Disengage (leave the organization; then you can join the same or another org):
27
+
28
+ ```bash
29
+ npx @miraland-labs/conduit-bridge disconnect
30
+ # Or non-interactive: disconnect --yes
23
31
  ```
24
32
 
25
33
  After approval, seed detects installed CLIs as **offline lanes**. Bring one or more online (subscription failover / concurrent lanes):
package/dist/cli.js CHANGED
@@ -3,10 +3,12 @@ import { parseArgs } from "node:util";
3
3
  import { resolve, dirname, join as pathJoin } from "node:path";
4
4
  import { hostname, userInfo } from "node:os";
5
5
  import { spawn } from "node:child_process";
6
+ import { createInterface } from "node:readline/promises";
6
7
  import { readFileSync } from "node:fs";
8
+ import { stdin as input, stdout as output } from "node:process";
7
9
  import { fileURLToPath } from "node:url";
8
- import { ConduitClient } from "./client.js";
9
- import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
10
+ import { ConduitClient, ConduitRequestError } from "./client.js";
11
+ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
10
12
  import { runMcp } from "./mcp.js";
11
13
  import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
12
14
  import { DRIVERS } from "./driver.js";
@@ -54,6 +56,27 @@ async function connect() {
54
56
  throw new Error(data.error?.message ?? `Connect failed (${response.status})`);
55
57
  await finishConnection(baseUrl, data, parseFuelSource(values.fuel));
56
58
  }
59
+ async function promptLine(question) {
60
+ if (!input.isTTY || !output.isTTY) {
61
+ throw new Error("Interactive input is unavailable; pass the value on the command line");
62
+ }
63
+ const rl = createInterface({ input, output });
64
+ try {
65
+ return (await rl.question(question)).trim();
66
+ }
67
+ finally {
68
+ rl.close();
69
+ }
70
+ }
71
+ async function resolveJoinOrganization(explicit) {
72
+ const raw = explicit?.trim() || await promptLine("Join which org? ");
73
+ const organization = raw.toLowerCase();
74
+ if (!organization)
75
+ throw new Error("Organization slug is required");
76
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
77
+ throw new Error("Organization must be its lowercase workspace slug");
78
+ return organization;
79
+ }
57
80
  async function join() {
58
81
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
59
82
  url: { type: "string" }, resume: { type: "boolean" }, machine: { type: "string" }, operator: { type: "string" },
@@ -75,13 +98,11 @@ async function join() {
75
98
  if (pending && !values.resume)
76
99
  console.log(`Resuming the pending connection request for ${pending.baseUrl}.`);
77
100
  if (!pending) {
78
- if (!values.url || !values.organization) {
79
- throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "--organization", "<slug>", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
101
+ if (!values.url) {
102
+ throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "[--organization <slug>]", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
80
103
  }
81
104
  const baseUrl = normalizeBaseUrl(values.url);
82
- const organization = values.organization.trim().toLowerCase();
83
- if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
84
- throw new Error("Organization must be its lowercase workspace slug");
105
+ const organization = await resolveJoinOrganization(values.organization);
85
106
  // A config that fails validation (for example pre-organization) must not
86
107
  // block rejoining — join replaces it with a complete identity.
87
108
  const connected = await loadConfigIfPresent().catch(() => null);
@@ -448,11 +469,45 @@ async function heartbeat(client, config, brief, processOnlineIds) {
448
469
  ...(brief ? { workspace_brief: brief } : {}),
449
470
  }) });
450
471
  }
472
+ async function disconnect() {
473
+ const { values } = parseArgs({ args: process.argv.slice(3), options: { yes: { type: "boolean", short: "y" } } });
474
+ const config = await loadConfigIfPresent().catch(() => null);
475
+ if (!config) {
476
+ await clearLocalConnection();
477
+ console.log("This Bridge is not connected to an organization.");
478
+ return;
479
+ }
480
+ if (!values.yes) {
481
+ const answer = (await promptLine(`Disengage this computer from the organization and clear local credentials? [y/N] `)).toLowerCase();
482
+ if (answer !== "y" && answer !== "yes") {
483
+ console.log("Cancelled.");
484
+ return;
485
+ }
486
+ }
487
+ try {
488
+ await new ConduitClient(config).request("/runner/v1/disengage", { method: "POST", body: "{}" });
489
+ console.log("Conduit disengaged this computer from the organization.");
490
+ }
491
+ catch (error) {
492
+ // Local clear still proceeds when the server already revoked the key (admin disengage).
493
+ if (error instanceof ConduitRequestError && (error.status === 401 || error.status === 404)) {
494
+ console.warn(`Server disengage skipped (${error.status}): ${redactSecrets(error.message)}`);
495
+ }
496
+ else {
497
+ throw error;
498
+ }
499
+ }
500
+ await clearLocalConnection();
501
+ console.log("Local Bridge credentials cleared. Run join to connect again (same or another org).");
502
+ console.log(`Tip: stop a background runner with \`${bridgeUsage("uninstall-service")}\` if one is installed.`);
503
+ }
451
504
  try {
452
505
  if (command === "connect")
453
506
  await connect();
454
507
  else if (command === "join")
455
508
  await join();
509
+ else if (command === "disconnect")
510
+ await disconnect();
456
511
  else if (command === "fuel")
457
512
  await fuelCommand();
458
513
  else if (command === "drivers")
@@ -466,7 +521,7 @@ try {
466
521
  else if (command === "uninstall-service")
467
522
  await uninstallService();
468
523
  else
469
- throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
524
+ throw new Error(`Usage: ${BRIDGE_NPX} <join|disconnect|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
470
525
  }
471
526
  catch (error) {
472
527
  console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
package/dist/config.js CHANGED
@@ -260,6 +260,17 @@ export async function clearPendingConnection() {
260
260
  throw error;
261
261
  });
262
262
  }
263
+ /** Drop local org credentials after a server-side disengage. Keeps installation.json. */
264
+ export async function clearLocalConnection() {
265
+ await withConfigLock(async () => {
266
+ for (const target of [path, runtimePath, pendingPath]) {
267
+ await unlink(target).catch((error) => {
268
+ if (error.code !== "ENOENT")
269
+ throw error;
270
+ });
271
+ }
272
+ });
273
+ }
263
274
  export async function loadOrCreateInstallationId() {
264
275
  try {
265
276
  const identity = JSON.parse(await readFile(installationPath, "utf8"));
package/dist/execution.js CHANGED
@@ -44,6 +44,7 @@ const taskSpecSchema = z.object({
44
44
  change_scope: z.array(z.string()).optional(), work_role: z.string().optional(),
45
45
  repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
46
46
  risk_level: z.string().optional(),
47
+ deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
47
48
  });
48
49
  const executionContractSchema = z.object({
49
50
  repository_fingerprint: z.string().nullable().optional().default(null),
@@ -58,6 +59,7 @@ const workPackageSchema = z.object({
58
59
  acceptance: z.array(z.string()).optional(),
59
60
  change_scope: z.array(z.string()).optional(),
60
61
  required_evidence: z.array(z.string()).optional(),
62
+ deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
61
63
  initiative: z.object({
62
64
  title: z.string().nullable().optional(),
63
65
  desired_outcome: z.string().nullable().optional(),
@@ -272,7 +274,10 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
272
274
  const task = taskDetailSchema.parse(detail.task);
273
275
  const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
274
276
  const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
275
- const spec = parseTaskSpec(task.spec_json);
277
+ const parsedSpec = parseTaskSpec(task.spec_json);
278
+ const deliverable = workPackage?.deliverable ?? parsedSpec.deliverable;
279
+ const spec = { ...parsedSpec, deliverable };
280
+ const artifactDelivery = deliverable === "artifact";
276
281
  const grants = z.array(z.string()).parse(task.grants_json ? JSON.parse(task.grants_json) : []);
277
282
  const reworkFeedback = task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
278
283
  // Recompile current state at claim time — earlier packages may have moved the repo.
@@ -309,6 +314,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
309
314
  }
310
315
  }
311
316
  let attemptWorkspace;
317
+ let deliverySubmitted = false;
312
318
  if (options.existingWorktree) {
313
319
  attemptWorkspace = options.existingWorktree;
314
320
  }
@@ -486,20 +492,26 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
486
492
  }
487
493
  await client.updateAttempt(taskId, { phase: "agent_finished", delivery: { spec, report } });
488
494
  await submitFinishedDelivery(client, taskId);
495
+ deliverySubmitted = true;
489
496
  console.log(`Assignment ${taskId} delivered for review and acceptance.`);
490
497
  }
491
498
  finally {
492
499
  clearInterval(renewTimer);
493
500
  if (heartbeatTimer)
494
501
  clearInterval(heartbeatTimer);
495
- // F-07: dispose the attempt worktree so the next claim cannot inherit edits.
496
- // Terminal submit may already have cleared activeAttempts never throw from cleanup.
497
- const path = config.activeAttempts[taskId]?.worktreePath ?? attemptWorkspace;
498
- await removeAttemptWorktree(workspace, path).catch((error) => {
499
- console.error(`Attempt worktree cleanup failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
500
- });
501
- if (config.activeAttempts[taskId]) {
502
- await client.updateAttempt(taskId, { worktreePath: undefined }).catch(() => undefined);
502
+ if (artifactDelivery && !deliverySubmitted) {
503
+ console.error(`Artifact delivery was not submitted; retained generated files at ${attemptWorkspace}`);
504
+ }
505
+ else {
506
+ // F-07: dispose the attempt worktree so the next claim cannot inherit edits.
507
+ // Terminal submit may already have cleared activeAttempts — never throw from cleanup.
508
+ const path = config.activeAttempts[taskId]?.worktreePath ?? attemptWorkspace;
509
+ await removeAttemptWorktree(workspace, path).catch((error) => {
510
+ console.error(`Attempt worktree cleanup failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
511
+ });
512
+ if (config.activeAttempts[taskId]) {
513
+ await client.updateAttempt(taskId, { worktreePath: undefined }).catch(() => undefined);
514
+ }
503
515
  }
504
516
  }
505
517
  }
@@ -658,6 +670,24 @@ async function prepareDelivery(client, attemptId, taskId, report) {
658
670
  }
659
671
  export function validateDeliveryReport(report, spec, grants = []) {
660
672
  validateChangeScope(report, spec.change_scope ?? []);
673
+ if (spec.deliverable === "artifact") {
674
+ const published = report.evidence.some((item) => {
675
+ if (!["preview", "research", "documentation"].includes(item.kind))
676
+ return false;
677
+ if (!item.uri || !item.digest || !/^sha256:[0-9a-f]{64}$/i.test(item.digest))
678
+ return false;
679
+ try {
680
+ const protocol = new URL(item.uri).protocol;
681
+ return protocol === "https:" || protocol === "http:";
682
+ }
683
+ catch {
684
+ return false;
685
+ }
686
+ });
687
+ if (!published) {
688
+ throw new Error("Artifact delivery requires a published HTTP(S) URL and sha256 digest");
689
+ }
690
+ }
661
691
  // Invariant 9: grants bound what an attempt may do. Cursor/plan-mode are soft hints the headless
662
692
  // agent can leave, so repository-write authority is enforced here on the reported delivery: a task
663
693
  // without repo_write must not report repository changes.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.9.0",
4
- "description": "Conduit Bridge CLI — join, connect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
3
+ "version": "0.9.1",
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": {
7
7
  "conduit": "dist/cli.js"