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.
Files changed (78) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +204 -81
  5. package/dist/agent-fence.js +467 -397
  6. package/dist/agentconfig.d.ts +1 -1
  7. package/dist/agentconfig.js +11 -2
  8. package/dist/batch-checks.js +14 -26
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  10. package/dist/builder.d.ts +9 -0
  11. package/dist/builder.js +50 -4
  12. package/dist/check-access.d.ts +51 -0
  13. package/dist/check-access.js +77 -0
  14. package/dist/coding-provider.d.ts +14 -0
  15. package/dist/coding-provider.js +33 -1
  16. package/dist/coding-workspace.d.ts +15 -0
  17. package/dist/coding-workspace.js +9 -2
  18. package/dist/contracts/console-api.d.ts +2 -0
  19. package/dist/contracts/console-api.js +2 -0
  20. package/dist/doctor.d.ts +7 -0
  21. package/dist/doctor.js +4 -0
  22. package/dist/egress.d.ts +2 -0
  23. package/dist/egress.js +13 -4
  24. package/dist/exec.d.ts +17 -3
  25. package/dist/exec.js +54 -17
  26. package/dist/flow-actions.d.ts +1 -0
  27. package/dist/flow-actions.js +3 -2
  28. package/dist/flow-code.d.ts +31 -2
  29. package/dist/flow-code.js +57 -5
  30. package/dist/flow-steps.d.ts +4 -0
  31. package/dist/flow-steps.js +9 -5
  32. package/dist/flow-triggers.d.ts +4 -0
  33. package/dist/flow-triggers.js +8 -3
  34. package/dist/invoke.d.ts +50 -3
  35. package/dist/invoke.js +204 -66
  36. package/dist/operate.d.ts +4 -0
  37. package/dist/operate.js +119 -0
  38. package/dist/project-delete-ui.d.ts +23 -6
  39. package/dist/project-delete-ui.js +94 -1
  40. package/dist/project-sandbox.d.ts +59 -0
  41. package/dist/project-sandbox.js +83 -0
  42. package/dist/project-tools.d.ts +29 -5
  43. package/dist/project-tools.js +43 -7
  44. package/dist/release.d.ts +1 -1
  45. package/dist/release.js +1 -0
  46. package/dist/result-follow-ups.js +10 -15
  47. package/dist/sandbox-launch.d.ts +1 -0
  48. package/dist/sandbox-launch.js +148 -0
  49. package/dist/sandbox-runtime.d.ts +62 -0
  50. package/dist/sandbox-runtime.js +140 -0
  51. package/dist/scope.d.ts +37 -0
  52. package/dist/scope.js +32 -5
  53. package/dist/scout-browser-lock.d.ts +69 -0
  54. package/dist/scout-browser-lock.js +69 -0
  55. package/dist/scout-browser.d.ts +60 -1
  56. package/dist/scout-browser.js +200 -4
  57. package/dist/scout.d.ts +9 -4
  58. package/dist/scout.js +14 -4
  59. package/dist/serve.js +12 -1
  60. package/dist/server/http.d.ts +2 -0
  61. package/dist/server/render-results.d.ts +3 -0
  62. package/dist/server/render-results.js +1 -0
  63. package/dist/server/render-tasks.js +5 -1
  64. package/dist/server/route-table.js +2 -0
  65. package/dist/server/settings.js +64 -0
  66. package/dist/server/tasks.js +2 -0
  67. package/dist/store.d.ts +59 -2
  68. package/dist/store.js +166 -4
  69. package/dist/subagent-tools.d.ts +1 -0
  70. package/dist/subagent-tools.js +7 -5
  71. package/dist/surface.js +2 -1
  72. package/dist/tool-broker.d.ts +2 -1
  73. package/dist/tool-broker.js +2 -2
  74. package/dist/tool-launcher.js +10 -16
  75. package/package.json +2 -1
  76. package/scripts/provider-canary.mjs +5 -6
  77. package/dist/egress-bridge.d.ts +0 -17
  78. package/dist/egress-bridge.js +0 -111
@@ -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");
@@ -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 or a web
9
- * server with its values is still run as before.
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
- function runLocal(spec, values) {
15
- const child = spawn(spec.command, spec.args, { env: { ...process.env, ...values }, stdio: "inherit" });
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
- runLocal(launch.spec, launch.values);
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.68",
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, macosFenced, linuxFenceAvailable, linuxFenced } = await import(join(root, "dist", "agent-fence.js"));
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() ? macosFenced("git", gitArgs, fence, grants)
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
- : macosFenceAvailable() ? macosFenced("true", [], fence, grants) : linuxFenced("true", [], fence, grants);
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`);
@@ -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>;
@@ -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
- }