toolroll 0.9.68 → 0.9.70
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/CHANGELOG.md +81 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +204 -81
- package/dist/agent-fence.js +467 -397
- package/dist/agentconfig.d.ts +1 -1
- package/dist/agentconfig.js +11 -2
- package/dist/batch-checks.js +14 -26
- package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
- package/dist/builder.d.ts +9 -0
- package/dist/builder.js +50 -4
- package/dist/check-access.d.ts +51 -0
- package/dist/check-access.js +77 -0
- package/dist/coding-provider.d.ts +14 -0
- package/dist/coding-provider.js +33 -1
- package/dist/coding-workspace.d.ts +15 -0
- package/dist/coding-workspace.js +9 -2
- package/dist/contracts/console-api.d.ts +2 -0
- package/dist/contracts/console-api.js +2 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +4 -0
- package/dist/egress.d.ts +2 -0
- package/dist/egress.js +13 -4
- package/dist/exec.d.ts +17 -3
- package/dist/exec.js +54 -17
- package/dist/flow-actions.d.ts +1 -0
- package/dist/flow-actions.js +3 -2
- package/dist/flow-code.d.ts +31 -2
- package/dist/flow-code.js +57 -5
- package/dist/flow-steps.d.ts +4 -0
- package/dist/flow-steps.js +9 -5
- package/dist/flow-triggers.d.ts +4 -0
- package/dist/flow-triggers.js +8 -3
- package/dist/invoke.d.ts +50 -3
- package/dist/invoke.js +204 -66
- package/dist/operate.d.ts +4 -0
- package/dist/operate.js +119 -0
- package/dist/project-delete-ui.d.ts +23 -6
- package/dist/project-delete-ui.js +94 -1
- package/dist/project-sandbox.d.ts +59 -0
- package/dist/project-sandbox.js +83 -0
- package/dist/project-tools.d.ts +29 -5
- package/dist/project-tools.js +43 -7
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -0
- package/dist/result-follow-ups.js +10 -15
- package/dist/sandbox-launch.d.ts +1 -0
- package/dist/sandbox-launch.js +148 -0
- package/dist/sandbox-runtime.d.ts +62 -0
- package/dist/sandbox-runtime.js +140 -0
- package/dist/scope.d.ts +37 -0
- package/dist/scope.js +32 -5
- package/dist/scout-browser-lock.d.ts +69 -0
- package/dist/scout-browser-lock.js +69 -0
- package/dist/scout-browser.d.ts +60 -1
- package/dist/scout-browser.js +200 -4
- package/dist/scout.d.ts +9 -4
- package/dist/scout.js +14 -4
- package/dist/serve.js +12 -1
- package/dist/server/http.d.ts +2 -0
- package/dist/server/render-results.d.ts +3 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/render-tasks.js +5 -1
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +64 -0
- package/dist/server/tasks.js +2 -0
- package/dist/store.d.ts +59 -2
- package/dist/store.js +166 -4
- package/dist/subagent-tools.d.ts +1 -0
- package/dist/subagent-tools.js +7 -5
- package/dist/surface.js +2 -1
- package/dist/tool-broker.d.ts +2 -1
- package/dist/tool-broker.js +2 -2
- package/dist/tool-launcher.js +10 -16
- package/package.json +2 -1
- package/scripts/provider-canary.mjs +5 -6
- package/dist/egress-bridge.d.ts +0 -17
- package/dist/egress-bridge.js +0 -111
package/dist/tool-broker.js
CHANGED
|
@@ -139,8 +139,8 @@ class LocalServer {
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
const { file, args } = wrap === null ? { file: this.tool.spec.command, args: [...this.tool.spec.args] } : wrap(this.tool.spec.command, this.tool.spec.args);
|
|
143
|
-
const child = spawn(file, args, { env, ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }), stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32" });
|
|
142
|
+
const { file, args, env: sandboxEnv } = wrap === null ? { file: this.tool.spec.command, args: [...this.tool.spec.args], env: undefined } : wrap(this.tool.spec.command, this.tool.spec.args, this.options.cwd);
|
|
143
|
+
const child = spawn(file, args, { env: { ...env, ...(sandboxEnv ?? {}) }, ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }), stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32" });
|
|
144
144
|
this.child = child;
|
|
145
145
|
this.initialized = false;
|
|
146
146
|
child.stdout?.setEncoding("utf8");
|
package/dist/tool-launcher.js
CHANGED
|
@@ -5,23 +5,15 @@
|
|
|
5
5
|
* against codex-cli 0.156), so nothing rides Codex's environment. A build's
|
|
6
6
|
* launch file names its run's tool broker (tool-broker.ts) and that launch's
|
|
7
7
|
* token, and this process relays stdio to it over HTTP; the tool's own
|
|
8
|
-
* secrets stay in the broker. A launch file naming a local program
|
|
9
|
-
*
|
|
8
|
+
* secrets stay in the broker. A launch file naming a local program is refused
|
|
9
|
+
* (docs/plans/sandbox-parity.md C2): Codex starts this launcher outside its
|
|
10
|
+
* command sandbox, so a program started here would run unfenced; every local
|
|
11
|
+
* server runs in the broker, inside the server fence.
|
|
10
12
|
*/
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
13
|
import { readFileSync } from "node:fs";
|
|
13
14
|
import { createInterface } from "node:readline";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const forward = (signal) => { try {
|
|
17
|
-
child.kill(signal);
|
|
18
|
-
}
|
|
19
|
-
catch { /* gone */ } };
|
|
20
|
-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
21
|
-
process.on(signal, () => forward(signal));
|
|
22
|
-
child.on("error", error => { process.stderr.write(`the tool could not start: ${error.message}\n`); process.exit(127); });
|
|
23
|
-
child.on("exit", (code, signal) => process.exit(code ?? (signal === null ? 1 : 128)));
|
|
24
|
-
}
|
|
15
|
+
/** The refusal for a launch file naming a local program: nothing is started (126, "could not be executed"). */
|
|
16
|
+
const LOCAL_LAUNCH_REFUSED = "This tool's launch names a local program, which only Toolroll's tool broker may start (inside its fence); nothing was started.";
|
|
25
17
|
/** stdio ↔ streamable HTTP: each line from Codex is POSTed; JSON or event-stream answers go back as lines. */
|
|
26
18
|
function relayWeb(spec, values) {
|
|
27
19
|
const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
|
|
@@ -98,5 +90,7 @@ catch {
|
|
|
98
90
|
}
|
|
99
91
|
if (launch.spec.transport === "http")
|
|
100
92
|
relayWeb(launch.spec, launch.values);
|
|
101
|
-
else
|
|
102
|
-
|
|
93
|
+
else {
|
|
94
|
+
process.stderr.write(`${LOCAL_LAUNCH_REFUSED}\n`);
|
|
95
|
+
process.exit(126);
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.70",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
}
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
+
"@anthropic-ai/sandbox-runtime": "0.0.79",
|
|
128
129
|
"@slack/socket-mode": "3.1.0",
|
|
129
130
|
"@xterm/addon-serialize": "0.14.0",
|
|
130
131
|
"@xterm/headless": "6.0.0",
|
|
@@ -313,7 +313,7 @@ async function runtimeIdentity() {
|
|
|
313
313
|
export async function checkCheckoutGit({ provider, worktree, databaseFile, repository, home = homedir() }, execute = run) {
|
|
314
314
|
if (!["claude", "codex", "gemini"].includes(provider)) return null;
|
|
315
315
|
if (typeof worktree !== "string" || worktree === "") throw new Error("the builder did not retain its worktree for the Git fence check");
|
|
316
|
-
const { agentFence, taskBoundary, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, fenceGrants, codexFenceArgv, macosFenceAvailable,
|
|
316
|
+
const { agentFence, taskBoundary, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, fenceGrants, codexFenceArgv, macosFenceAvailable, linuxFenceAvailable, sandboxedSpawn } = await import(join(root, "dist", "agent-fence.js"));
|
|
317
317
|
const binding = trustedWorktreeBinding(repository, worktree);
|
|
318
318
|
const boundary = taskBoundary({ worktree, provider, home, binding });
|
|
319
319
|
const fence = agentFence({ databaseFile, worktree, home, boundary, binding });
|
|
@@ -329,16 +329,15 @@ export async function checkCheckoutGit({ provider, worktree, databaseFile, repos
|
|
|
329
329
|
if (control.code !== 0) throw new Error(`unfenced git ${args.join(" ")} failed: ${control.stderr}`);
|
|
330
330
|
const wrapped = provider === "codex"
|
|
331
331
|
? { file: "codex", args: ["sandbox", ...codexFenceArgv(fence, false, grants), "-P", "standing-orders", "--", "git", ...gitArgs] }
|
|
332
|
-
: macosFenceAvailable() ?
|
|
333
|
-
: linuxFenceAvailable() ? linuxFenced("git", gitArgs, fence, grants) : null;
|
|
332
|
+
: macosFenceAvailable() || linuxFenceAvailable() ? sandboxedSpawn("git", gitArgs, fence, grants, worktree) : null;
|
|
334
333
|
if (wrapped === null || fence.length === 0) { skipped = true; if (args[0] === "rev-parse") head = control.stdout.trim(); continue; }
|
|
335
334
|
const checked = await execute(wrapped.file, wrapped.args, { cwd: worktree,
|
|
336
|
-
env });
|
|
335
|
+
env: { ...env, ...(wrapped.env ?? {}) } });
|
|
337
336
|
if (checked.code !== 0) {
|
|
338
337
|
const trivial = provider === "codex"
|
|
339
338
|
? { file: "codex", args: ["sandbox", ...codexFenceArgv(fence, false, grants), "-P", "standing-orders", "--", "true"] }
|
|
340
|
-
:
|
|
341
|
-
const sandbox = await execute(trivial.file, trivial.args, { cwd: worktree, env });
|
|
339
|
+
: sandboxedSpawn("true", [], fence, grants, worktree);
|
|
340
|
+
const sandbox = await execute(trivial.file, trivial.args, { cwd: worktree, env: { ...env, ...(trivial.env ?? {}) } });
|
|
342
341
|
throw new Error(sandbox.code === 0 ? `The build fence blocks this checkout's own Git data. git ${args.join(" ")}: ${checked.stderr}` : `The provider sandbox could not run its control: ${sandbox.stderr}`);
|
|
343
342
|
}
|
|
344
343
|
if (checked.stdout !== control.stdout) throw new Error(`fenced git ${args.join(" ")} disagrees with its control`);
|
package/dist/egress-bridge.d.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/** The prefix of a bridge folder in the temp folder (agent-fence.ts RUN_FOLDER_PREFIXES denies every other run's). */
|
|
2
|
-
export declare const BRIDGE_PREFIX = "so-net-";
|
|
3
|
-
/** The forwarder, run inside the namespace as `node forward.mjs <folder> <port>... -- <command>`. Zero dependencies. */
|
|
4
|
-
export declare const FORWARDER_SOURCE: string;
|
|
5
|
-
/** Whether a bridge made under `root` keeps every socket path within the limit (its folder and `<port>.sock`). */
|
|
6
|
-
export declare function bridgeFits(root: string): boolean;
|
|
7
|
-
export type EgressBridge = {
|
|
8
|
-
folder: string;
|
|
9
|
-
forwarder: string;
|
|
10
|
-
close: () => Promise<void>;
|
|
11
|
-
};
|
|
12
|
-
/**
|
|
13
|
-
* Serve one socket per port (each relayed to that port on the host's loopback) in a fresh owner-only folder under
|
|
14
|
-
* `root`: the plane's own bridge folder beside the database (agent-fence.ts BRIDGE_DIR), which every build's fence
|
|
15
|
-
* hides from launch, so no other build can change the forwarder or reach the sockets.
|
|
16
|
-
*/
|
|
17
|
-
export declare function startEgressBridge(ports: readonly number[], root?: string): Promise<EgressBridge>;
|
package/dist/egress-bridge.js
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How a Linux build with a network of its own reaches its proxy and tool broker (agent-fence.ts linuxFenced). The
|
|
3
|
-
* plane serves one unix socket per port in a folder of its own (`<folder>/<port>.sock`, each relayed to that port on
|
|
4
|
-
* the host's loopback); bubblewrap binds the folder read-only into the build's namespace, where the forwarder below
|
|
5
|
-
* listens on the namespace's own 127.0.0.1 at the same ports and relays each connection to its socket. Only those
|
|
6
|
-
* ports cross: nothing else of the host's network is reachable, and the relay never opens a connection of its own.
|
|
7
|
-
*
|
|
8
|
-
* The forwarder is written into the folder at launch (the plane's own file, read-only to the build), so it never runs
|
|
9
|
-
* from an installation path a fence might hide or a build could change.
|
|
10
|
-
*/
|
|
11
|
-
import { chmodSync, lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { connect, createServer } from "node:net";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
/** The prefix of a bridge folder in the temp folder (agent-fence.ts RUN_FOLDER_PREFIXES denies every other run's). */
|
|
16
|
-
export const BRIDGE_PREFIX = "so-net-";
|
|
17
|
-
/** The forwarder, run inside the namespace as `node forward.mjs <folder> <port>... -- <command>`. Zero dependencies. */
|
|
18
|
-
export const FORWARDER_SOURCE = String.raw `import { connect, createServer } from "node:net";
|
|
19
|
-
import { spawn } from "node:child_process";
|
|
20
|
-
const argv = process.argv.slice(2);
|
|
21
|
-
const split = argv.indexOf("--");
|
|
22
|
-
if (split < 1 || split === argv.length - 1) { process.stderr.write("toolroll forwarder: usage <folder> <port>... -- <command>\n"); process.exit(2); }
|
|
23
|
-
const folder = argv[0];
|
|
24
|
-
const ports = argv.slice(1, split).map(Number);
|
|
25
|
-
const command = argv.slice(split + 1);
|
|
26
|
-
let ready = 0;
|
|
27
|
-
for (const port of ports) {
|
|
28
|
-
const server = createServer(client => {
|
|
29
|
-
const upstream = connect(folder + "/" + port + ".sock");
|
|
30
|
-
client.on("error", () => upstream.destroy());
|
|
31
|
-
upstream.on("error", () => client.destroy());
|
|
32
|
-
client.pipe(upstream);
|
|
33
|
-
upstream.pipe(client);
|
|
34
|
-
});
|
|
35
|
-
server.on("error", error => { process.stderr.write("toolroll forwarder: " + error.message + "\n"); process.exit(70); });
|
|
36
|
-
server.listen(port, "127.0.0.1", () => { if (++ready === ports.length) start(); });
|
|
37
|
-
}
|
|
38
|
-
if (ports.length === 0) start();
|
|
39
|
-
function start() {
|
|
40
|
-
const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
|
|
41
|
-
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, () => child.kill(signal));
|
|
42
|
-
child.on("error", error => { process.stderr.write("toolroll forwarder: " + error.message + "\n"); process.exit(127); });
|
|
43
|
-
child.on("exit", (code, signal) => {
|
|
44
|
-
if (signal === null) process.exit(code ?? 1);
|
|
45
|
-
// Die the way the agent died: this process's own handlers (which only pass a signal on) step aside first.
|
|
46
|
-
process.removeAllListeners(signal);
|
|
47
|
-
process.kill(process.pid, signal);
|
|
48
|
-
setTimeout(() => process.exit(128), 1000).unref();
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
`;
|
|
52
|
-
/** The longest a unix socket's path may be here (macOS 104 bytes, Linux 108, with room to spare). */
|
|
53
|
-
const SOCKET_PATH_MAX = 100;
|
|
54
|
-
/** Whether a bridge made under `root` keeps every socket path within the limit (its folder and `<port>.sock`). */
|
|
55
|
-
export function bridgeFits(root) {
|
|
56
|
-
return join(root, `${BRIDGE_PREFIX}XXXXXX`, "65535.sock").length <= SOCKET_PATH_MAX;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Serve one socket per port (each relayed to that port on the host's loopback) in a fresh owner-only folder under
|
|
60
|
-
* `root`: the plane's own bridge folder beside the database (agent-fence.ts BRIDGE_DIR), which every build's fence
|
|
61
|
-
* hides from launch, so no other build can change the forwarder or reach the sockets.
|
|
62
|
-
*/
|
|
63
|
-
export async function startEgressBridge(ports, root = tmpdir()) {
|
|
64
|
-
// The root must be a real folder of this user's alone, never a link a build could have planted before it existed.
|
|
65
|
-
const held = lstatSync(root);
|
|
66
|
-
if (held.isSymbolicLink() || !held.isDirectory() || (process.getuid !== undefined && held.uid !== process.getuid()) || (root !== tmpdir() && (held.mode & 0o077) !== 0)) {
|
|
67
|
-
throw new Error(`the bridge folder ${root} isn't a private folder of this user's`);
|
|
68
|
-
}
|
|
69
|
-
if (!bridgeFits(root))
|
|
70
|
-
throw new Error(`the bridge folder ${root} is too deep for a socket path`);
|
|
71
|
-
const folder = mkdtempSync(join(root, BRIDGE_PREFIX));
|
|
72
|
-
chmodSync(folder, 0o700);
|
|
73
|
-
const forwarder = join(folder, "forward.mjs");
|
|
74
|
-
writeFileSync(forwarder, FORWARDER_SOURCE, { mode: 0o500, flag: "wx" });
|
|
75
|
-
const servers = [];
|
|
76
|
-
const sockets = new Set();
|
|
77
|
-
try {
|
|
78
|
-
for (const port of ports) {
|
|
79
|
-
const server = createServer(client => {
|
|
80
|
-
sockets.add(client);
|
|
81
|
-
const upstream = connect(port, "127.0.0.1");
|
|
82
|
-
sockets.add(upstream);
|
|
83
|
-
const drop = () => { client.destroy(); upstream.destroy(); };
|
|
84
|
-
client.on("error", drop);
|
|
85
|
-
upstream.on("error", drop);
|
|
86
|
-
client.on("close", () => { sockets.delete(client); upstream.destroy(); });
|
|
87
|
-
upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
|
|
88
|
-
client.pipe(upstream);
|
|
89
|
-
upstream.pipe(client);
|
|
90
|
-
});
|
|
91
|
-
await new Promise((done, fail) => { server.once("error", fail); server.listen(join(folder, `${port}.sock`), () => { server.off("error", fail); done(); }); });
|
|
92
|
-
servers.push(server);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
for (const server of servers)
|
|
97
|
-
server.close();
|
|
98
|
-
rmSync(folder, { recursive: true, force: true });
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
folder,
|
|
103
|
-
forwarder,
|
|
104
|
-
close: async () => {
|
|
105
|
-
for (const socket of sockets)
|
|
106
|
-
socket.destroy();
|
|
107
|
-
await Promise.all(servers.map(server => new Promise(done => server.close(() => done()))));
|
|
108
|
-
rmSync(folder, { recursive: true, force: true });
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|