@miraland-labs/conduit-bridge 0.1.0
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 +33 -0
- package/dist/brief.js +85 -0
- package/dist/cli.js +332 -0
- package/dist/client.js +66 -0
- package/dist/config.js +87 -0
- package/dist/detect.js +31 -0
- package/dist/driver.js +201 -0
- package/dist/execution.js +264 -0
- package/dist/mcp.js +54 -0
- package/dist/service.js +153 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @miraland-labs/conduit-bridge
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js 20+
|
|
8
|
+
- **Claude Code** (`claude` on PATH) for automated execution today
|
|
9
|
+
- macOS or Linux for `install-service` (Windows: keep a terminal runner open)
|
|
10
|
+
|
|
11
|
+
## Install / run
|
|
12
|
+
|
|
13
|
+
No global install required:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
After approval, keep the computer executing work (not heartbeat-only):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @miraland-labs/conduit-bridge runner --agent claude-code --workspace /path/to/repo
|
|
23
|
+
npx @miraland-labs/conduit-bridge install-service --agent claude-code --workspace /path/to/repo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Optional global install creates a `conduit` shim on PATH:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g @miraland-labs/conduit-bridge
|
|
30
|
+
conduit join --url <https://your-conduit> --organization <slug>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Detected IDEs (Cursor, VS Code, Codex) are diagnostics only. Execution drivers today: `claude-code` only.
|
package/dist/brief.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
const MANIFESTS = ["package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json", "Cargo.toml", "pyproject.toml", "go.mod", "Makefile"];
|
|
4
|
+
const VERIFICATION_SCRIPTS = ["verify", "typecheck", "lint", "test", "build"];
|
|
5
|
+
const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage"]);
|
|
6
|
+
export async function buildWorkspaceBrief(workspace) {
|
|
7
|
+
const entries = await readdir(workspace, { withFileTypes: true });
|
|
8
|
+
const modules = entries
|
|
9
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !EXCLUDED_DIRECTORIES.has(entry.name))
|
|
10
|
+
.map((entry) => entry.name).sort().slice(0, 30);
|
|
11
|
+
const files = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
|
|
12
|
+
const manifests = MANIFESTS.filter((name) => files.has(name));
|
|
13
|
+
return {
|
|
14
|
+
repository: await gitRemoteUrl(workspace),
|
|
15
|
+
base_commit: await gitHeadCommit(workspace),
|
|
16
|
+
modules,
|
|
17
|
+
manifests,
|
|
18
|
+
verification: await verificationCommands(workspace, files),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function normalizeRepositoryUrl(url) {
|
|
22
|
+
let value = url.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
|
|
23
|
+
const ssh = value.match(/^git@([^:]+):(.+)$/);
|
|
24
|
+
if (ssh)
|
|
25
|
+
value = `https://${ssh[1]}/${ssh[2]}`;
|
|
26
|
+
return value.replace(/^https?:\/\//, "").replace(/^ssh:\/\/(git@)?/, "");
|
|
27
|
+
}
|
|
28
|
+
async function gitRemoteUrl(workspace) {
|
|
29
|
+
try {
|
|
30
|
+
const { common } = await gitDirectories(workspace);
|
|
31
|
+
const config = await readFile(join(common, "config"), "utf8");
|
|
32
|
+
const remote = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
|
|
33
|
+
return remote?.[1] ?? null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function gitHeadCommit(workspace) {
|
|
40
|
+
try {
|
|
41
|
+
const { worktree, common } = await gitDirectories(workspace);
|
|
42
|
+
const head = (await readFile(join(worktree, "HEAD"), "utf8")).trim();
|
|
43
|
+
if (/^[0-9a-f]{40,64}$/i.test(head))
|
|
44
|
+
return head;
|
|
45
|
+
const ref = head.match(/^ref:\s*(\S+)$/)?.[1];
|
|
46
|
+
if (!ref)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
return (await readFile(join(common, ref), "utf8")).trim();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
const packed = await readFile(join(common, "packed-refs"), "utf8");
|
|
53
|
+
const line = packed.split("\n").find((entry) => entry.endsWith(` ${ref}`));
|
|
54
|
+
return line?.split(" ")[0] ?? null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function gitDirectories(workspace) {
|
|
62
|
+
const dotGit = join(workspace, ".git");
|
|
63
|
+
const info = await stat(dotGit);
|
|
64
|
+
const pointer = info.isDirectory() ? null : (await readFile(dotGit, "utf8")).trim().match(/^gitdir:\s*(.+)$/)?.[1];
|
|
65
|
+
if (!info.isDirectory() && !pointer)
|
|
66
|
+
throw new Error("Invalid Git worktree metadata");
|
|
67
|
+
const worktree = info.isDirectory() ? dotGit : resolve(workspace, pointer);
|
|
68
|
+
try {
|
|
69
|
+
return { worktree, common: resolve(worktree, (await readFile(join(worktree, "commondir"), "utf8")).trim()) };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { worktree, common: worktree };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function verificationCommands(workspace, files) {
|
|
76
|
+
if (!files.has("package.json"))
|
|
77
|
+
return [];
|
|
78
|
+
try {
|
|
79
|
+
const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
|
|
80
|
+
return VERIFICATION_SCRIPTS.filter((name) => manifest.scripts?.[name]).map((name) => `npm run ${name}`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { hostname, userInfo } from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { ConduitClient } from "./client.js";
|
|
7
|
+
import { clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, savePendingConnection, suggestMachineName, } from "./config.js";
|
|
8
|
+
import { runMcp } from "./mcp.js";
|
|
9
|
+
import { detectInstalledClients } from "./detect.js";
|
|
10
|
+
import { DRIVERS } from "./driver.js";
|
|
11
|
+
import { buildWorkspaceBrief } from "./brief.js";
|
|
12
|
+
import { executeNextAssignment, renewLeases } from "./execution.js";
|
|
13
|
+
import { installRunnerService, uninstallRunnerService } from "./service.js";
|
|
14
|
+
const [command] = process.argv.slice(2);
|
|
15
|
+
/** Install-free form shown in help/output so clean laptops never need `conduit` on PATH. */
|
|
16
|
+
const BRIDGE_NPX = "npx @miraland-labs/conduit-bridge";
|
|
17
|
+
function bridgeUsage(...args) {
|
|
18
|
+
return `${BRIDGE_NPX} ${args.join(" ")}`;
|
|
19
|
+
}
|
|
20
|
+
function parseFuelSource(value) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (value === "conduit" || value === "local")
|
|
24
|
+
return value;
|
|
25
|
+
throw new Error("Fuel source must be conduit or local");
|
|
26
|
+
}
|
|
27
|
+
async function connect() {
|
|
28
|
+
const { values } = parseArgs({ args: process.argv.slice(3), options: { url: { type: "string" }, code: { type: "string" }, fuel: { type: "string" } } });
|
|
29
|
+
if (!values.url || !values.code)
|
|
30
|
+
throw new Error(`Usage: ${bridgeUsage("connect", "--url", "<worker-url>", "--code", "<one-time-code>", "[--fuel conduit|local]")}`);
|
|
31
|
+
const parsedUrl = new URL(values.url);
|
|
32
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol))
|
|
33
|
+
throw new Error("Conduit URL must use http or https");
|
|
34
|
+
const baseUrl = parsedUrl.toString().replace(/\/+$/, "");
|
|
35
|
+
const response = await fetch(`${baseUrl}/runner/v1/bootstrap/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: values.code }) });
|
|
36
|
+
const data = await response.json();
|
|
37
|
+
if (!response.ok)
|
|
38
|
+
throw new Error(data.error?.message ?? `Connect failed (${response.status})`);
|
|
39
|
+
await finishConnection(baseUrl, data, parseFuelSource(values.fuel));
|
|
40
|
+
}
|
|
41
|
+
async function join() {
|
|
42
|
+
const { values } = parseArgs({ args: process.argv.slice(3), options: {
|
|
43
|
+
url: { type: "string" }, resume: { type: "boolean" }, machine: { type: "string" }, operator: { type: "string" },
|
|
44
|
+
organization: { type: "string" },
|
|
45
|
+
capability: { type: "string", multiple: true }, capacity: { type: "string" }, "no-open": { type: "boolean" },
|
|
46
|
+
fuel: { type: "string" },
|
|
47
|
+
} });
|
|
48
|
+
const fuelSource = parseFuelSource(values.fuel);
|
|
49
|
+
let pending = await loadPendingConnection();
|
|
50
|
+
if (pending && Date.now() >= new Date(pending.expiresAt).getTime()) {
|
|
51
|
+
await clearPendingConnection();
|
|
52
|
+
pending = null;
|
|
53
|
+
}
|
|
54
|
+
if (values.resume && !pending)
|
|
55
|
+
throw new Error("No pending connection request to resume");
|
|
56
|
+
if (pending && values.url && normalizeBaseUrl(values.url) !== pending.baseUrl) {
|
|
57
|
+
throw new Error(`A connection request for ${pending.baseUrl} is still pending. Run \`${bridgeUsage("join", "--resume")}\`.`);
|
|
58
|
+
}
|
|
59
|
+
if (pending && !values.resume)
|
|
60
|
+
console.log(`Resuming the pending connection request for ${pending.baseUrl}.`);
|
|
61
|
+
if (!pending) {
|
|
62
|
+
if (!values.url || !values.organization) {
|
|
63
|
+
throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "--organization", "<slug>", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
|
|
64
|
+
}
|
|
65
|
+
const baseUrl = normalizeBaseUrl(values.url);
|
|
66
|
+
const organization = values.organization.trim().toLowerCase();
|
|
67
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
|
|
68
|
+
throw new Error("Organization must be its lowercase workspace slug");
|
|
69
|
+
// A config that fails validation (for example pre-organization) must not
|
|
70
|
+
// block rejoining — join replaces it with a complete identity.
|
|
71
|
+
const connected = await loadConfigIfPresent().catch(() => null);
|
|
72
|
+
if (connected)
|
|
73
|
+
throw new Error(`This Bridge is already connected to ${connected.baseUrl} as ${connected.machineId}.`);
|
|
74
|
+
const installationId = await loadOrCreateInstallationId();
|
|
75
|
+
const detected = await detectInstalledClients();
|
|
76
|
+
const operatorName = values.operator?.trim() || userInfo().username;
|
|
77
|
+
const machineName = values.machine?.trim() || suggestMachineName(hostname(), installationId);
|
|
78
|
+
const capabilities = values.capability?.map((item) => item.trim()).filter(Boolean) ?? ["implement"];
|
|
79
|
+
const leaseCapacity = values.capacity ? Number(values.capacity) : 1;
|
|
80
|
+
console.log("\nRequesting a Conduit connection:");
|
|
81
|
+
console.log(`Operator: ${operatorName}`);
|
|
82
|
+
console.log(`Computer: ${machineName}`);
|
|
83
|
+
console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
|
|
84
|
+
console.log(`Proposed roles: ${capabilities.join(", ")}`);
|
|
85
|
+
console.log(`Assignments at once: ${leaseCapacity}`);
|
|
86
|
+
if (fuelSource)
|
|
87
|
+
console.log(`Fuel source: ${fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
|
|
88
|
+
console.log("Execution today requires Claude Code (`claude` on PATH). Other detected clients are diagnostics only.");
|
|
89
|
+
console.log("Use --machine <name> to override the computer label.\n");
|
|
90
|
+
const response = await fetch(`${baseUrl}/runner/v1/connect/requests`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "content-type": "application/json" },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
organization,
|
|
95
|
+
installation_id: installationId,
|
|
96
|
+
agent_name: operatorName,
|
|
97
|
+
machine_name: machineName,
|
|
98
|
+
detected_clients: detected,
|
|
99
|
+
capabilities,
|
|
100
|
+
lease_capacity: leaseCapacity,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
const data = await response.json();
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
throw new Error(data.error?.message ?? `Connection request failed (${response.status})`);
|
|
106
|
+
pending = {
|
|
107
|
+
baseUrl,
|
|
108
|
+
organization,
|
|
109
|
+
requestId: String(data.request_id),
|
|
110
|
+
secret: String(data.secret),
|
|
111
|
+
userCode: String(data.user_code),
|
|
112
|
+
verificationUrl: String(data.verification_url),
|
|
113
|
+
expiresAt: String(data.expires_at),
|
|
114
|
+
};
|
|
115
|
+
await savePendingConnection(pending);
|
|
116
|
+
}
|
|
117
|
+
console.log(`\nOpen ${pending.verificationUrl}`);
|
|
118
|
+
console.log(`Enter device code: ${pending.userCode}`);
|
|
119
|
+
console.log("Waiting for a project owner to approve. You may close this terminal and run `conduit join --resume` later.\n");
|
|
120
|
+
if (!values["no-open"])
|
|
121
|
+
openUrl(pending.verificationUrl);
|
|
122
|
+
await waitForConnection(pending, fuelSource);
|
|
123
|
+
}
|
|
124
|
+
function normalizeBaseUrl(value) {
|
|
125
|
+
const parsedUrl = new URL(value);
|
|
126
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol))
|
|
127
|
+
throw new Error("Conduit URL must use http or https");
|
|
128
|
+
return parsedUrl.toString().replace(/\/+$/, "");
|
|
129
|
+
}
|
|
130
|
+
async function waitForConnection(pending, fuelSource) {
|
|
131
|
+
let lastStatus = "";
|
|
132
|
+
while (Date.now() < new Date(pending.expiresAt).getTime()) {
|
|
133
|
+
const response = await fetch(`${pending.baseUrl}/runner/v1/connect/requests/${pending.requestId}/exchange`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "content-type": "application/json" },
|
|
136
|
+
body: JSON.stringify({ secret: pending.secret }),
|
|
137
|
+
});
|
|
138
|
+
const data = await response.json();
|
|
139
|
+
if (response.status === 202) {
|
|
140
|
+
const status = String(data.status ?? "pending");
|
|
141
|
+
if (status !== lastStatus)
|
|
142
|
+
console.log(status === "pending_claim" ? "Waiting for you to confirm the device code…" : "Waiting for project approval…");
|
|
143
|
+
lastStatus = status;
|
|
144
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, 3_000));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
if ([403, 409, 410].includes(response.status))
|
|
149
|
+
await clearPendingConnection();
|
|
150
|
+
throw new Error(data.error?.message ?? `Connection failed (${response.status})`);
|
|
151
|
+
}
|
|
152
|
+
await finishConnection(pending.baseUrl, data, fuelSource);
|
|
153
|
+
await clearPendingConnection();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
await clearPendingConnection();
|
|
157
|
+
throw new Error("Connection request expired. Run `conduit join --url <worker-url>` again.");
|
|
158
|
+
}
|
|
159
|
+
async function finishConnection(baseUrl, data, fuelSource) {
|
|
160
|
+
const fuelList = Array.isArray(data.fuel) ? data.fuel : [];
|
|
161
|
+
const fuel = {};
|
|
162
|
+
for (const row of fuelList) {
|
|
163
|
+
if (row.project_id && row.gateway_secret)
|
|
164
|
+
fuel[row.project_id] = { gatewayKey: row.gateway_secret };
|
|
165
|
+
}
|
|
166
|
+
const config = {
|
|
167
|
+
baseUrl,
|
|
168
|
+
organizationId: String(data.organization_id),
|
|
169
|
+
machineId: String(data.machine_id),
|
|
170
|
+
runnerKey: String(data.runner_secret),
|
|
171
|
+
capabilities: data.capabilities,
|
|
172
|
+
grants: data.grants,
|
|
173
|
+
leaseCapacity: Number(data.lease_capacity),
|
|
174
|
+
activeAttempts: {},
|
|
175
|
+
fuelSource: fuelSource ?? "conduit",
|
|
176
|
+
...(Object.keys(fuel).length ? { fuel } : {}),
|
|
177
|
+
};
|
|
178
|
+
await saveConfig(config);
|
|
179
|
+
const client = new ConduitClient(config);
|
|
180
|
+
let heartbeatOk = true;
|
|
181
|
+
try {
|
|
182
|
+
await heartbeat(client, config, null);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
heartbeatOk = false;
|
|
186
|
+
}
|
|
187
|
+
const detected = await detectInstalledClients();
|
|
188
|
+
console.log(`Connected machine: ${config.machineId}`);
|
|
189
|
+
console.log(heartbeatOk ? "Runner credential and heartbeat: OK" : "Runner credential saved; heartbeat will retry when the runner starts");
|
|
190
|
+
console.log(`Detected local clients: ${detected.join(", ") || "none"}`);
|
|
191
|
+
console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
|
|
192
|
+
console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
|
|
193
|
+
console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`);
|
|
194
|
+
console.log("Heartbeats report capabilities for diagnostics only; matching uses the Connect confirmation. Detected clients never receive grants automatically.");
|
|
195
|
+
console.log(`MCP setup: {"mcpServers":{"conduit":{"command":"npx","args":["-y","@miraland-labs/conduit-bridge","mcp"]}}}`);
|
|
196
|
+
console.log(`Execute work: ${bridgeUsage("runner", "--agent", "claude-code", "--workspace", "<repo>")}`);
|
|
197
|
+
console.log(`Keep on shift (macOS/Linux): ${bridgeUsage("install-service", "--agent", "claude-code", "--workspace", "<repo>")}`);
|
|
198
|
+
console.log(`Flip fuel later: ${bridgeUsage("fuel", "local|conduit")}`);
|
|
199
|
+
console.log("Windows: keep the runner terminal open — install-service is macOS/Linux only.");
|
|
200
|
+
if (Object.keys(fuel).length) {
|
|
201
|
+
console.log(`Fleet fueling: ${Object.keys(fuel).length} project key(s) configured for Conduit /v1`);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
console.log(`Gateway base URL (fuel keys provisioned on first assignment): ${baseUrl}/v1`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function openUrl(url) {
|
|
208
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
|
|
209
|
+
if (!command)
|
|
210
|
+
return;
|
|
211
|
+
try {
|
|
212
|
+
const child = spawn(command, [url], { detached: true, stdio: "ignore" });
|
|
213
|
+
child.on("error", () => undefined);
|
|
214
|
+
child.unref();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// The printed URL remains the reliable fallback on headless machines.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function installService() {
|
|
221
|
+
await loadConfig();
|
|
222
|
+
const { values } = parseArgs({ args: process.argv.slice(3), options: {
|
|
223
|
+
agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, "agent-timeout-minutes": { type: "string" },
|
|
224
|
+
} });
|
|
225
|
+
if (!values.agent || !values.workspace) {
|
|
226
|
+
throw new Error(`Usage: ${bridgeUsage("install-service", "--agent", "claude-code", "--workspace", "<repository-path>")} (agent + workspace required; heartbeat-only services are not supported)`);
|
|
227
|
+
}
|
|
228
|
+
if (!DRIVERS[values.agent]) {
|
|
229
|
+
throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
|
|
230
|
+
}
|
|
231
|
+
const result = await installRunnerService({
|
|
232
|
+
agent: values.agent,
|
|
233
|
+
workspace: resolve(values.workspace),
|
|
234
|
+
interval: values.interval,
|
|
235
|
+
agentTimeoutMinutes: values["agent-timeout-minutes"],
|
|
236
|
+
});
|
|
237
|
+
console.log(`Installed Conduit runner service (${result.platform}): ${result.path}`);
|
|
238
|
+
console.log("The runner keeps executing after this terminal closes. Laptop sleep still pauses work.");
|
|
239
|
+
}
|
|
240
|
+
async function uninstallService() {
|
|
241
|
+
const result = await uninstallRunnerService();
|
|
242
|
+
console.log(`Removed Conduit runner service (${result.platform}): ${result.path}`);
|
|
243
|
+
}
|
|
244
|
+
async function fuelCommand() {
|
|
245
|
+
const mode = process.argv[3];
|
|
246
|
+
if (mode !== "local" && mode !== "conduit") {
|
|
247
|
+
throw new Error(`Usage: ${bridgeUsage("fuel", "local|conduit")}`);
|
|
248
|
+
}
|
|
249
|
+
const config = await loadConfig();
|
|
250
|
+
config.fuelSource = mode;
|
|
251
|
+
await saveConfig(config);
|
|
252
|
+
const client = new ConduitClient(config);
|
|
253
|
+
await heartbeat(client, config, null);
|
|
254
|
+
console.log(`Fuel source set to ${mode === "local" ? "local subscription" : "Conduit pump"} and reported on heartbeat.`);
|
|
255
|
+
}
|
|
256
|
+
async function runner() {
|
|
257
|
+
const { values } = parseArgs({ args: process.argv.slice(3), options: {
|
|
258
|
+
agent: { type: "string" }, workspace: { type: "string" }, interval: { type: "string" }, once: { type: "boolean" },
|
|
259
|
+
"agent-timeout-minutes": { type: "string" }, fuel: { type: "string" },
|
|
260
|
+
} });
|
|
261
|
+
const config = await loadConfig();
|
|
262
|
+
const fuelOverride = parseFuelSource(values.fuel);
|
|
263
|
+
if (fuelOverride) {
|
|
264
|
+
config.fuelSource = fuelOverride;
|
|
265
|
+
await saveConfig(config);
|
|
266
|
+
}
|
|
267
|
+
const client = new ConduitClient(config);
|
|
268
|
+
let driver = null;
|
|
269
|
+
if (values.agent) {
|
|
270
|
+
driver = DRIVERS[values.agent] ?? null;
|
|
271
|
+
if (!driver)
|
|
272
|
+
throw new Error(`Unknown agent driver: ${values.agent}. Available: ${Object.keys(DRIVERS).join(", ")}`);
|
|
273
|
+
if (!values.workspace)
|
|
274
|
+
throw new Error("--workspace <repository-path> is required with --agent");
|
|
275
|
+
}
|
|
276
|
+
const workspace = values.workspace ? resolve(values.workspace) : null;
|
|
277
|
+
const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
|
|
278
|
+
const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
|
|
279
|
+
const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
|
|
280
|
+
const fuelLabel = config.fuelSource === "local" ? "local subscription" : "Conduit pump";
|
|
281
|
+
if (!driver || !workspace) {
|
|
282
|
+
console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", "claude-code", "--workspace", "<repo>")}`);
|
|
283
|
+
}
|
|
284
|
+
console.log(`Conduit runner connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
|
|
285
|
+
for (;;) {
|
|
286
|
+
let executed = false;
|
|
287
|
+
try {
|
|
288
|
+
await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
|
|
289
|
+
await renewLeases(client, config);
|
|
290
|
+
if (driver && workspace) {
|
|
291
|
+
executed = await executeNextAssignment(client, config, driver, workspace, brief, timeoutMs);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
console.error(`Runner cycle failed; retrying: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}`);
|
|
296
|
+
}
|
|
297
|
+
if (values.once && executed)
|
|
298
|
+
return;
|
|
299
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function heartbeat(client, config, brief) {
|
|
303
|
+
await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
|
|
304
|
+
status: "online",
|
|
305
|
+
capabilities: config.capabilities,
|
|
306
|
+
lease_capacity: config.leaseCapacity,
|
|
307
|
+
fuel_source: config.fuelSource === "local" ? "local" : "conduit",
|
|
308
|
+
...(brief ? { workspace_brief: brief } : {}),
|
|
309
|
+
}) });
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
if (command === "connect")
|
|
313
|
+
await connect();
|
|
314
|
+
else if (command === "join")
|
|
315
|
+
await join();
|
|
316
|
+
else if (command === "fuel")
|
|
317
|
+
await fuelCommand();
|
|
318
|
+
else if (command === "mcp")
|
|
319
|
+
await runMcp();
|
|
320
|
+
else if (command === "runner")
|
|
321
|
+
await runner();
|
|
322
|
+
else if (command === "install-service")
|
|
323
|
+
await installService();
|
|
324
|
+
else if (command === "uninstall-service")
|
|
325
|
+
await uninstallService();
|
|
326
|
+
else
|
|
327
|
+
throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|mcp|runner|install-service|uninstall-service>`);
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { saveConfig } from "./config.js";
|
|
2
|
+
export class ConduitRequestError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, status, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class ConduitClient {
|
|
12
|
+
config;
|
|
13
|
+
persist;
|
|
14
|
+
constructor(config, persist = saveConfig) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.persist = persist;
|
|
17
|
+
}
|
|
18
|
+
async request(path, init = {}) {
|
|
19
|
+
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
|
20
|
+
...init,
|
|
21
|
+
headers: { authorization: `Bearer ${this.config.runnerKey}`, "content-type": "application/json", ...init.headers },
|
|
22
|
+
});
|
|
23
|
+
const data = await response.json();
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
async claim(taskId, attemptId) {
|
|
29
|
+
const data = await this.request(`/runner/v1/tasks/${taskId}/claim`, { method: "POST", body: JSON.stringify({ attempt_id: attemptId, idempotency_key: `bridge:claim:${attemptId}` }) });
|
|
30
|
+
const active = { taskId, attemptId, leaseToken: String(data.lease_token), leaseExpiresAt: String(data.lease_expires_at), phase: "claimed" };
|
|
31
|
+
this.config.activeAttempts[taskId] = active;
|
|
32
|
+
await this.persist(this.config);
|
|
33
|
+
return { ...data, lease_token: "stored by Conduit Bridge" };
|
|
34
|
+
}
|
|
35
|
+
attempt(taskId) {
|
|
36
|
+
const active = this.config.activeAttempts[taskId];
|
|
37
|
+
if (!active)
|
|
38
|
+
throw new Error("Task has not been claimed by this Bridge");
|
|
39
|
+
return active;
|
|
40
|
+
}
|
|
41
|
+
async attemptRequest(taskId, action, body) {
|
|
42
|
+
const active = this.attempt(taskId);
|
|
43
|
+
return this.request(`/runner/v1/tasks/${taskId}/${action}`, { method: "POST", body: JSON.stringify({ ...body, attempt_id: active.attemptId, lease_token: active.leaseToken }) });
|
|
44
|
+
}
|
|
45
|
+
async updateAttempt(taskId, patch) {
|
|
46
|
+
const active = this.attempt(taskId);
|
|
47
|
+
Object.assign(active, patch);
|
|
48
|
+
await this.persist(this.config);
|
|
49
|
+
return active;
|
|
50
|
+
}
|
|
51
|
+
async clearAttempt(taskId) {
|
|
52
|
+
delete this.config.activeAttempts[taskId];
|
|
53
|
+
await this.persist(this.config);
|
|
54
|
+
}
|
|
55
|
+
/** Rotate and cache a project-scoped gateway fuel key for agent /v1 calls. */
|
|
56
|
+
async ensureFuel(projectId) {
|
|
57
|
+
const cached = this.config.fuel?.[projectId]?.gatewayKey;
|
|
58
|
+
if (cached)
|
|
59
|
+
return cached;
|
|
60
|
+
const data = await this.request(`/runner/v1/fuel/${projectId}`);
|
|
61
|
+
const gatewayKey = String(data.gateway_secret);
|
|
62
|
+
this.config.fuel = { ...this.config.fuel, [projectId]: { gatewayKey } };
|
|
63
|
+
await this.persist(this.config);
|
|
64
|
+
return gatewayKey;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const directory = join(homedir(), ".config", "conduit");
|
|
6
|
+
const path = join(directory, "config.json");
|
|
7
|
+
const pendingPath = join(directory, "pending-connect.json");
|
|
8
|
+
const installationPath = join(directory, "installation.json");
|
|
9
|
+
export async function loadConfig() {
|
|
10
|
+
const config = await loadConfigIfPresent();
|
|
11
|
+
if (!config)
|
|
12
|
+
throw new Error("Conduit is not connected. Run `conduit join --url <worker-url>` first.");
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
15
|
+
export async function loadConfigIfPresent() {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = JSON.parse(await readFile(path, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
// Fail closed on configs from before the organization retrofit: a Bridge
|
|
24
|
+
// without an organization identity must reconnect, not guess its tenant.
|
|
25
|
+
const config = raw;
|
|
26
|
+
const required = [
|
|
27
|
+
["baseUrl", config.baseUrl], ["organizationId", config.organizationId], ["machineId", config.machineId], ["runnerKey", config.runnerKey],
|
|
28
|
+
];
|
|
29
|
+
for (const [field, value] of required) {
|
|
30
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
31
|
+
throw new Error(`Bridge configuration is missing ${field}; run \`conduit join --url <worker-url>\` to reconnect this machine.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
config.activeAttempts ??= {};
|
|
35
|
+
for (const active of Object.values(config.activeAttempts))
|
|
36
|
+
active.phase ??= "agent_running";
|
|
37
|
+
if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
|
|
38
|
+
config.fuelSource = "conduit";
|
|
39
|
+
return config;
|
|
40
|
+
}
|
|
41
|
+
export async function saveConfig(config) {
|
|
42
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
43
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
44
|
+
await chmod(path, 0o600);
|
|
45
|
+
}
|
|
46
|
+
export async function loadPendingConnection() {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(await readFile(pendingPath, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function savePendingConnection(connection) {
|
|
55
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
56
|
+
await writeFile(pendingPath, `${JSON.stringify(connection, null, 2)}\n`, { mode: 0o600 });
|
|
57
|
+
await chmod(pendingPath, 0o600);
|
|
58
|
+
}
|
|
59
|
+
export async function clearPendingConnection() {
|
|
60
|
+
await unlink(pendingPath).catch((error) => {
|
|
61
|
+
if (error.code !== "ENOENT")
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export async function loadOrCreateInstallationId() {
|
|
66
|
+
try {
|
|
67
|
+
const identity = JSON.parse(await readFile(installationPath, "utf8"));
|
|
68
|
+
if (/^device_[A-Za-z0-9_-]{32}$/.test(identity.id))
|
|
69
|
+
return identity.id;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Create the local identity below.
|
|
73
|
+
}
|
|
74
|
+
const identity = { id: `device_${randomBytes(24).toString("base64url")}` };
|
|
75
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
76
|
+
await writeFile(installationPath, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
await chmod(installationPath, 0o600);
|
|
78
|
+
return identity.id;
|
|
79
|
+
}
|
|
80
|
+
export function suggestMachineName(host, installationId) {
|
|
81
|
+
const base = host.split(".")[0]?.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "computer";
|
|
82
|
+
const suffix = installationId.slice(-5).toLowerCase();
|
|
83
|
+
return `${base.slice(0, 199 - suffix.length)}-${suffix}`;
|
|
84
|
+
}
|
|
85
|
+
export function redactSecrets(value) {
|
|
86
|
+
return value.replace(/(runner_sk_|gateway_sk_|lease_|connect_)[A-Za-z0-9-]+/g, "$1[redacted]");
|
|
87
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
const CLIENTS = [
|
|
4
|
+
{ command: "codex", label: "Codex CLI" },
|
|
5
|
+
{ command: "claude", label: "Claude Code" },
|
|
6
|
+
{ command: "cursor", label: "Cursor" },
|
|
7
|
+
{ command: "code", label: "Visual Studio Code" },
|
|
8
|
+
];
|
|
9
|
+
export async function detectInstalledClients(pathValue = process.env.PATH ?? "", platform = process.platform) {
|
|
10
|
+
const directories = pathValue.split(delimiter).filter(Boolean);
|
|
11
|
+
const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
12
|
+
const detected = [];
|
|
13
|
+
for (const client of CLIENTS) {
|
|
14
|
+
const found = await firstExecutable(directories.flatMap((directory) => extensions.map((extension) => join(directory, `${client.command}${extension}`))));
|
|
15
|
+
if (found)
|
|
16
|
+
detected.push(client.label);
|
|
17
|
+
}
|
|
18
|
+
return detected;
|
|
19
|
+
}
|
|
20
|
+
async function firstExecutable(paths) {
|
|
21
|
+
for (const path of paths) {
|
|
22
|
+
try {
|
|
23
|
+
await access(path, constants.X_OK);
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Continue through PATH; absence is an expected diagnostic result.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|