@miraland-labs/conduit-bridge 0.16.173 → 0.16.180
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 +2 -2
- package/dist/cli.js +3 -1
- package/dist/driver.js +252 -4
- package/dist/ensure-test-evidence.js +29 -4
- package/dist/execution.js +116 -47
- package/dist/investigation.js +24 -16
- package/dist/ops.js +3 -0
- package/dist/service.js +2 -1
- package/package.json +1 -1
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.16.125
|
|
5
|
+
**Package version:** `0.16.176`. Earlier (0.16.125) — the workspace brief reports `declared_demonstration` so the planner knows whether the repository can show itself (docs/historical/KERNEL_HISTORY_2026-09.md §51). Earlier (0.16.124) — when a project declares `.conduit/demonstrate`, Bridge runs that bounded command at the delivered head and attaches a stamped `preview` row (docs/historical/KERNEL_HISTORY_2026-09.md §47). Earlier (0.16.95) — Conduit-fuel lanes bind organization aliases (T144), the Anthropic base URL is the origin (T148), the investigation lane resolves its model (T154); a hosted container image lives under `hosted/` (docs/HOSTED_RUNNER.md). Earlier (0.16.27) — work-package execution budgets reach every agent turn, are bounded by the machine ceiling, and expose their effective source for operations. Local-fuel provider refusals make the affected lane unavailable instead of repeatedly spending attempts; an operator who changes the lane's account or plan can invalidate that observation with `ops quota-clear <driver>`. Bridge discovers bounded repository verification commands, including Make targets `test`, `check`, `verify`, `replay`, `typecheck`, `lint`, and `build`. `ops disconnect` asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `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
|
|
|
@@ -84,7 +84,7 @@ persisted and excludes only that lane; `online`/`offline` does not erase it. `op
|
|
|
84
84
|
installation and authentication, not remaining vendor allowance. After deliberately changing an
|
|
85
85
|
account or plan, run `ops quota-clear <driver>` to clear the old observation; this does not probe the
|
|
86
86
|
provider, and the next real claim records another refusal if the new allowance is still unavailable.
|
|
87
|
-
Fix the named issue and let a fresh heartbeat land before choosing **Recheck** in
|
|
87
|
+
Fix the named issue and let a fresh heartbeat land before choosing **Recheck** in Tasks.
|
|
88
88
|
|
|
89
89
|
Conduit labels an exact driver/CLI version **Certified** only after two consecutive real canaries:
|
|
90
90
|
repository delivery with PR/evidence and a successful rework cycle. All other lanes are
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { assignmentPollBehindServer, ConduitClient, ConduitRequestError } from "
|
|
|
12
12
|
import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, saveConfig, saveConfigPrefs, saveDriverQuota, savePendingConnection, suggestMachineName, } from "./config.js";
|
|
13
13
|
import { runMcp } from "./mcp.js";
|
|
14
14
|
import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
|
|
15
|
-
import { DRIVERS, MODEL_TIERS } from "./driver.js";
|
|
15
|
+
import { DRIVERS, MODEL_TIERS, probeUntrustedCodeSandbox } from "./driver.js";
|
|
16
16
|
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriverModels, setDriversOnline, formatLaneTiers, laneDispatchBlock, laneStatuses, } from "./drivers.js";
|
|
17
17
|
import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
|
|
18
18
|
import { parseAgentTimeoutMinutes, parseMaxTurns, resolveMaxTurns } from "./execution-budget.js";
|
|
@@ -826,6 +826,7 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
|
|
|
826
826
|
// heartbeat.
|
|
827
827
|
const preflightToSend = {
|
|
828
828
|
...preflight,
|
|
829
|
+
untrusted_code_isolation: await probeUntrustedCodeSandbox(),
|
|
829
830
|
...(assignmentPollBehindServer() ? { runner_behind_server: true } : {}),
|
|
830
831
|
...(process.env.CONDUIT_RUNNER_SERVICE === "container" ? { runner_service: "container" } : {}),
|
|
831
832
|
...(workspacePath && reportedGateRehearsals.length ? { gate_rehearsals: reportedGateRehearsals } : {}),
|
|
@@ -890,6 +891,7 @@ async function disconnect() {
|
|
|
890
891
|
console.log(`Tip: stop a background runner with \`${bridgeUsage("uninstall-service")}\` if one is installed.`);
|
|
891
892
|
}
|
|
892
893
|
try {
|
|
894
|
+
await probeUntrustedCodeSandbox();
|
|
893
895
|
if (command === "connect")
|
|
894
896
|
await connect();
|
|
895
897
|
else if (command === "join")
|
package/dist/driver.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { boundedTail } from "./ensure-test-evidence.js";
|
|
3
3
|
import { resolveAgentTimeout, resolveMaxTurns } from "./execution-budget.js";
|
|
4
4
|
import { agentExitSignal, turnCapSignal, vendorKindForAntigravity } from "./failure-signal.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { constants, existsSync } from "node:fs";
|
|
11
|
+
import { access, mkdir, mkdtemp, readFile, readdir, realpath, stat, rm, rmdir, writeFile } from "node:fs/promises";
|
|
12
|
+
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
8
13
|
import { z } from "zod";
|
|
9
14
|
import { allowsExternalFetch, deniedCommands, executionClassPromptRules, isRunnableVerificationCommand, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
|
|
10
15
|
/**
|
|
@@ -1994,6 +1999,249 @@ export function boundedEnvironment(fuel, fuelSource = "conduit", extraEnv) {
|
|
|
1994
1999
|
Object.assign(env, extraEnv);
|
|
1995
2000
|
return env;
|
|
1996
2001
|
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Gates and probes inherit build caches, never runner credentials or proxy settings.
|
|
2004
|
+
* This environment is not an OS sandbox: direct network routes and same-uid filesystem access
|
|
2005
|
+
* remain available until the hosted-runner sandbox lands. CARGO_HOME is an accepted shared cache.
|
|
2006
|
+
*/
|
|
2007
|
+
export function untrustedCodeEnvironment(scratchHome) {
|
|
2008
|
+
const allowed = ["PATH", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE", "TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR",
|
|
2009
|
+
"CARGO_HOME", "RUSTUP_HOME", "CARGO_TARGET_DIR", "CARGO_INCREMENTAL",
|
|
2010
|
+
...Object.keys(process.env).filter((name) => name.startsWith("LC_") || name.startsWith("CARGO_PROFILE_"))];
|
|
2011
|
+
return {
|
|
2012
|
+
...Object.fromEntries(allowed.flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]]])),
|
|
2013
|
+
HOME: scratchHome,
|
|
2014
|
+
XDG_CONFIG_HOME: join(scratchHome, "config"),
|
|
2015
|
+
XDG_CACHE_HOME: join(scratchHome, "cache"),
|
|
2016
|
+
XDG_DATA_HOME: join(scratchHome, "data"),
|
|
2017
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
2018
|
+
GIT_ASKPASS: "",
|
|
2019
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
2020
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
2021
|
+
GIT_SSH_COMMAND: "ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=yes -o IdentityAgent=none -i /dev/null",
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
/** Pure spawn projection; parent mounts precede their writable children. */
|
|
2025
|
+
export function buildUntrustedCodeSpawn(input, host) {
|
|
2026
|
+
const direct = { command: input.command, argv: input.argv, cwd: input.cwd, env: input.env };
|
|
2027
|
+
if (host.os_sandbox === "none")
|
|
2028
|
+
return direct;
|
|
2029
|
+
const writable = [...new Set([...input.writable, ...(host.worktreeMetadata ? [host.worktreeMetadata] : [])])]
|
|
2030
|
+
.sort((a, b) => a.split("/").length - b.split("/").length);
|
|
2031
|
+
const hidden = host.sourceRepository ? join(host.sourceRepository, ".conduit") : undefined;
|
|
2032
|
+
const rootWorkspace = input.cwd === host.sourceRepository;
|
|
2033
|
+
if (host.os_sandbox === "bwrap") {
|
|
2034
|
+
const argv = ["--die-with-parent", "--new-session", "--unshare-net", "--unshare-pid", "--unshare-ipc", "--unshare-uts",
|
|
2035
|
+
"--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc",
|
|
2036
|
+
...["/tmp", "/run", "/home", "/root"].flatMap((path) => ["--tmpfs", path]),
|
|
2037
|
+
...(host.sourceRepository ? ["--ro-bind", host.sourceRepository, host.sourceRepository] : []),
|
|
2038
|
+
...(host.toolchainRead ?? []).flatMap((path) => ["--ro-bind", path, path]),
|
|
2039
|
+
...(rootWorkspace ? ["--bind", input.cwd, input.cwd] : []),
|
|
2040
|
+
...(hidden ? ["--tmpfs", hidden] : []),
|
|
2041
|
+
...writable.filter((path) => !rootWorkspace || path !== input.cwd).flatMap((path) => ["--bind", path, path]), "--chdir", input.cwd, "--clearenv",
|
|
2042
|
+
...Object.entries(input.env).flatMap(([key, value]) => value === undefined ? [] : ["--setenv", key, value]),
|
|
2043
|
+
"--", input.command, ...input.argv];
|
|
2044
|
+
return { ...direct, command: host.executable ?? "/usr/bin/bwrap", argv };
|
|
2045
|
+
}
|
|
2046
|
+
const subpaths = (paths) => [...new Set(paths)].map((path) => {
|
|
2047
|
+
const subpath = `(subpath ${JSON.stringify(path)})`;
|
|
2048
|
+
return hidden && hidden.startsWith(`${path}/`)
|
|
2049
|
+
? `(require-all ${subpath} (require-not (subpath ${JSON.stringify(hidden)})))` : subpath;
|
|
2050
|
+
}).join(" ");
|
|
2051
|
+
const readPaths = [...writable, ...(host.sourceRepository ? [host.sourceRepository] : []),
|
|
2052
|
+
"/usr", "/bin", "/opt/homebrew", "/Library", "/System", "/private/tmp", "/private/var/folders", ...(host.toolchainRead ?? [])];
|
|
2053
|
+
const ancestors = new Set();
|
|
2054
|
+
for (const path of readPaths) {
|
|
2055
|
+
for (let parent = dirname(path);; parent = dirname(parent)) {
|
|
2056
|
+
ancestors.add(parent);
|
|
2057
|
+
if (parent === dirname(parent))
|
|
2058
|
+
break;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
const metadata = [...ancestors].map((path) => `(literal ${JSON.stringify(path)})`).join(" ");
|
|
2062
|
+
const profile = `(version 1) (allow default) (deny network*) (deny file-write*)
|
|
2063
|
+
(allow file-write* ${subpaths(writable)}) (allow file-write-data (literal "/dev/null"))
|
|
2064
|
+
(deny file-read* (subpath ${JSON.stringify(host.home)}) ${hidden ? `(subpath ${JSON.stringify(hidden)})` : ""})
|
|
2065
|
+
(allow file-read* ${subpaths(readPaths)}) (allow file-read-metadata ${metadata})`;
|
|
2066
|
+
return { ...direct, command: host.executable ?? "/usr/bin/sandbox-exec", argv: ["-p", profile, "--", input.command, ...input.argv] };
|
|
2067
|
+
}
|
|
2068
|
+
/** Resolve the worktree's own Git administration area without granting a sibling's metadata. */
|
|
2069
|
+
async function sandboxRepositoryPaths(cwd) {
|
|
2070
|
+
try {
|
|
2071
|
+
const pointer = await readFile(join(cwd, ".git"), "utf8");
|
|
2072
|
+
const match = /^gitdir: (.+)\s*$/.exec(pointer.trim());
|
|
2073
|
+
if (!match)
|
|
2074
|
+
return {};
|
|
2075
|
+
const metadata = await realpath(resolve(cwd, match[1]));
|
|
2076
|
+
const common = await realpath(resolve(metadata, (await readFile(join(metadata, "commondir"), "utf8")).trim()));
|
|
2077
|
+
const backlink = await realpath((await readFile(join(metadata, "gitdir"), "utf8")).trim());
|
|
2078
|
+
if (dirname(metadata) !== join(common, "worktrees") || backlink !== join(cwd, ".git"))
|
|
2079
|
+
return {};
|
|
2080
|
+
return { sourceRepository: dirname(common), worktreeMetadata: metadata };
|
|
2081
|
+
}
|
|
2082
|
+
catch {
|
|
2083
|
+
if (await stat(join(cwd, ".git")).then((entry) => entry.isDirectory(), () => false))
|
|
2084
|
+
return { sourceRepository: cwd };
|
|
2085
|
+
return {};
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
/** A delivered instrument's nested runs stay inside its already isolated scratch directory. */
|
|
2089
|
+
export function untrustedCodeTempRoot(workspace) {
|
|
2090
|
+
return process.env.HOME && process.env.TMPDIR === join(process.env.HOME, "tmp")
|
|
2091
|
+
? process.env.TMPDIR : join(workspace, ".conduit", "tmp");
|
|
2092
|
+
}
|
|
2093
|
+
async function sandboxPathDirectories(env) {
|
|
2094
|
+
const home = await realpath(homedir());
|
|
2095
|
+
const directories = new Set();
|
|
2096
|
+
for (const path of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
2097
|
+
try {
|
|
2098
|
+
const canonical = await realpath(path);
|
|
2099
|
+
if (canonical !== home && !home.startsWith(`${canonical}/`) && (await stat(canonical)).isDirectory())
|
|
2100
|
+
directories.add(canonical);
|
|
2101
|
+
}
|
|
2102
|
+
catch { /* A missing PATH entry must not stop execvp inside the read-restricted sandbox. */ }
|
|
2103
|
+
}
|
|
2104
|
+
return [...directories];
|
|
2105
|
+
}
|
|
2106
|
+
/** Existing PATH directories and Bridge's installed runtime, never an enclosing HOME. */
|
|
2107
|
+
async function sandboxToolchainPaths(env) {
|
|
2108
|
+
const home = await realpath(homedir());
|
|
2109
|
+
const paths = new Set();
|
|
2110
|
+
const add = async (path) => {
|
|
2111
|
+
try {
|
|
2112
|
+
const canonical = await realpath(path);
|
|
2113
|
+
if (canonical === home || home.startsWith(`${canonical}/`))
|
|
2114
|
+
return;
|
|
2115
|
+
if ((await stat(canonical)).isDirectory())
|
|
2116
|
+
paths.add(canonical);
|
|
2117
|
+
}
|
|
2118
|
+
catch { /* Absent PATH entries are not toolchain directories. */ }
|
|
2119
|
+
};
|
|
2120
|
+
const visited = new Set();
|
|
2121
|
+
for (const path of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
2122
|
+
await add(path);
|
|
2123
|
+
try {
|
|
2124
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
2125
|
+
if (!entry.isSymbolicLink())
|
|
2126
|
+
continue;
|
|
2127
|
+
const target = await realpath(join(path, entry.name));
|
|
2128
|
+
if (!(await stat(target)).isFile())
|
|
2129
|
+
continue;
|
|
2130
|
+
// An executable symlink can need its package's lib/ beside bin/, not the enclosing nvm tree.
|
|
2131
|
+
for (let parent = dirname(target); parent !== home && basename(parent) !== ".nvm"; parent = dirname(parent)) {
|
|
2132
|
+
if (visited.has(parent) || parent === dirname(parent))
|
|
2133
|
+
break;
|
|
2134
|
+
visited.add(parent);
|
|
2135
|
+
if (existsSync(join(parent, "package.json"))) {
|
|
2136
|
+
await add(parent);
|
|
2137
|
+
break;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
catch { /* An unreadable or vanished PATH entry contributes no extra authority. */ }
|
|
2143
|
+
}
|
|
2144
|
+
await add(fileURLToPath(new URL("..", import.meta.url)));
|
|
2145
|
+
// The loader and its dependencies are part of Bridge's installed runtime tree.
|
|
2146
|
+
try {
|
|
2147
|
+
const loader = await realpath(createRequire(import.meta.url).resolve("tsx"));
|
|
2148
|
+
const index = loader.lastIndexOf("/node_modules/");
|
|
2149
|
+
if (index >= 0)
|
|
2150
|
+
await add(loader.slice(0, index + "/node_modules".length));
|
|
2151
|
+
}
|
|
2152
|
+
catch { /* An absent loader is reported by the existing probe command. */ }
|
|
2153
|
+
return [...paths];
|
|
2154
|
+
}
|
|
2155
|
+
/** Same CR15 environment on unsupported hosts; isolated temp and mounts on supported hosts. */
|
|
2156
|
+
export async function untrustedCodeSpawn(input) {
|
|
2157
|
+
const sandbox = await probeUntrustedCodeSandbox();
|
|
2158
|
+
if (sandbox.os_sandbox === "none")
|
|
2159
|
+
return input;
|
|
2160
|
+
const cwd = await realpath(input.cwd);
|
|
2161
|
+
const candidates = input.command.includes("/") ? [resolve(cwd, input.command)]
|
|
2162
|
+
: (input.env.PATH ?? "/usr/bin:/bin").split(":").map((path) => resolve(cwd, path, input.command));
|
|
2163
|
+
let command;
|
|
2164
|
+
let unavailable = "ENOENT";
|
|
2165
|
+
for (const path of candidates) {
|
|
2166
|
+
// Keep the command's own name: a rustup proxy such as `cargo -> rustup` dispatches on argv[0].
|
|
2167
|
+
try {
|
|
2168
|
+
await access(path, constants.X_OK);
|
|
2169
|
+
command = join(await realpath(dirname(path)), basename(path));
|
|
2170
|
+
break;
|
|
2171
|
+
}
|
|
2172
|
+
catch (error) {
|
|
2173
|
+
if (error.code === "EACCES")
|
|
2174
|
+
unavailable = "EACCES";
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if (!command)
|
|
2178
|
+
throw Object.assign(new Error(`spawn ${input.command} ${unavailable}`), { code: unavailable });
|
|
2179
|
+
if (!input.env.HOME)
|
|
2180
|
+
throw new Error("Untrusted code requires a scratch HOME");
|
|
2181
|
+
const home = await realpath(input.env.HOME);
|
|
2182
|
+
const temp = join(home, "tmp");
|
|
2183
|
+
await mkdir(temp, { recursive: true });
|
|
2184
|
+
const env = { ...input.env, PATH: (await sandboxPathDirectories(input.env)).join(delimiter), HOME: home, XDG_CONFIG_HOME: join(home, "config"),
|
|
2185
|
+
XDG_CACHE_HOME: join(home, "cache"), XDG_DATA_HOME: join(home, "data"), TMPDIR: temp, TMP: temp, TEMP: temp };
|
|
2186
|
+
const writable = [cwd, home, temp];
|
|
2187
|
+
for (const key of ["CARGO_TARGET_DIR", "CARGO_HOME"]) {
|
|
2188
|
+
const path = input.env[key];
|
|
2189
|
+
if (!path)
|
|
2190
|
+
continue;
|
|
2191
|
+
await mkdir(path, { recursive: true });
|
|
2192
|
+
writable.push(await realpath(path));
|
|
2193
|
+
}
|
|
2194
|
+
const toolchainRead = await sandboxToolchainPaths(env);
|
|
2195
|
+
// Installed toolchains are read like PATH directories; a gate never rewrites rustc for the next attempt.
|
|
2196
|
+
if (input.env.RUSTUP_HOME && existsSync(input.env.RUSTUP_HOME))
|
|
2197
|
+
toolchainRead.push(await realpath(input.env.RUSTUP_HOME));
|
|
2198
|
+
return buildUntrustedCodeSpawn({ ...input, command, cwd, env, writable }, {
|
|
2199
|
+
...sandbox, home: await realpath(homedir()), toolchainRead, ...await sandboxRepositoryPaths(cwd),
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
const sandboxExecFile = promisify(execFile);
|
|
2203
|
+
let sandboxProbe;
|
|
2204
|
+
/** One non-spending probe per process; a missing or unusable primitive is a reported host fact. */
|
|
2205
|
+
export function probeUntrustedCodeSandbox() {
|
|
2206
|
+
return sandboxProbe ??= probeHostSandbox();
|
|
2207
|
+
}
|
|
2208
|
+
async function probeHostSandbox() {
|
|
2209
|
+
const os_sandbox = process.platform === "linux" ? "bwrap" : process.platform === "darwin" ? "seatbelt" : "none";
|
|
2210
|
+
const executable = os_sandbox === "bwrap" ? "/usr/bin/bwrap" : "/usr/bin/sandbox-exec";
|
|
2211
|
+
if (os_sandbox === "none" || !existsSync(executable))
|
|
2212
|
+
return { os_sandbox: "none", reason: "No supported OS sandbox executable is installed" };
|
|
2213
|
+
let scratch;
|
|
2214
|
+
const server = createServer((socket) => socket.end());
|
|
2215
|
+
try {
|
|
2216
|
+
scratch = await realpath(await mkdtemp(join(tmpdir(), "conduit-sandbox-probe-")));
|
|
2217
|
+
const host = { os_sandbox, executable, reason: "", home: await realpath(homedir()),
|
|
2218
|
+
toolchainRead: await sandboxToolchainPaths(process.env) };
|
|
2219
|
+
const env = untrustedCodeEnvironment(scratch);
|
|
2220
|
+
const run = async (command, argv) => {
|
|
2221
|
+
const spawn = buildUntrustedCodeSpawn({ command, argv, cwd: scratch, env, writable: [scratch] }, host);
|
|
2222
|
+
await sandboxExecFile(spawn.command, spawn.argv, { cwd: spawn.cwd, env: spawn.env, timeout: 5_000, maxBuffer: 4_000 });
|
|
2223
|
+
};
|
|
2224
|
+
await run("/usr/bin/true", []);
|
|
2225
|
+
// A live listener is the positive control: a closed port would fail even without isolation.
|
|
2226
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
2227
|
+
const address = server.address();
|
|
2228
|
+
if (!address || typeof address === "string")
|
|
2229
|
+
throw new Error("Sandbox network probe has no listener");
|
|
2230
|
+
await run(process.execPath, ["-e", `const s=require('node:net').connect(${address.port},'127.0.0.1');s.on('connect',()=>{s.destroy();process.exit(1)});s.on('error',()=>process.exit(0));s.setTimeout(2000,()=>{s.destroy();process.exit(0)});`]);
|
|
2231
|
+
return { os_sandbox, reason: "OS sandbox executed a child and blocked the network probe" };
|
|
2232
|
+
}
|
|
2233
|
+
catch (error) {
|
|
2234
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2235
|
+
return { os_sandbox: "none", reason: detail.split("\n").find((line) => /bwrap:|sandbox-exec:/.test(line))?.trim().slice(0, 500)
|
|
2236
|
+
?? detail.split("\n")[0].slice(0, 500) };
|
|
2237
|
+
}
|
|
2238
|
+
finally {
|
|
2239
|
+
if (server.listening)
|
|
2240
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
2241
|
+
if (scratch)
|
|
2242
|
+
await rm(scratch, { recursive: true, force: true });
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
1997
2245
|
/** The `/v1` a fuelled agent is pointed at — and the one its model names are validated against. */
|
|
1998
2246
|
export function fuelEndpoint(baseUrl) {
|
|
1999
2247
|
return `${baseUrl.replace(/\/+$/, "")}/v1`;
|
|
@@ -4,8 +4,20 @@
|
|
|
4
4
|
* it never substitutes for it.
|
|
5
5
|
*/
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { untrustedCodeTempRoot, untrustedCodeSpawn, untrustedCodeEnvironment, criterionKey, resolveCriterionReference, signalGroup, STDIO_DRAIN_MS, CHILD_STOP_GRACE_MS, OWN_PROCESS_GROUP, trackProcessGroup, untrackProcessGroup } from "./driver.js";
|
|
8
10
|
import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
|
|
11
|
+
import { workspaceDependenciesMissingFailure } from "./failure-signal.js";
|
|
12
|
+
/** A typed spawn refusal, distinct from a command that ran and failed. */
|
|
13
|
+
export class VerificationCommandUnavailableError extends Error {
|
|
14
|
+
failure;
|
|
15
|
+
constructor(command, code, detail) {
|
|
16
|
+
super(`Verification command could not start: ${command} (${code}): ${detail}`);
|
|
17
|
+
this.name = "VerificationCommandUnavailableError";
|
|
18
|
+
this.failure = workspaceDependenciesMissingFailure(this.message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
9
21
|
const TEST_EVIDENCE_DETAILS_MIN = 32;
|
|
10
22
|
/** Cold `cargo test` in an attempt worktree routinely exceeds two minutes of compile. */
|
|
11
23
|
export const VERIFICATION_TIMEOUT_MS = 600_000;
|
|
@@ -437,8 +449,19 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
|
|
|
437
449
|
partial[stream].append(parts.at(-1));
|
|
438
450
|
};
|
|
439
451
|
const output = () => [lines.text().replace(/\n$/, ""), partial.stdout.text(), partial.stderr.text()].filter(Boolean).join("\n");
|
|
452
|
+
const tempRoot = untrustedCodeTempRoot(workspace);
|
|
453
|
+
await mkdir(tempRoot, { recursive: true });
|
|
454
|
+
const scratchHome = await mkdtemp(join(tempRoot, "gate-home-"));
|
|
455
|
+
const commandSpawn = await untrustedCodeSpawn({ command: bin, argv: argv.slice(1), cwd: workspace, env: untrustedCodeEnvironment(scratchHome) })
|
|
456
|
+
.catch(async (error) => {
|
|
457
|
+
await rm(scratchHome, { recursive: true, force: true });
|
|
458
|
+
const unavailable = error;
|
|
459
|
+
if (typeof unavailable.code === "string")
|
|
460
|
+
throw new VerificationCommandUnavailableError(command, unavailable.code, unavailable.message);
|
|
461
|
+
throw error;
|
|
462
|
+
});
|
|
440
463
|
return new Promise((resolve, reject) => {
|
|
441
|
-
const child = spawn(
|
|
464
|
+
const child = spawn(commandSpawn.command, commandSpawn.argv, { cwd: commandSpawn.cwd, env: commandSpawn.env,
|
|
442
465
|
stdio: ["ignore", "pipe", "pipe"], detached: OWN_PROCESS_GROUP });
|
|
443
466
|
const group = OWN_PROCESS_GROUP ? child.pid : undefined;
|
|
444
467
|
if (group !== undefined)
|
|
@@ -494,7 +517,7 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
|
|
|
494
517
|
if (typeof error.code === "string") {
|
|
495
518
|
settled = true;
|
|
496
519
|
cleanup();
|
|
497
|
-
reject(error);
|
|
520
|
+
reject(new VerificationCommandUnavailableError(command, error.code, error.message));
|
|
498
521
|
}
|
|
499
522
|
else {
|
|
500
523
|
if (!stderr.text())
|
|
@@ -507,7 +530,7 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
|
|
|
507
530
|
clearTimeout(timer);
|
|
508
531
|
drainTimer = setTimeout(() => settle(code), STDIO_DRAIN_MS);
|
|
509
532
|
});
|
|
510
|
-
});
|
|
533
|
+
}).finally(() => rm(scratchHome, { recursive: true, force: true }));
|
|
511
534
|
}
|
|
512
535
|
/**
|
|
513
536
|
* Bridge may attach genuine command output; it must not invent which criteria that output proves.
|
|
@@ -744,6 +767,8 @@ export async function ensureTestEvidence(input) {
|
|
|
744
767
|
witnessed.push({
|
|
745
768
|
kind: "test",
|
|
746
769
|
name: command,
|
|
770
|
+
command,
|
|
771
|
+
exit_code: result.code,
|
|
747
772
|
details,
|
|
748
773
|
acceptance_criteria: criteriaForTestEvidence(input.report, command, acceptance),
|
|
749
774
|
witness: "bridge",
|
package/dist/execution.js
CHANGED
|
@@ -31,7 +31,7 @@ import { agentClaimsRepositoryWork, claimsVsGitMismatch, isLandTreeEmpty, report
|
|
|
31
31
|
const execFileAsync = promisify(execFile);
|
|
32
32
|
const deliveryReportSchemaPath = fileURLToPath(new URL("../schemas/delivery-report.schema.json", import.meta.url));
|
|
33
33
|
const repairBriefSchemaPath = fileURLToPath(new URL("../schemas/repair-brief.schema.json", import.meta.url));
|
|
34
|
-
import { boundedTail, captureVerificationFailure, DeliveryReportDefectError, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationFailedError, VerificationGateRanNothingError, VerificationGateTimedOutError, VERIFICATION_TIMEOUT_MS } from "./ensure-test-evidence.js";
|
|
34
|
+
import { boundedTail, captureVerificationFailure, DeliveryReportDefectError, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationCommandUnavailableError, VerificationFailedError, VerificationGateRanNothingError, VerificationGateTimedOutError, VERIFICATION_TIMEOUT_MS } from "./ensure-test-evidence.js";
|
|
35
35
|
import { ensureChangeEvidence } from "./ensure-change-evidence.js";
|
|
36
36
|
import { ensureDemonstrationEvidence } from "./ensure-demonstration-evidence.js";
|
|
37
37
|
import { ensureNormativeEvidence } from "./ensure-normative-evidence.js";
|
|
@@ -1100,6 +1100,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1100
1100
|
// of the failed code would make the durable retry mechanically impossible.
|
|
1101
1101
|
let retainAttemptWorktree = diagnosis;
|
|
1102
1102
|
const reportVerificationGateTimeout = async (input) => {
|
|
1103
|
+
retainAttemptWorktree = true;
|
|
1103
1104
|
const response = await queueTerminal(client, taskId, { action: "fail", body: {
|
|
1104
1105
|
error: input.detail,
|
|
1105
1106
|
retryable: false,
|
|
@@ -1107,12 +1108,13 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1107
1108
|
...(input.pullRequestUrl ? { pull_request_url: input.pullRequestUrl } : {}),
|
|
1108
1109
|
idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
|
|
1109
1110
|
} });
|
|
1110
|
-
retainAttemptWorktree = response
|
|
1111
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1111
1112
|
console.error(`Assignment ${taskId} verification gate did not finish: ${redactSecrets(input.detail)}`);
|
|
1112
1113
|
};
|
|
1113
1114
|
if (diagnosis) {
|
|
1114
1115
|
if (!sourceAttemptId) {
|
|
1115
|
-
|
|
1116
|
+
retainAttemptWorktree = true;
|
|
1117
|
+
const response = await queueTerminal(client, taskId, {
|
|
1116
1118
|
action: "fail",
|
|
1117
1119
|
body: {
|
|
1118
1120
|
failure: {
|
|
@@ -1129,11 +1131,13 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1129
1131
|
idempotency_key: `bridge:diagnosis-source-missing:${active.attemptId}`,
|
|
1130
1132
|
},
|
|
1131
1133
|
});
|
|
1134
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1132
1135
|
return;
|
|
1133
1136
|
}
|
|
1134
1137
|
const retained = options.existingWorktree ?? attemptWorktreePath(workspace, sourceAttemptId);
|
|
1135
1138
|
if (!(await proveResumeWorktree(workspace, sourceAttemptId, retained))) {
|
|
1136
|
-
|
|
1139
|
+
retainAttemptWorktree = true;
|
|
1140
|
+
const response = await queueTerminal(client, taskId, {
|
|
1137
1141
|
action: "fail",
|
|
1138
1142
|
body: {
|
|
1139
1143
|
failure: {
|
|
@@ -1150,6 +1154,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1150
1154
|
idempotency_key: `bridge:diagnosis-worktree-missing:${active.attemptId}`,
|
|
1151
1155
|
},
|
|
1152
1156
|
});
|
|
1157
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1153
1158
|
return;
|
|
1154
1159
|
}
|
|
1155
1160
|
attemptWorkspace = retained;
|
|
@@ -1161,13 +1166,17 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1161
1166
|
}
|
|
1162
1167
|
else {
|
|
1163
1168
|
if (!startCommit) {
|
|
1164
|
-
|
|
1169
|
+
retainAttemptWorktree = true;
|
|
1170
|
+
const response = await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, failure: bridgeCause("agent_execution_failed", "No start commit for attempt worktree"), idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
|
|
1171
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1165
1172
|
return;
|
|
1166
1173
|
}
|
|
1167
1174
|
if (executionContract.requested_base_commit) {
|
|
1168
1175
|
const resolved = await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, startCommit);
|
|
1169
1176
|
if (!resolved) {
|
|
1170
|
-
|
|
1177
|
+
retainAttemptWorktree = true;
|
|
1178
|
+
const response = await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, failure: bridgeCause("base_not_ancestor", "Required base commit is not available in this workspace"), idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
|
|
1179
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1171
1180
|
return;
|
|
1172
1181
|
}
|
|
1173
1182
|
worktreeStart = resolved;
|
|
@@ -1175,7 +1184,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1175
1184
|
else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
|
|
1176
1185
|
const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
|
|
1177
1186
|
if (!reachable) {
|
|
1178
|
-
|
|
1187
|
+
retainAttemptWorktree = true;
|
|
1188
|
+
const response = await queueTerminal(client, taskId, { action: "fail", body: { error: "Delivered head is not available in this workspace", retryable: true, failure: bridgeCause("agent_execution_failed", "Delivered head is not available in this workspace"), idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
|
|
1189
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1179
1190
|
console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
|
|
1180
1191
|
return;
|
|
1181
1192
|
}
|
|
@@ -1192,12 +1203,19 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1192
1203
|
const message = code === "source_workspace_dirty"
|
|
1193
1204
|
? "Source workspace has uncommitted changes; refuse to start an isolated attempt"
|
|
1194
1205
|
: `Unable to create attempt worktree: ${code}`;
|
|
1195
|
-
|
|
1206
|
+
retainAttemptWorktree = true;
|
|
1207
|
+
const response = await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, ...(code === "source_workspace_dirty" ? { failure: bridgeCause("workspace_dirty", message) } : {}), idempotency_key: `bridge:worktree:${active.attemptId}:${code}` } });
|
|
1208
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1196
1209
|
console.error(`Assignment ${taskId} worktree create failed: ${redactSecrets(message)}`);
|
|
1197
1210
|
return;
|
|
1198
1211
|
}
|
|
1199
1212
|
await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
|
|
1200
1213
|
}
|
|
1214
|
+
const removeReleasedPreflightWorktree = async () => {
|
|
1215
|
+
if (!retainAttemptWorktree) {
|
|
1216
|
+
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1201
1219
|
// T136: a first revision's witness starts at the commit the attempt worktree actually checked
|
|
1202
1220
|
// out. When main has moved past the requested base, that resolved start is the claimed head;
|
|
1203
1221
|
// diffing from the requested base would report commits that predate the agent as its work.
|
|
@@ -1235,7 +1253,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1235
1253
|
const message = error instanceof Error ? error.message : String(error);
|
|
1236
1254
|
const retryable = message.startsWith(NORMATIVE_REF_PREFIX)
|
|
1237
1255
|
&& /could not clone|could not checkout|timed out|Could not resolve|connection/i.test(message);
|
|
1238
|
-
|
|
1256
|
+
retainAttemptWorktree = true;
|
|
1257
|
+
const response = await queueTerminal(client, taskId, {
|
|
1239
1258
|
action: "fail",
|
|
1240
1259
|
body: {
|
|
1241
1260
|
error: message,
|
|
@@ -1246,6 +1265,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1246
1265
|
idempotency_key: `bridge:normative-ref:${active.attemptId}`,
|
|
1247
1266
|
},
|
|
1248
1267
|
});
|
|
1268
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1269
|
+
await removeReleasedPreflightWorktree();
|
|
1249
1270
|
console.error(`Assignment ${taskId} normative reference materialization failed: ${redactSecrets(message)}`);
|
|
1250
1271
|
return;
|
|
1251
1272
|
}
|
|
@@ -1257,11 +1278,6 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1257
1278
|
// pins the attempt's verification_commands_digest to the tree the gates actually ran in, so a
|
|
1258
1279
|
// later refresh of the source checkout cannot invalidate a running attempt.
|
|
1259
1280
|
const attemptBrief = await buildWorkspaceBrief(attemptWorkspace).catch(() => liveBrief);
|
|
1260
|
-
const removeReleasedDiagnosticWorktree = async (response) => {
|
|
1261
|
-
if (diagnosis && !retainDiagnosticWorktree(response)) {
|
|
1262
|
-
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1263
|
-
}
|
|
1264
|
-
};
|
|
1265
1281
|
// Stamped class is the source of truth (Bridge 0.11+). Do not re-derive from grants.
|
|
1266
1282
|
let executionClass;
|
|
1267
1283
|
try {
|
|
@@ -1272,21 +1288,26 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1272
1288
|
console.error(`Assignment ${taskId}: ${detail}`);
|
|
1273
1289
|
// Non-retryable: control plane restamps on dispatch/claim. Infinite retry here burned path-4
|
|
1274
1290
|
// attempt budget when a pre-inversion task row still lacked the stamp.
|
|
1291
|
+
retainAttemptWorktree = true;
|
|
1275
1292
|
const response = await queueTerminal(client, taskId, {
|
|
1276
1293
|
action: "fail",
|
|
1277
1294
|
body: { error: detail, retryable: false, failure: bridgeCause("execution_unclassified", detail), idempotency_key: `bridge:fail:${active.attemptId}:missing-class` },
|
|
1278
1295
|
});
|
|
1279
|
-
|
|
1296
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1297
|
+
await removeReleasedPreflightWorktree();
|
|
1280
1298
|
return;
|
|
1281
1299
|
}
|
|
1282
1300
|
// An artifact deliverable that is not publish_artifact cannot write/publish — fail closed.
|
|
1283
1301
|
if (artifactDelivery && executionClass !== "publish_artifact") {
|
|
1284
1302
|
const detail = `Artifact deliverable stamped as execution class ${executionClass}, which cannot write or publish it. Revise the plan to use publish_artifact.`;
|
|
1285
1303
|
console.error(`Assignment ${taskId}: ${detail}`);
|
|
1286
|
-
|
|
1304
|
+
retainAttemptWorktree = true;
|
|
1305
|
+
const response = await queueTerminal(client, taskId, {
|
|
1287
1306
|
action: "fail",
|
|
1288
1307
|
body: { error: detail, retryable: false, failure: bridgeCause("execution_unclassified", detail), idempotency_key: `bridge:fail:${active.attemptId}:artifact-class` },
|
|
1289
1308
|
});
|
|
1309
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1310
|
+
await removeReleasedPreflightWorktree();
|
|
1290
1311
|
return;
|
|
1291
1312
|
}
|
|
1292
1313
|
// Full classFloor must survive project/machine ceilings (observe/verify need repo_read too).
|
|
@@ -1294,16 +1315,19 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1294
1315
|
if (!floor.ok) {
|
|
1295
1316
|
const detail = `Class floor unmet: ${floor.reason}`;
|
|
1296
1317
|
console.error(`Assignment ${taskId}: ${detail}`);
|
|
1318
|
+
retainAttemptWorktree = true;
|
|
1297
1319
|
const response = await queueTerminal(client, taskId, {
|
|
1298
1320
|
action: "fail",
|
|
1299
1321
|
body: { error: detail, retryable: false, failure: bridgeCause("execution_unclassified", detail), idempotency_key: `bridge:fail:${active.attemptId}:class-floor` },
|
|
1300
1322
|
});
|
|
1301
|
-
|
|
1323
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1324
|
+
await removeReleasedPreflightWorktree();
|
|
1302
1325
|
return;
|
|
1303
1326
|
}
|
|
1304
1327
|
const builtInDriverId = Object.entries(DRIVERS).find(([, candidate]) => candidate === driver)?.[0] ?? null;
|
|
1305
1328
|
if (diagnosis && builtInDriverId && !supportsReadOnlyDiagnosis(builtInDriverId)) {
|
|
1306
1329
|
const detail = `${driver.name} cannot combine bounded test execution with enforced read-only repository access.`;
|
|
1330
|
+
retainAttemptWorktree = true;
|
|
1307
1331
|
const response = await queueTerminal(client, taskId, {
|
|
1308
1332
|
action: "fail",
|
|
1309
1333
|
body: {
|
|
@@ -1321,12 +1345,14 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1321
1345
|
idempotency_key: `bridge:diagnosis-driver-authority:${active.attemptId}`,
|
|
1322
1346
|
},
|
|
1323
1347
|
});
|
|
1324
|
-
|
|
1348
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1349
|
+
await removeReleasedPreflightWorktree();
|
|
1325
1350
|
console.error(`Assignment ${taskId} diagnosis held: ${detail}`);
|
|
1326
1351
|
return;
|
|
1327
1352
|
}
|
|
1328
1353
|
const diagnosticFailure = workPackage?.diagnostic_context?.failure_output;
|
|
1329
1354
|
if (diagnosis && !diagnosticFailure) {
|
|
1355
|
+
retainAttemptWorktree = true;
|
|
1330
1356
|
const response = await queueTerminal(client, taskId, {
|
|
1331
1357
|
action: "fail",
|
|
1332
1358
|
body: {
|
|
@@ -1336,7 +1362,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1336
1362
|
idempotency_key: `bridge:diagnosis-context-missing:${active.attemptId}`,
|
|
1337
1363
|
},
|
|
1338
1364
|
});
|
|
1339
|
-
|
|
1365
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1366
|
+
await removeReleasedPreflightWorktree();
|
|
1340
1367
|
return;
|
|
1341
1368
|
}
|
|
1342
1369
|
const evidenceCommands = verificationEvidenceCommands(attemptBrief?.verification ?? [], attemptBrief?.declared_verification ?? [], spec.acceptance ?? []);
|
|
@@ -1347,6 +1374,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1347
1374
|
// reads the same missing_verification Hold.
|
|
1348
1375
|
if (executionKind === "authoring" && needsTestEvidence(spec, grants) && !evidenceCommands.length) {
|
|
1349
1376
|
const detail = missingVerificationDetail(worktreeStart);
|
|
1377
|
+
retainAttemptWorktree = true;
|
|
1350
1378
|
const response = await queueTerminal(client, taskId, {
|
|
1351
1379
|
action: "fail",
|
|
1352
1380
|
body: {
|
|
@@ -1356,9 +1384,10 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1356
1384
|
idempotency_key: `bridge:missing-verification:${active.attemptId}`,
|
|
1357
1385
|
},
|
|
1358
1386
|
});
|
|
1387
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1359
1388
|
// A fresh tree stands at the base commit and no agent ran, so it holds no diagnostic value. A
|
|
1360
1389
|
// resumed tree holds the agent's own work and is kept.
|
|
1361
|
-
if (!options.existingWorktree &&
|
|
1390
|
+
if (!options.existingWorktree && !retainAttemptWorktree) {
|
|
1362
1391
|
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1363
1392
|
}
|
|
1364
1393
|
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
@@ -1382,7 +1411,22 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1382
1411
|
declaredVerificationCommands: attemptBrief?.declared_verification ?? [],
|
|
1383
1412
|
acceptance: spec.acceptance ?? [],
|
|
1384
1413
|
changeScope: spec.change_scope ?? [],
|
|
1414
|
+
}).catch(async (error) => {
|
|
1415
|
+
if (!(error instanceof VerificationCommandUnavailableError))
|
|
1416
|
+
throw error;
|
|
1417
|
+
retainAttemptWorktree = true;
|
|
1418
|
+
const response = await queueTerminal(client, taskId, { action: "fail", body: {
|
|
1419
|
+
error: error.message, retryable: false, failure: error.failure,
|
|
1420
|
+
idempotency_key: `bridge:verification-command-unavailable:${active.attemptId}`,
|
|
1421
|
+
} });
|
|
1422
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1423
|
+
if (!retainAttemptWorktree)
|
|
1424
|
+
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1425
|
+
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(error.message)}`);
|
|
1426
|
+
return null;
|
|
1385
1427
|
});
|
|
1428
|
+
if (!dryRun)
|
|
1429
|
+
return;
|
|
1386
1430
|
if (dryRun.ranNothing) {
|
|
1387
1431
|
const { command, exitCode, reason } = dryRun.ranNothing;
|
|
1388
1432
|
const detail = `Verification gate ran nothing on the base commit ${worktreeStart}: ${command} (${reason}, exit ${exitCode})`;
|
|
@@ -1397,6 +1441,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1397
1441
|
exit_code: exitCode,
|
|
1398
1442
|
vendor_message: reason,
|
|
1399
1443
|
};
|
|
1444
|
+
retainAttemptWorktree = true;
|
|
1400
1445
|
const response = await queueTerminal(client, taskId, {
|
|
1401
1446
|
action: "fail",
|
|
1402
1447
|
body: {
|
|
@@ -1406,8 +1451,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1406
1451
|
idempotency_key: `bridge:verification-gate-ran-nothing:${active.attemptId}`,
|
|
1407
1452
|
},
|
|
1408
1453
|
});
|
|
1454
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1409
1455
|
// No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
|
|
1410
|
-
if (
|
|
1456
|
+
if (!retainAttemptWorktree) {
|
|
1411
1457
|
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1412
1458
|
}
|
|
1413
1459
|
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
@@ -1416,6 +1462,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1416
1462
|
if (dryRun.timedOut) {
|
|
1417
1463
|
const { command, stderrTail, stdoutTail } = dryRun.timedOut;
|
|
1418
1464
|
const detail = `Verification gate did not finish on the base commit ${worktreeStart}: ${command} (stopped after ${Math.round(VERIFICATION_TIMEOUT_MS / 60_000)} minutes)`;
|
|
1465
|
+
retainAttemptWorktree = true;
|
|
1419
1466
|
const response = await queueTerminal(client, taskId, {
|
|
1420
1467
|
action: "fail",
|
|
1421
1468
|
body: {
|
|
@@ -1425,8 +1472,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1425
1472
|
idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
|
|
1426
1473
|
},
|
|
1427
1474
|
});
|
|
1475
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1428
1476
|
// No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
|
|
1429
|
-
if (
|
|
1477
|
+
if (!retainAttemptWorktree) {
|
|
1430
1478
|
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1431
1479
|
}
|
|
1432
1480
|
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
@@ -1435,6 +1483,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1435
1483
|
if (dryRun.redBase) {
|
|
1436
1484
|
const { command, exitCode, stderrTail, stdoutTail } = dryRun.redBase;
|
|
1437
1485
|
const detail = `Verification fails on the base commit ${worktreeStart}: ${command} (exit ${exitCode ?? "unknown"})`;
|
|
1486
|
+
retainAttemptWorktree = true;
|
|
1438
1487
|
const response = await queueTerminal(client, taskId, {
|
|
1439
1488
|
action: "fail",
|
|
1440
1489
|
body: {
|
|
@@ -1444,8 +1493,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1444
1493
|
idempotency_key: `bridge:verification-red-base:${active.attemptId}`,
|
|
1445
1494
|
},
|
|
1446
1495
|
});
|
|
1496
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1447
1497
|
// No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
|
|
1448
|
-
if (
|
|
1498
|
+
if (!retainAttemptWorktree) {
|
|
1449
1499
|
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1450
1500
|
}
|
|
1451
1501
|
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
@@ -1453,6 +1503,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1453
1503
|
}
|
|
1454
1504
|
if (dryRun.outsideScope.length) {
|
|
1455
1505
|
const detail = verificationScopeConflictDetail(dryRun.outsideScope, spec.change_scope ?? []);
|
|
1506
|
+
retainAttemptWorktree = true;
|
|
1456
1507
|
const response = await queueTerminal(client, taskId, {
|
|
1457
1508
|
action: "fail",
|
|
1458
1509
|
body: {
|
|
@@ -1462,9 +1513,10 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1462
1513
|
idempotency_key: `bridge:verification-scope-conflict:${active.attemptId}`,
|
|
1463
1514
|
},
|
|
1464
1515
|
});
|
|
1516
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1465
1517
|
// No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
|
|
1466
1518
|
// Keep it only when the control plane says it still needs it.
|
|
1467
|
-
if (
|
|
1519
|
+
if (!retainAttemptWorktree) {
|
|
1468
1520
|
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1469
1521
|
}
|
|
1470
1522
|
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
@@ -1499,6 +1551,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1499
1551
|
const diagnosisFingerprintBefore = diagnosis ? await worktreeStateFingerprint(attemptWorkspace) : null;
|
|
1500
1552
|
if (diagnosis && diagnosisFingerprintBefore === null) {
|
|
1501
1553
|
const detail = "Bridge could not fingerprint the retained worktree before read-only diagnosis.";
|
|
1554
|
+
retainAttemptWorktree = true;
|
|
1502
1555
|
const response = await queueTerminal(client, taskId, {
|
|
1503
1556
|
action: "fail",
|
|
1504
1557
|
body: {
|
|
@@ -1517,6 +1570,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1517
1570
|
},
|
|
1518
1571
|
});
|
|
1519
1572
|
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1573
|
+
await removeReleasedPreflightWorktree();
|
|
1520
1574
|
console.error(`Assignment ${taskId} diagnosis held: ${detail}`);
|
|
1521
1575
|
return;
|
|
1522
1576
|
}
|
|
@@ -1539,6 +1593,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1539
1593
|
const pinnedFacts = active.phase === "agent_running" ? active.executionFacts : undefined;
|
|
1540
1594
|
const executionFacts = pinnedFacts ? resumeExecutionFacts(pinnedFacts, observedFacts) : observedFacts;
|
|
1541
1595
|
const verificationCommands = pinnedFacts ? active.verificationCommands ?? [] : attemptBrief?.verification ?? [];
|
|
1596
|
+
const declaredVerificationCommands = pinnedFacts ? active.declaredVerificationCommands ?? [] : attemptBrief?.declared_verification ?? [];
|
|
1542
1597
|
// A resumed attempt must still be doing the work it claimed. When a pinned fact moved — the
|
|
1543
1598
|
// verification command list, the repository, the base commit — the attempt is over: report it
|
|
1544
1599
|
// once with the fact named, and let the owner's Retry pin the facts again.
|
|
@@ -1549,7 +1604,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1549
1604
|
}
|
|
1550
1605
|
// The command list is pinned with the digest it produced: on a later turn the report can name the
|
|
1551
1606
|
// gate that moved instead of showing two hex strings.
|
|
1552
|
-
await client.updateAttempt(taskId, { executionFacts, verificationCommands });
|
|
1607
|
+
await client.updateAttempt(taskId, { executionFacts, verificationCommands, declaredVerificationCommands });
|
|
1553
1608
|
await client.attemptRequest(taskId, "progress", {
|
|
1554
1609
|
phase: diagnosis ? "inspecting" : "agent_running",
|
|
1555
1610
|
execution_facts: executionFacts,
|
|
@@ -1629,18 +1684,18 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1629
1684
|
// the retry resumes from the head reported here instead of starting over at the base. The tree
|
|
1630
1685
|
// stays on this computer, where that head is always reachable.
|
|
1631
1686
|
const reportTurnTimeout = async (message) => {
|
|
1632
|
-
retainAttemptWorktree = true;
|
|
1633
1687
|
const resume = diagnosis || machineBoundTimeout(agentTimeout) ? null : await checkpointWorkInProgress({
|
|
1634
1688
|
workspace: attemptWorkspace, spec, grants, baseCommit: deliveryBaseCommit,
|
|
1635
1689
|
}).catch((error) => {
|
|
1636
1690
|
console.error(`Work in progress was not committed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
|
|
1637
1691
|
return null;
|
|
1638
1692
|
});
|
|
1693
|
+
retainAttemptWorktree = true;
|
|
1639
1694
|
const response = await queueTerminal(client, taskId, {
|
|
1640
1695
|
action: "fail",
|
|
1641
1696
|
body: timeoutFailureBody(agentTimeout, active.attemptId, resume),
|
|
1642
1697
|
});
|
|
1643
|
-
retainAttemptWorktree = Boolean(resume) || response
|
|
1698
|
+
retainAttemptWorktree = Boolean(resume) || retainDiagnosticWorktree(response);
|
|
1644
1699
|
console.error(`Assignment ${taskId} timed out: ${redactSecrets(message)}`);
|
|
1645
1700
|
};
|
|
1646
1701
|
const failAttemptFromDriverResult = async (result) => {
|
|
@@ -1678,7 +1733,6 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1678
1733
|
})
|
|
1679
1734
|
: null;
|
|
1680
1735
|
if (capturedVerification?.timedOut) {
|
|
1681
|
-
retainAttemptWorktree = true;
|
|
1682
1736
|
await reportVerificationGateTimeout({ command: capturedVerification.command, detail: capturedVerification.detail,
|
|
1683
1737
|
stderr: capturedVerification.stderr ?? "", stdout: capturedVerification.stdout ?? "" });
|
|
1684
1738
|
return retainAttemptWorktree;
|
|
@@ -1707,7 +1761,6 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1707
1761
|
// the same run and must not silently reclassify an unretryable failure.
|
|
1708
1762
|
// Keep the tree until the replay-safe terminal response explicitly says whether diagnosis was
|
|
1709
1763
|
// queued. A network fault here must not delete the evidence before Bridge can replay terminal.
|
|
1710
|
-
retainAttemptWorktree = true;
|
|
1711
1764
|
// The driver's typed cause rides beside the prose, never instead of it. A recovered
|
|
1712
1765
|
// verification log describes the same run: the signal becomes Bridge's verification witness
|
|
1713
1766
|
// and the prose carries the evidence, so the verify class stays typed without new tables.
|
|
@@ -1722,6 +1775,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1722
1775
|
? { ...result.signal, phase: "verification", witness: "bridge", kind: "verification_failed", command: capturedVerification.command, stderr_tail: undefined, stdout_tail: undefined, ...(diffStat ? { diff_stat: diffStat } : {}) }
|
|
1723
1776
|
: result.signal
|
|
1724
1777
|
: undefined;
|
|
1778
|
+
retainAttemptWorktree = true;
|
|
1725
1779
|
const response = await queueTerminal(client, taskId, { action: "fail", body: {
|
|
1726
1780
|
error: message,
|
|
1727
1781
|
retryable: bindingRequired ? false : retryableAgentFailure(agentMessage),
|
|
@@ -1734,7 +1788,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1734
1788
|
...(signal ? { signal } : {}),
|
|
1735
1789
|
idempotency_key: `bridge:fail:${active.attemptId}`,
|
|
1736
1790
|
} });
|
|
1737
|
-
retainAttemptWorktree = response
|
|
1791
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1738
1792
|
console.error(`Assignment ${taskId} failed: ${redactSecrets(message)}`);
|
|
1739
1793
|
return retainAttemptWorktree;
|
|
1740
1794
|
};
|
|
@@ -1842,6 +1896,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1842
1896
|
const detail = diagnosisWitness === "changed"
|
|
1843
1897
|
? "The diagnostic lane changed the retained worktree during a read-only run."
|
|
1844
1898
|
: "Bridge could not fingerprint the retained worktree after read-only diagnosis.";
|
|
1899
|
+
retainAttemptWorktree = true;
|
|
1845
1900
|
const response = await queueTerminal(client, taskId, {
|
|
1846
1901
|
action: "fail",
|
|
1847
1902
|
body: {
|
|
@@ -1870,6 +1925,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1870
1925
|
// A spent allowance keeps its prose reading here (the control plane quotes the reset clock
|
|
1871
1926
|
// from it); a computer fact in the CLI's words is named by code, as on the authoring path.
|
|
1872
1927
|
const diagnosisCause = providerQuotaRefusal(message) ? null : agentOutputCause(message);
|
|
1928
|
+
retainAttemptWorktree = true;
|
|
1873
1929
|
const response = await queueTerminal(client, taskId, {
|
|
1874
1930
|
action: "fail",
|
|
1875
1931
|
body: {
|
|
@@ -1892,6 +1948,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1892
1948
|
}
|
|
1893
1949
|
catch (error) {
|
|
1894
1950
|
const message = error instanceof Error ? error.message : "Diagnostic RepairBrief was invalid";
|
|
1951
|
+
retainAttemptWorktree = true;
|
|
1895
1952
|
const response = await queueTerminal(client, taskId, {
|
|
1896
1953
|
action: "fail",
|
|
1897
1954
|
body: {
|
|
@@ -1904,6 +1961,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1904
1961
|
console.error(`Assignment ${taskId} diagnosis returned no usable brief: ${redactSecrets(message)}`);
|
|
1905
1962
|
return;
|
|
1906
1963
|
}
|
|
1964
|
+
retainAttemptWorktree = true;
|
|
1907
1965
|
const response = await queueTerminal(client, taskId, {
|
|
1908
1966
|
action: "complete",
|
|
1909
1967
|
body: {
|
|
@@ -1997,6 +2055,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1997
2055
|
if (await isLandTreeEmpty({ workspace: attemptWorkspace, spec, grants, baseCommit: deliveryBaseCommit })) {
|
|
1998
2056
|
const base = spec.repository?.base_commit ?? "unknown";
|
|
1999
2057
|
const classified = classifyFinalizeFailure(agentNoLandCommitMessage(base));
|
|
2058
|
+
retainAttemptWorktree = true;
|
|
2000
2059
|
const response = await queueTerminal(client, taskId, {
|
|
2001
2060
|
action: "fail",
|
|
2002
2061
|
body: {
|
|
@@ -2016,6 +2075,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2016
2075
|
else {
|
|
2017
2076
|
const base = spec.repository?.base_commit ?? "unknown";
|
|
2018
2077
|
const classified = classifyFinalizeFailure(agentNoLandCommitMessage(base));
|
|
2078
|
+
retainAttemptWorktree = true;
|
|
2019
2079
|
const response = await queueTerminal(client, taskId, {
|
|
2020
2080
|
action: "fail",
|
|
2021
2081
|
body: {
|
|
@@ -2062,7 +2122,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2062
2122
|
idempotency_key: `bridge:no-delivery-report:${active.attemptId}`,
|
|
2063
2123
|
},
|
|
2064
2124
|
});
|
|
2065
|
-
retainAttemptWorktree = response
|
|
2125
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
2066
2126
|
console.error(`Assignment ${taskId} attempted no delivery report: ${redactSecrets(detail)}`);
|
|
2067
2127
|
return;
|
|
2068
2128
|
}
|
|
@@ -2177,6 +2237,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2177
2237
|
if (await isLandTreeEmpty({ workspace: attemptWorkspace, spec, grants, baseCommit: deliveryBaseCommit })) {
|
|
2178
2238
|
const base = spec.repository?.base_commit ?? "unknown";
|
|
2179
2239
|
const classified = classifyFinalizeFailure(agentNoLandCommitMessage(base));
|
|
2240
|
+
retainAttemptWorktree = true;
|
|
2180
2241
|
const response = await queueTerminal(client, taskId, {
|
|
2181
2242
|
action: "fail",
|
|
2182
2243
|
body: {
|
|
@@ -2208,7 +2269,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2208
2269
|
driver,
|
|
2209
2270
|
executionClass,
|
|
2210
2271
|
capabilities: spec.required_capabilities ?? [],
|
|
2211
|
-
verificationCommands
|
|
2272
|
+
verificationCommands,
|
|
2212
2273
|
objective: task.objective,
|
|
2213
2274
|
selection,
|
|
2214
2275
|
fuel,
|
|
@@ -2257,8 +2318,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2257
2318
|
report,
|
|
2258
2319
|
spec,
|
|
2259
2320
|
grants,
|
|
2260
|
-
verificationCommands
|
|
2261
|
-
declaredVerificationCommands
|
|
2321
|
+
verificationCommands,
|
|
2322
|
+
declaredVerificationCommands,
|
|
2262
2323
|
});
|
|
2263
2324
|
report = await ensureChangeEvidence({
|
|
2264
2325
|
workspace: attemptWorkspace,
|
|
@@ -2308,7 +2369,6 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2308
2369
|
// never a repair attempt or a blind retry, which is what burned six deliveries on 2026-09-04
|
|
2309
2370
|
// against a bare `python3 -m unittest` that discovered nothing.
|
|
2310
2371
|
if (error instanceof VerificationGateRanNothingError) {
|
|
2311
|
-
retainAttemptWorktree = true;
|
|
2312
2372
|
const signal = {
|
|
2313
2373
|
phase: "verification",
|
|
2314
2374
|
witness: "bridge",
|
|
@@ -2317,6 +2377,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2317
2377
|
exit_code: error.exitCode,
|
|
2318
2378
|
vendor_message: error.vendorMessage,
|
|
2319
2379
|
};
|
|
2380
|
+
retainAttemptWorktree = true;
|
|
2320
2381
|
const response = await queueTerminal(client, taskId, {
|
|
2321
2382
|
action: "fail",
|
|
2322
2383
|
body: {
|
|
@@ -2329,7 +2390,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2329
2390
|
idempotency_key: `bridge:invalid-delivery:${active.attemptId}`,
|
|
2330
2391
|
},
|
|
2331
2392
|
});
|
|
2332
|
-
retainAttemptWorktree = response
|
|
2393
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
2333
2394
|
console.error(`Assignment ${taskId} verification gate ran nothing: ${redactSecrets(error.message)}`);
|
|
2334
2395
|
const gateReplyTail = reportText.slice(-8_000);
|
|
2335
2396
|
console.error(`Assignment ${taskId} agent reply tail (${reportText.length} chars total, redacted): ${redactSecrets(gateReplyTail) || "<empty>"}`);
|
|
@@ -2350,15 +2411,16 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2350
2411
|
// A report that fell short of the delivery contract is named by its type and travels as the
|
|
2351
2412
|
// delivery_report signal, so repair routing never reads this message (K3, debt 18 item 3c).
|
|
2352
2413
|
const reportDefect = error instanceof DeliveryReportDefectError;
|
|
2353
|
-
const classified =
|
|
2354
|
-
? { retryable:
|
|
2355
|
-
:
|
|
2356
|
-
? { retryable:
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2414
|
+
const classified = error instanceof VerificationCommandUnavailableError
|
|
2415
|
+
? { retryable: false, error: message, failure: error.failure }
|
|
2416
|
+
: verificationFailed
|
|
2417
|
+
? { retryable: true, error: `Verification failed after the agent run.\n${message}${diffStat ? `\n\nChanged files:\n${diffStat}` : ""}` }
|
|
2418
|
+
: reportDefect
|
|
2419
|
+
? { retryable: false, error: message }
|
|
2420
|
+
// classifyFinalizeFailure: forge transport → retryable; contract/environment/unknown → fail
|
|
2421
|
+
// closed (unknown prefixed as "Bridge finalize interrupted", not laundered as contract).
|
|
2422
|
+
: classifyFinalizeFailure(message);
|
|
2423
|
+
retainAttemptWorktree = true;
|
|
2362
2424
|
const response = await queueTerminal(client, taskId, {
|
|
2363
2425
|
action: "fail",
|
|
2364
2426
|
body: {
|
|
@@ -2390,8 +2452,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2390
2452
|
return;
|
|
2391
2453
|
}
|
|
2392
2454
|
await client.updateAttempt(taskId, { phase: "agent_finished", delivery: { spec, report } });
|
|
2455
|
+
retainAttemptWorktree = true;
|
|
2393
2456
|
const response = await submitFinishedDelivery(client, taskId);
|
|
2394
|
-
retainAttemptWorktree = response
|
|
2457
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
2395
2458
|
deliverySubmitted = response.status !== "failed";
|
|
2396
2459
|
if (deliverySubmitted)
|
|
2397
2460
|
console.log(`Assignment ${taskId} delivered for review and acceptance.`);
|
|
@@ -2823,6 +2886,8 @@ async function prepareDelivery(client, attemptId, taskId, report) {
|
|
|
2823
2886
|
details: item.details,
|
|
2824
2887
|
acceptance_criteria: item.acceptance_criteria,
|
|
2825
2888
|
...(item.witness ? { witness: item.witness } : {}),
|
|
2889
|
+
...(item.command !== undefined ? { command: item.command } : {}),
|
|
2890
|
+
...(item.exit_code !== undefined ? { exit_code: item.exit_code } : {}),
|
|
2826
2891
|
},
|
|
2827
2892
|
idempotency_key: `bridge:artifact:${attemptId}:${index + 1}`,
|
|
2828
2893
|
});
|
|
@@ -3001,7 +3066,11 @@ export async function verificationScopeDryRun(input) {
|
|
|
3001
3066
|
let redBase = null;
|
|
3002
3067
|
for (const command of commands) {
|
|
3003
3068
|
// A gate that cannot start says nothing about the scope; the next one still can.
|
|
3004
|
-
const result = await run(command, input.workspace).catch(() =>
|
|
3069
|
+
const result = await run(command, input.workspace).catch((error) => {
|
|
3070
|
+
if (error instanceof VerificationCommandUnavailableError)
|
|
3071
|
+
throw error;
|
|
3072
|
+
return null;
|
|
3073
|
+
});
|
|
3005
3074
|
if (!result)
|
|
3006
3075
|
continue;
|
|
3007
3076
|
// A gate that never exited says nothing about the base commit: it is not a red base, and it
|
package/dist/investigation.js
CHANGED
|
@@ -13,12 +13,12 @@ import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } fr
|
|
|
13
13
|
import { headCommitOrNull } from "./git.js";
|
|
14
14
|
import { ConduitRequestError } from "./client.js";
|
|
15
15
|
import { learnDriverFuel, resolveAssignmentModel, changedPathsSince, runDeliveryVerificationGates, discardWorktreeWrites } from "./execution.js";
|
|
16
|
-
import { DRIVERS, execute, extractAgentReportJsonText } from "./driver.js";
|
|
16
|
+
import { DRIVERS, execute, extractAgentReportJsonText, untrustedCodeTempRoot, untrustedCodeSpawn, untrustedCodeEnvironment } from "./driver.js";
|
|
17
17
|
import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
|
|
18
18
|
import { diffStatSince } from "./git-witness.js";
|
|
19
19
|
import { switchManagedWorkspace } from "./on-shift-apply.js";
|
|
20
20
|
import { execFile } from "node:child_process";
|
|
21
|
-
import { rm, writeFile } from "node:fs/promises";
|
|
21
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
22
22
|
import { createRequire } from "node:module";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { promisify } from "node:util";
|
|
@@ -471,14 +471,6 @@ function probeTail(stderr, stdout) {
|
|
|
471
471
|
const stderrTail = stderr.length > PROBE_TAIL_CHARS ? stderr.slice(stderr.length - PROBE_TAIL_CHARS) : stderr;
|
|
472
472
|
return boundedTail(`${stderrTail}\n${stdout}`, PROBE_TAIL_CHARS);
|
|
473
473
|
}
|
|
474
|
-
function probeEnv() {
|
|
475
|
-
const env = {};
|
|
476
|
-
if (process.env.PATH !== undefined)
|
|
477
|
-
env.PATH = process.env.PATH;
|
|
478
|
-
if (process.env.HOME !== undefined)
|
|
479
|
-
env.HOME = process.env.HOME;
|
|
480
|
-
return env;
|
|
481
|
-
}
|
|
482
474
|
function probeInterpreter(language) {
|
|
483
475
|
if (language === "ts")
|
|
484
476
|
return "node --import tsx";
|
|
@@ -514,12 +506,16 @@ async function execProbe(argv, workspace) {
|
|
|
514
506
|
const bin = argv[0];
|
|
515
507
|
if (!bin)
|
|
516
508
|
throw new Error("Probe command is empty");
|
|
509
|
+
const tempRoot = untrustedCodeTempRoot(workspace);
|
|
510
|
+
await mkdir(tempRoot, { recursive: true });
|
|
511
|
+
const scratchHome = await mkdtemp(join(tempRoot, "probe-home-"));
|
|
517
512
|
try {
|
|
518
|
-
const
|
|
519
|
-
|
|
513
|
+
const spawn = await untrustedCodeSpawn({ command: bin, argv: argv.slice(1), cwd: workspace, env: untrustedCodeEnvironment(scratchHome) });
|
|
514
|
+
const { stdout, stderr } = await execFileAsync(spawn.command, spawn.argv, {
|
|
515
|
+
cwd: spawn.cwd,
|
|
520
516
|
timeout: PROBE_TIMEOUT_MS,
|
|
521
517
|
maxBuffer: PROBE_MAX_BUFFER_BYTES,
|
|
522
|
-
env:
|
|
518
|
+
env: spawn.env,
|
|
523
519
|
});
|
|
524
520
|
return { stdout: String(stdout), stderr: String(stderr), code: 0 };
|
|
525
521
|
}
|
|
@@ -535,6 +531,9 @@ async function execProbe(argv, workspace) {
|
|
|
535
531
|
code: err.killed === true || typeof err.code !== "number" ? -1 : err.code,
|
|
536
532
|
};
|
|
537
533
|
}
|
|
534
|
+
finally {
|
|
535
|
+
await rm(scratchHome, { recursive: true, force: true });
|
|
536
|
+
}
|
|
538
537
|
}
|
|
539
538
|
function witnessRecord(input) {
|
|
540
539
|
return {
|
|
@@ -657,14 +656,23 @@ function toStepWitness(witness) {
|
|
|
657
656
|
/**
|
|
658
657
|
* The delivered instrument runs through the agent runs' own process helper (T90): bounded output,
|
|
659
658
|
* an answer on the child's exit even when a process it left behind holds the pipes, and a stop
|
|
660
|
-
* that escalates to SIGKILL. Its environment
|
|
659
|
+
* that escalates to SIGKILL. Its environment uses a disposable HOME without machine credentials or proxies.
|
|
661
660
|
*/
|
|
662
661
|
async function execDeliveredProbeRun(argv, stdin, workspace, timeoutMs) {
|
|
663
662
|
const [file, ...args] = argv;
|
|
664
663
|
if (!file)
|
|
665
664
|
throw new Error("delivered probe command is empty");
|
|
666
|
-
const
|
|
667
|
-
|
|
665
|
+
const tempRoot = untrustedCodeTempRoot(workspace);
|
|
666
|
+
await mkdir(tempRoot, { recursive: true });
|
|
667
|
+
const scratchHome = await mkdtemp(join(tempRoot, "probe-home-"));
|
|
668
|
+
try {
|
|
669
|
+
const spawn = await untrustedCodeSpawn({ command: file, argv: args, cwd: workspace, env: untrustedCodeEnvironment(scratchHome) });
|
|
670
|
+
const result = await execute(spawn.command, spawn.argv, spawn.cwd, timeoutMs, undefined, "conduit", stdin, undefined, undefined, spawn.env);
|
|
671
|
+
return { code: result.code ?? -1, stdout: result.stdout, stderr: result.stderr };
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
await rm(scratchHome, { recursive: true, force: true });
|
|
675
|
+
}
|
|
668
676
|
}
|
|
669
677
|
export async function runDeliveredProbeInstrument(input) {
|
|
670
678
|
const entry = join(input.workspace, PROBE_RUN_ENTRY);
|
package/dist/ops.js
CHANGED
|
@@ -9,6 +9,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
9
9
|
import { stdin as input, stdout as output } from "node:process";
|
|
10
10
|
import { createInterface } from "node:readline/promises";
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
|
+
import { probeUntrustedCodeSandbox } from "./driver.js";
|
|
12
13
|
import { ConduitClient } from "./client.js";
|
|
13
14
|
import { loadConfig } from "./config.js";
|
|
14
15
|
import { ensureCheckout } from "./checkout.js";
|
|
@@ -355,6 +356,8 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
355
356
|
const installed = await detectInstalledClients();
|
|
356
357
|
const health = probeAgentHealth(installed, process.env);
|
|
357
358
|
console.log(`Bridge preflight: ${report.ready ? "READY" : "BLOCKED"}`);
|
|
359
|
+
const isolation = await (deps.probeSandbox ?? probeUntrustedCodeSandbox)();
|
|
360
|
+
console.log(`Untrusted-code isolation = ${isolation.os_sandbox === "none" ? `environment only: ${isolation.reason}` : `os sandbox (${isolation.os_sandbox})`}`);
|
|
358
361
|
const installedAgents = health.filter((h) => h.installed);
|
|
359
362
|
if (installedAgents.length > 0) {
|
|
360
363
|
console.log("\nAgent Diagnostics:");
|
package/dist/service.js
CHANGED
|
@@ -198,7 +198,8 @@ export async function systemdDropInConflicts(home = homedir(), name = DEFAULT_SE
|
|
|
198
198
|
conflicts.add(name);
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
-
|
|
201
|
+
// systemd answers for this user's real unit only; another home's units are not its to report.
|
|
202
|
+
if (platform() === "linux" && home === homedir()) {
|
|
202
203
|
const effective = spawnSync("systemctl", ["--user", "show", `${name}.service`, "--property=DropInPaths", "--value"], { encoding: "utf8" });
|
|
203
204
|
if (effective.status === 0) {
|
|
204
205
|
for (const path of parseSystemdDropInPaths(effective.stdout))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.180",
|
|
4
4
|
"description": "Conduit Bridge CLI \u2014 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": {
|