@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 +9 -1
- package/dist/cli.js +63 -8
- package/dist/config.js +11 -0
- package/dist/execution.js +39 -9
- 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
|
-
**Current npm:** `0.
|
|
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
|
|
79
|
-
throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "--organization
|
|
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
|
|
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
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
await
|
|
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.
|
|
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"
|