@miraland-labs/conduit-bridge 0.14.9 → 0.16.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/dist/cli.js +6 -0
- package/dist/investigation.js +260 -0
- package/dist/preflight.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
|
|
|
20
20
|
import { pumpExecutionSlots, renewLeases } from "./execution.js";
|
|
21
21
|
import { installRunnerService, uninstallRunnerService } from "./service.js";
|
|
22
22
|
import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, unavailableWorkspacePreflight } from "./preflight.js";
|
|
23
|
+
import { executeNextInvestigation } from "./investigation.js";
|
|
23
24
|
import { bridgeVersion } from "./version.js";
|
|
24
25
|
const [command] = process.argv.slice(2);
|
|
25
26
|
/** Install-free form shown in help/output so clean laptops never need `conduit` on PATH. */
|
|
@@ -495,6 +496,11 @@ async function runner() {
|
|
|
495
496
|
process.exit(0);
|
|
496
497
|
}
|
|
497
498
|
await renewLeases(client, config);
|
|
499
|
+
if (workspace && onlineDriverIds(config).length) {
|
|
500
|
+
// Observation runs before authoring: an owner waiting on an answer should not queue behind
|
|
501
|
+
// a long delivery, and the control plane already counted this slot as busy.
|
|
502
|
+
progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
|
|
503
|
+
}
|
|
498
504
|
if (workspace && onlineDriverIds(config).length) {
|
|
499
505
|
progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
|
|
500
506
|
heartbeat: async () => {
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** Read-only investigation lane on Bridge (Slice 5).
|
|
2
|
+
*
|
|
3
|
+
* An investigation answers one question against one pinned commit and returns a bounded brief. It
|
|
4
|
+
* is not an assignment: no delivery, no pull request, no evidence packet, no task state. The run is
|
|
5
|
+
* held to repo_read + test_run by the same driver projection that enforces read-only diagnosis, and
|
|
6
|
+
* the worktree it reads is deleted whichever way the run ends.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
|
|
10
|
+
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
|
|
11
|
+
import { redactSecrets } from "./config.js";
|
|
12
|
+
import { DRIVERS, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
|
|
13
|
+
import { pickDriverForClaim, supportsReadOnlyDiagnosis } from "./drivers.js";
|
|
14
|
+
import { changedPathsSince } from "./execution.js";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const budgetSchema = z.object({
|
|
19
|
+
max_files: z.number().int().min(1).max(24).optional().default(8),
|
|
20
|
+
max_duration_ms: z.number().int().min(10_000).max(15 * 60_000).optional().default(5 * 60_000),
|
|
21
|
+
}).passthrough();
|
|
22
|
+
export const investigationAssignmentSchema = z.object({
|
|
23
|
+
id: z.string().uuid(),
|
|
24
|
+
kind: z.literal("repository"),
|
|
25
|
+
question: z.string().min(1).max(4_000),
|
|
26
|
+
grants: z.array(z.string()).default([]),
|
|
27
|
+
budget: budgetSchema.optional().default({}),
|
|
28
|
+
repository_fingerprint: z.string().nullable().optional().default(null),
|
|
29
|
+
commit: z.string().nullable().optional().default(null),
|
|
30
|
+
source_attempt_id: z.string().uuid().nullable().optional().default(null),
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* Enforcement is a vendor's claim; verification is ours. Both run on every investigation — this only
|
|
34
|
+
* records which enforcement, if any, was also in force. Lanes with no read-only mode still qualify:
|
|
35
|
+
* the run happens in a disposable worktree with no remote credential, and it is checked afterwards.
|
|
36
|
+
*/
|
|
37
|
+
export function boundedByForDriver(driverId) {
|
|
38
|
+
if (driverId === "codex")
|
|
39
|
+
return "os_sandbox";
|
|
40
|
+
if (driverId === "claude-code" || driverId === "cursor")
|
|
41
|
+
return "tool_allowlist";
|
|
42
|
+
return "observed_clean";
|
|
43
|
+
}
|
|
44
|
+
/** Mirrors the control plane's bounded result contract; anything else is refused before settlement. */
|
|
45
|
+
const investigationBriefSchema = z.object({
|
|
46
|
+
summary: z.string().trim().min(1).max(4_000),
|
|
47
|
+
findings: z.array(z.string().trim().min(1).max(1_000)).max(12).default([]),
|
|
48
|
+
verification: z.array(z.string().trim().min(1).max(1_000)).max(8).default([]),
|
|
49
|
+
limitations: z.array(z.string().trim().min(1).max(1_000)).max(8).default([]),
|
|
50
|
+
files_read: z.number().int().min(0).max(24).default(0),
|
|
51
|
+
bytes_read: z.number().int().min(0).max(512 * 1024).default(0),
|
|
52
|
+
commands_run: z.array(z.string().trim().min(1).max(200)).max(10).default([]),
|
|
53
|
+
});
|
|
54
|
+
export function buildInvestigationPrompt(assignment, commit) {
|
|
55
|
+
return [
|
|
56
|
+
"You are Conductor's read-only investigation lane. Answer exactly one question about this repository at one pinned commit.",
|
|
57
|
+
"You have repo_read and test_run only. Do not edit, create, delete, format, commit, branch, push, install, or change any file.",
|
|
58
|
+
"Read the actual code and run only bounded verification commands that already exist in the project. Never start a server, deploy, or call a production system.",
|
|
59
|
+
"Ground every finding in files and symbols you actually read. If the answer cannot be settled at this commit, say so in limitations rather than guessing.",
|
|
60
|
+
"Do not quote secrets, credentials, tokens, or file bodies. Name paths and symbols instead.",
|
|
61
|
+
"",
|
|
62
|
+
`PINNED COMMIT ${commit}`,
|
|
63
|
+
`QUESTION\n${assignment.question}`,
|
|
64
|
+
`BUDGET\nAt most ${assignment.budget.max_files} files.`,
|
|
65
|
+
"",
|
|
66
|
+
"Return only one fenced ```json object with exactly this shape:",
|
|
67
|
+
'{"summary":"direct answer to the question","findings":["grounded fact with file/symbol"],"verification":["command you actually ran"],"limitations":["what you could not settle"],"files_read":0,"bytes_read":0,"commands_run":["command you actually ran"]}',
|
|
68
|
+
"summary is a string; the other list fields are arrays of strings. Close the fence and add no prose after it.",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
/** One message for every unusable answer, so the retry decision reads it rather than guessing. */
|
|
72
|
+
export const UNUSABLE_BRIEF = "Investigation returned no usable brief";
|
|
73
|
+
export function parseInvestigationBrief(text) {
|
|
74
|
+
let candidate;
|
|
75
|
+
// A missing fence and a malformed body are the same outcome to the owner: no answer.
|
|
76
|
+
try {
|
|
77
|
+
candidate = parseJsonObjectCandidate(extractAgentReportJsonText(text));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error(UNUSABLE_BRIEF);
|
|
81
|
+
}
|
|
82
|
+
const parsed = investigationBriefSchema.safeParse(candidate);
|
|
83
|
+
if (!parsed.success)
|
|
84
|
+
throw new Error(UNUSABLE_BRIEF);
|
|
85
|
+
return parsed.data;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Identity of the source workspace: HEAD plus its dirty set. Compared before and after the run so an
|
|
89
|
+
* observer that reached outside its disposable worktree is caught, not assumed away.
|
|
90
|
+
*/
|
|
91
|
+
async function workspaceFingerprint(workspace) {
|
|
92
|
+
try {
|
|
93
|
+
const head = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], { timeout: 30_000 });
|
|
94
|
+
const dirty = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], { timeout: 30_000, maxBuffer: 8_000_000 });
|
|
95
|
+
return `${head.stdout.trim()}\n${dirty.stdout.trim()}`;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Prove the run only looked. A write inside the disposable worktree is discarded anyway, but it
|
|
103
|
+
* means the answer came from a tree that no longer matches the pinned commit — so the brief is not
|
|
104
|
+
* evidence about that commit and must not be settled as if it were.
|
|
105
|
+
*/
|
|
106
|
+
export async function verifyObservationOnly(input) {
|
|
107
|
+
const changed = await changedPathsSince(input.attemptWorkspace, input.commit);
|
|
108
|
+
// Git could not answer. An unverified read-only claim is not a read-only claim.
|
|
109
|
+
if (changed === null)
|
|
110
|
+
return { ok: false, error: "observer_unverifiable", detail: "the worktree could not be compared with the pinned commit" };
|
|
111
|
+
if (changed.length) {
|
|
112
|
+
return { ok: false, error: "observer_wrote", detail: `${changed.length} path(s) changed under the pinned commit, starting with ${changed.slice(0, 3).join(", ")}` };
|
|
113
|
+
}
|
|
114
|
+
const after = await workspaceFingerprint(input.workspace);
|
|
115
|
+
if (input.fingerprintBefore === null || after === null) {
|
|
116
|
+
return { ok: false, error: "observer_unverifiable", detail: "the source workspace could not be fingerprinted" };
|
|
117
|
+
}
|
|
118
|
+
if (after !== input.fingerprintBefore) {
|
|
119
|
+
return { ok: false, error: "observer_wrote", detail: "the source workspace changed during the run" };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
async function settle(client, investigationId, body) {
|
|
124
|
+
await client.request(`/runner/v1/investigations/${investigationId}/settle`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Poll, claim and run one investigation. Returns true when one was executed, so the caller's cycle
|
|
131
|
+
* knows it made progress.
|
|
132
|
+
*/
|
|
133
|
+
export async function executeNextInvestigation(client, config, workspace, brief, timeoutMs) {
|
|
134
|
+
const data = await client.request("/runner/v1/investigations");
|
|
135
|
+
const assignments = z.array(investigationAssignmentSchema).parse(data.investigations ?? []);
|
|
136
|
+
const assignment = assignments[0];
|
|
137
|
+
if (!assignment)
|
|
138
|
+
return false;
|
|
139
|
+
// Prefer a lane that enforces read-only, but do not require one. Enforcement is a vendor claim;
|
|
140
|
+
// the guarantee this lane actually rests on is a disposable worktree plus verification afterwards,
|
|
141
|
+
// which works the same on every agent — including ones that ship no read-only mode at all.
|
|
142
|
+
const driverId = pickDriverForClaim(config, null, supportsReadOnlyDiagnosis)
|
|
143
|
+
?? pickDriverForClaim(config, null);
|
|
144
|
+
const driver = driverId ? DRIVERS[driverId] : null;
|
|
145
|
+
if (!driver) {
|
|
146
|
+
console.error(`Investigation ${assignment.id} skipped: no online lane on this computer.`);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
150
|
+
const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
|
|
151
|
+
const commit = assignment.commit;
|
|
152
|
+
const leaseToken = `${crypto.randomUUID()}${crypto.randomUUID()}`;
|
|
153
|
+
if (!commit) {
|
|
154
|
+
await claimThenSettle(client, assignment.id, leaseToken, {
|
|
155
|
+
lease_token: leaseToken, status: "failed", error: "investigation_commit_missing", retryable: false,
|
|
156
|
+
});
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (assignment.repository_fingerprint && liveRepository !== assignment.repository_fingerprint) {
|
|
160
|
+
await claimThenSettle(client, assignment.id, leaseToken, {
|
|
161
|
+
lease_token: leaseToken, status: "failed", error: "workspace_repository_mismatch", retryable: true,
|
|
162
|
+
});
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
if (!(await ensureCommitAvailable(workspace, commit).catch(() => false))) {
|
|
166
|
+
await claimThenSettle(client, assignment.id, leaseToken, {
|
|
167
|
+
lease_token: leaseToken, status: "failed", error: "commit_not_available", retryable: true,
|
|
168
|
+
});
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
await client.request(`/runner/v1/investigations/${assignment.id}/claim`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
body: JSON.stringify({ lease_token: leaseToken }),
|
|
174
|
+
});
|
|
175
|
+
console.log(`Claiming investigation ${assignment.id} at ${commit} on ${driverId ?? "the default lane"}`);
|
|
176
|
+
const fingerprintBefore = await workspaceFingerprint(workspace);
|
|
177
|
+
let attemptWorkspace = null;
|
|
178
|
+
try {
|
|
179
|
+
attemptWorkspace = await createAttemptWorktree({
|
|
180
|
+
sourceWorkspace: workspace,
|
|
181
|
+
attemptId: assignment.id,
|
|
182
|
+
startCommit: commit,
|
|
183
|
+
});
|
|
184
|
+
const result = await driver.run({
|
|
185
|
+
prompt: buildInvestigationPrompt(assignment, commit),
|
|
186
|
+
workspace: attemptWorkspace,
|
|
187
|
+
grants: assignment.grants,
|
|
188
|
+
verificationCommands: liveBrief?.verification ?? [],
|
|
189
|
+
deliverable: "repository",
|
|
190
|
+
// The same signal the read-only diagnosis lane uses; it is what denies edits in the driver.
|
|
191
|
+
workRole: "diagnose",
|
|
192
|
+
executionClass: "verify",
|
|
193
|
+
timeoutMs: Math.min(timeoutMs ?? assignment.budget.max_duration_ms, assignment.budget.max_duration_ms),
|
|
194
|
+
});
|
|
195
|
+
if (result.status === "failed") {
|
|
196
|
+
const message = result.error ?? "Investigation run failed";
|
|
197
|
+
await settle(client, assignment.id, { lease_token: leaseToken, status: "failed", error: redactSecrets(message).slice(0, 2_000), retryable: true });
|
|
198
|
+
console.error(`Investigation ${assignment.id} failed: ${redactSecrets(message)}`);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
const observed = parseInvestigationBrief(result.resultText ?? "");
|
|
202
|
+
const check = await verifyObservationOnly({ attemptWorkspace, workspace, commit, fingerprintBefore });
|
|
203
|
+
if (!check.ok) {
|
|
204
|
+
// The answer may be perfectly good, but it is no longer an answer about the pinned commit.
|
|
205
|
+
// Report what happened rather than settling a brief whose ground moved under it.
|
|
206
|
+
await settle(client, assignment.id, {
|
|
207
|
+
lease_token: leaseToken, status: "failed", error: `${check.error}: ${check.detail}`.slice(0, 2_000), retryable: true,
|
|
208
|
+
});
|
|
209
|
+
console.error(`Investigation ${assignment.id} discarded — ${check.error}: ${check.detail}`);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
await settle(client, assignment.id, {
|
|
213
|
+
lease_token: leaseToken,
|
|
214
|
+
status: "completed",
|
|
215
|
+
result: {
|
|
216
|
+
summary: observed.summary,
|
|
217
|
+
findings: observed.findings,
|
|
218
|
+
verification: observed.verification,
|
|
219
|
+
limitations: observed.limitations,
|
|
220
|
+
witness: {
|
|
221
|
+
repository_fingerprint: assignment.repository_fingerprint ?? liveRepository ?? "unknown",
|
|
222
|
+
commit,
|
|
223
|
+
observed_at: new Date().toISOString(),
|
|
224
|
+
bounded_by: boundedByForDriver(driverId),
|
|
225
|
+
files_read: observed.files_read,
|
|
226
|
+
bytes_read: observed.bytes_read,
|
|
227
|
+
commands_run: observed.commands_run,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
console.log(`Investigation ${assignment.id} settled with a bounded brief.`);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
const message = error instanceof Error ? error.message : "investigation_failed";
|
|
235
|
+
await settle(client, assignment.id, {
|
|
236
|
+
lease_token: leaseToken,
|
|
237
|
+
status: "failed",
|
|
238
|
+
error: redactSecrets(message).slice(0, 2_000),
|
|
239
|
+
// A malformed brief is worth one more try; a broken worktree is not fixed by repeating it.
|
|
240
|
+
retryable: message === UNUSABLE_BRIEF,
|
|
241
|
+
}).catch((settleError) => console.error(`Investigation settle failed: ${redactSecrets(settleError instanceof Error ? settleError.message : "unknown")}`));
|
|
242
|
+
console.error(`Investigation ${assignment.id} failed: ${redactSecrets(message)}`);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
if (attemptWorkspace) {
|
|
246
|
+
await removeAttemptWorktree(workspace, attemptWorkspace)
|
|
247
|
+
.catch((error) => console.error(`Investigation worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
/** Claim only to report why the run cannot start; the control plane needs the reason, not silence. */
|
|
253
|
+
async function claimThenSettle(client, investigationId, leaseToken, body) {
|
|
254
|
+
await client.request(`/runner/v1/investigations/${investigationId}/claim`, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
body: JSON.stringify({ lease_token: leaseToken }),
|
|
257
|
+
});
|
|
258
|
+
await settle(client, investigationId, body);
|
|
259
|
+
console.error(`Investigation ${investigationId} could not start: ${body.error}`);
|
|
260
|
+
}
|
package/dist/preflight.js
CHANGED
|
@@ -18,7 +18,7 @@ function modelsFingerprint(config) {
|
|
|
18
18
|
}
|
|
19
19
|
// Protocol 5 makes the lease-token claim contract explicit. Older Bridges may report a green
|
|
20
20
|
// preflight but cannot claim against the current control plane, so they must be admitted as stale.
|
|
21
|
-
export const BRIDGE_PROTOCOL_VERSION =
|
|
21
|
+
export const BRIDGE_PROTOCOL_VERSION = 7;
|
|
22
22
|
const execFileAsync = promisify(execFile);
|
|
23
23
|
async function defaultCommandRunner(command, args, cwd) {
|
|
24
24
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|