toolroll 0.9.69 → 0.9.71

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 (68) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +184 -115
  5. package/dist/agent-fence.js +418 -535
  6. package/dist/agentconfig.js +6 -1
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  8. package/dist/builder.js +24 -4
  9. package/dist/check-access.d.ts +6 -9
  10. package/dist/check-access.js +8 -11
  11. package/dist/coding-provider.d.ts +14 -0
  12. package/dist/coding-provider.js +33 -1
  13. package/dist/coding-workspace.d.ts +15 -0
  14. package/dist/coding-workspace.js +9 -2
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/doctor.d.ts +7 -0
  18. package/dist/doctor.js +4 -0
  19. package/dist/egress.d.ts +2 -0
  20. package/dist/egress.js +13 -4
  21. package/dist/exec.d.ts +17 -3
  22. package/dist/exec.js +54 -17
  23. package/dist/flow-actions.d.ts +1 -0
  24. package/dist/flow-actions.js +3 -2
  25. package/dist/flow-code.d.ts +31 -2
  26. package/dist/flow-code.js +57 -5
  27. package/dist/flow-steps.d.ts +4 -0
  28. package/dist/flow-steps.js +9 -5
  29. package/dist/flow-triggers.d.ts +4 -0
  30. package/dist/flow-triggers.js +8 -3
  31. package/dist/invoke.d.ts +25 -8
  32. package/dist/invoke.js +115 -115
  33. package/dist/operate.js +46 -0
  34. package/dist/project-delete-ui.d.ts +7 -6
  35. package/dist/project-delete-ui.js +37 -3
  36. package/dist/project-sandbox.d.ts +59 -0
  37. package/dist/project-sandbox.js +83 -0
  38. package/dist/project-tools.d.ts +29 -5
  39. package/dist/project-tools.js +43 -7
  40. package/dist/release.d.ts +1 -1
  41. package/dist/release.js +1 -0
  42. package/dist/sandbox-launch.d.ts +1 -0
  43. package/dist/sandbox-launch.js +148 -0
  44. package/dist/sandbox-runtime.d.ts +109 -0
  45. package/dist/sandbox-runtime.js +281 -0
  46. package/dist/scope.d.ts +28 -0
  47. package/dist/scope.js +25 -5
  48. package/dist/scout-browser-lock.d.ts +69 -0
  49. package/dist/scout-browser-lock.js +69 -0
  50. package/dist/scout-browser.d.ts +60 -1
  51. package/dist/scout-browser.js +200 -4
  52. package/dist/scout.d.ts +9 -4
  53. package/dist/scout.js +14 -4
  54. package/dist/serve.js +3 -1
  55. package/dist/server/render-tasks.js +4 -1
  56. package/dist/server/route-table.js +1 -0
  57. package/dist/server/settings.js +27 -0
  58. package/dist/store.d.ts +31 -1
  59. package/dist/store.js +83 -4
  60. package/dist/subagent-tools.d.ts +1 -0
  61. package/dist/subagent-tools.js +7 -5
  62. package/dist/tool-broker.d.ts +2 -1
  63. package/dist/tool-broker.js +2 -2
  64. package/dist/tool-launcher.js +10 -16
  65. package/package.json +2 -1
  66. package/scripts/provider-canary.mjs +5 -6
  67. package/dist/egress-bridge.d.ts +0 -17
  68. package/dist/egress-bridge.js +0 -111
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The scout browser helper's pinned install (scout-browser.ts ensureScoutBrowserHelper): its manifest and lockfile,
3
+ * generated once with `npm install --package-lock-only --registry https://registry.npmjs.org/` for @playwright/mcp
4
+ * 0.0.82. `npm ci` installs exactly this tree and refuses any tarball whose sha512 differs. Changing a version here is
5
+ * a supply-chain change: regenerate both and review the diff.
6
+ */
7
+ export const SCOUT_BROWSER_MANIFEST = {
8
+ "name": "toolroll-scout-browser",
9
+ "private": true,
10
+ "dependencies": {
11
+ "@playwright/mcp": "0.0.82"
12
+ }
13
+ };
14
+ export const SCOUT_BROWSER_LOCK = {
15
+ "name": "toolroll-scout-browser",
16
+ "lockfileVersion": 3,
17
+ "requires": true,
18
+ "packages": {
19
+ "": {
20
+ "name": "toolroll-scout-browser",
21
+ "dependencies": {
22
+ "@playwright/mcp": "0.0.82"
23
+ }
24
+ },
25
+ "node_modules/@playwright/mcp": {
26
+ "version": "0.0.82",
27
+ "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.82.tgz",
28
+ "integrity": "sha512-OCqftfb8H4dnqm/njbTBRk3seUvUPttOlJUxCtEzXGETYOlRH5Qt3bbXIjmZIuWAxD9RF+yg1ASrPeXvm0y5cA==",
29
+ "license": "Apache-2.0",
30
+ "dependencies": {
31
+ "playwright": "1.64.0-alpha-1789764292000",
32
+ "playwright-core": "1.64.0-alpha-1789764292000"
33
+ },
34
+ "bin": {
35
+ "playwright-mcp": "cli.js"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ }
40
+ },
41
+ "node_modules/playwright": {
42
+ "version": "1.64.0-alpha-1789764292000",
43
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0-alpha-1789764292000.tgz",
44
+ "integrity": "sha512-3Ngs4ERGdC912uW3srEDtNVey4u1wSaQ/EFcKhxMpz0by0K6nidaJgM087pLpJc1osytiVnL7ack5gvgPArPNw==",
45
+ "license": "Apache-2.0",
46
+ "dependencies": {
47
+ "playwright-core": "1.64.0-alpha-1789764292000"
48
+ },
49
+ "bin": {
50
+ "playwright": "cli.js"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ }
55
+ },
56
+ "node_modules/playwright-core": {
57
+ "version": "1.64.0-alpha-1789764292000",
58
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0-alpha-1789764292000.tgz",
59
+ "integrity": "sha512-ZgRaybFv4rRy7QMGnYprEGFdNJnvapNqcaER2w96bDQA+40BWIle1el1Yh9KHD3E6XYmieMB+r+UxFTCauTGGQ==",
60
+ "license": "Apache-2.0",
61
+ "bin": {
62
+ "playwright-core": "cli.js"
63
+ },
64
+ "engines": {
65
+ "node": ">=20"
66
+ }
67
+ }
68
+ }
69
+ };
@@ -17,6 +17,12 @@ export type BrowserStarter = (launch: BrowserLaunch, options: BrowserOptions) =>
17
17
  export type BrowserOptions = {
18
18
  /** The browser tool's environment: the scout's own, its proxy settings included. */
19
19
  env: Record<string, string | undefined>;
20
+ /** Toolroll's helpers folder (in its state folder) the pinned browser is installed in, and the scout's proxy: the
21
+ * launch contract's trusted inputs, from the plane, never from the launch. */
22
+ contract?: {
23
+ helpers: string;
24
+ proxy: string;
25
+ };
20
26
  /** Where it saves screenshots (its `--output-dir`), also its working folder. */
21
27
  folder: string;
22
28
  /** The whole start and check, from spawn to screenshot (review 828: at most 60 seconds). */
@@ -24,7 +30,8 @@ export type BrowserOptions = {
24
30
  };
25
31
  /** The public page the check opens through the scout's proxy. */
26
32
  export declare const PREFLIGHT_PAGE = "https://example.com/";
27
- /** How a scout gets its browser: started for real, or, for a test's stub agent, a placeholder that starts nothing. */
33
+ /** How a scout gets its browser: started for real under its launch contract, or, for a test's stub agent, a placeholder
34
+ * that starts nothing. */
28
35
  export declare function starterFor(agentInjected: boolean): BrowserStarter;
29
36
  /** Start the browser tool outside the fence and check it opens a public page and saves a screenshot. */
30
37
  export declare function startScoutBrowser(launch: BrowserLaunch, options: BrowserOptions): Promise<BrowserStart>;
@@ -46,3 +53,55 @@ export type McpSession = {
46
53
  };
47
54
  /** A client of the browser's HTTP transport, as the scout's own is: every step answers by `deadline`, or says why not. */
48
55
  export declare function openSession(url: string, deadline: number, late: string): McpSession;
56
+ /** The pinned browser tool, installed once into Toolroll's helpers folder. */
57
+ export declare const SCOUT_BROWSER_PACKAGE = "@playwright/mcp@0.0.82";
58
+ /** The names of the worker's environment the browser tool gets: an allowlist, never a denylist. */
59
+ export declare const SCOUT_BROWSER_ENV: readonly string[];
60
+ /** What the run records of the exception it ran under. */
61
+ export declare const SCOUT_BROWSER_CONTAINMENT = "Chrome's sandbox";
62
+ /** Where the pinned browser lives under `helpers`. */
63
+ export declare function scoutBrowserHelper(helpers: string): {
64
+ folder: string;
65
+ cli: string;
66
+ browsers: string;
67
+ };
68
+ /** The scout's browser tool: the pinned helper, run by Toolroll's own Node, headless, on its bundled Chrome. */
69
+ export declare function scoutBrowserTool(helpers: string): BrowserLaunch;
70
+ /**
71
+ * Why a browser launch breaks its contract, or null when it holds. Every input it trusts is the plane's: the helpers
72
+ * folder and the proxy come from `options.contract`, never from the launch. The executable must be Toolroll's own
73
+ * Node; the script the pinned helper's, inside a private helpers folder; the arguments exactly the expected ones (no
74
+ * way to switch Chrome's sandbox off, point at another browser or executable, or drop the proxy); the working folder
75
+ * the image folder; the environment only allowlisted names.
76
+ */
77
+ export declare function browserContractProblem(launch: BrowserLaunch, options: BrowserOptions): string | null;
78
+ /** The pinned lockfile's digest, recorded when the helper is installed and checked before every use. */
79
+ export declare const SCOUT_BROWSER_LOCK_DIGEST: string;
80
+ /** The npm of the Node running Toolroll (`lib/node_modules/npm/bin/npm-cli.js` beside it), run by that Node by path:
81
+ * never an `npm` found on PATH. Null where this Node has none beside it (a bundled runtime). */
82
+ export declare function ownNpm(execPath?: string): string | null;
83
+ /**
84
+ * Install the pinned browser into the helpers folder once. The worker runs this outside every fence, so nothing a build
85
+ * could have written may steer it: npm is Toolroll's own Node's, run by path; HOME is the staging folder and the user
86
+ * and global npm configurations are an empty file there (no ~/.npmrc, no global npmrc, no registry, script or Node
87
+ * option from either); the registry is pinned; install scripts never run; `npm ci` installs exactly the pinned lockfile
88
+ * (scout-browser-lock.ts) and refuses a tarball whose integrity differs; the installed versions are checked against it
89
+ * before ready.json is written. The npm cache and Chrome for Testing stay in the folder, never in the user's caches.
90
+ * Residual: Chrome for Testing comes from Playwright's own download over HTTPS, without a pinned hash.
91
+ */
92
+ export declare function ensureScoutBrowserHelper(helpers: string, timeoutMs?: number): Promise<{
93
+ ok: true;
94
+ } | {
95
+ ok: false;
96
+ reason: string;
97
+ }>;
98
+ /** How the helper's npm runs in `staging`: Toolroll's own npm by path, with its home, user and global configuration
99
+ * the staging folder's own empty ones, the registry and cache pinned; the environment only these names. */
100
+ export declare function helperNpm(staging: string, npm: string): {
101
+ env: Record<string, string>;
102
+ args: string[];
103
+ };
104
+ /** What of an installed tree differs from the pinned lockfile (a package missing or at another version), or null. */
105
+ export declare function lockDrift(folder: string): string | null;
106
+ /** The production starter: the pinned helper in place, then the launch held to its contract, then started. */
107
+ export declare function startContractedBrowser(launch: BrowserLaunch, options: BrowserOptions): Promise<BrowserStart>;
@@ -10,21 +10,32 @@
10
10
  *
11
11
  * Playwright MCP 0.0.82 has no per-client token for its HTTP transport; it answers only on 127.0.0.1, only for
12
12
  * that host name (its own host check), and only while this run's scout does.
13
+ *
14
+ * An explicit exception (docs/plans/sandbox-parity.md C4, owner Q-A): Toolroll can't fence this process without
15
+ * switching Chrome's sandbox off, so its launch is held to a contract instead, and refused when any part fails
16
+ * (`browserContractProblem`): the pinned Playwright MCP and its Chrome for Testing are installed in Toolroll's own
17
+ * helpers folder (inside the state folder every build's fence denies) and run by Toolroll's own Node; its arguments are
18
+ * exactly the expected ones (Chrome's sandbox on, the scout's proxy on, its image folder as its output and working
19
+ * folder); its environment is an allowlist. The run records which exception it ran under.
13
20
  */
14
21
  import { spawn } from "node:child_process";
15
22
  import { request as httpRequest } from "node:http";
16
23
  import { connect, createServer } from "node:net";
17
- import { readdirSync, rmSync } from "node:fs";
18
- import { join } from "node:path";
24
+ import { createHash } from "node:crypto";
25
+ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
26
+ import { dirname, join, relative, isAbsolute } from "node:path";
27
+ import { run } from "./exec.js";
28
+ import { SCOUT_BROWSER_LOCK, SCOUT_BROWSER_MANIFEST } from "./scout-browser-lock.js";
19
29
  import { sleep } from "./sleep.js";
20
30
  /** The public page the check opens through the scout's proxy. */
21
31
  export const PREFLIGHT_PAGE = "https://example.com/";
22
32
  const PREFLIGHT_TIMEOUT_MS = 60_000;
23
33
  const CLOSE_GRACE_MS = 5_000;
24
34
  const IMAGE = /\.(png|jpe?g)$/i;
25
- /** How a scout gets its browser: started for real, or, for a test's stub agent, a placeholder that starts nothing. */
35
+ /** How a scout gets its browser: started for real under its launch contract, or, for a test's stub agent, a placeholder
36
+ * that starts nothing. */
26
37
  export function starterFor(agentInjected) {
27
- return agentInjected ? unstarted : startScoutBrowser;
38
+ return agentInjected ? unstarted : startContractedBrowser;
28
39
  }
29
40
  const unstarted = async () => ({ ok: true, server: { url: "http://127.0.0.1:9/mcp", close: async () => undefined } });
30
41
  /** Start the browser tool outside the fence and check it opens a public page and saves a screenshot. */
@@ -227,3 +238,188 @@ function oneLine(text) {
227
238
  // eslint-disable-next-line no-control-regex
228
239
  return text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, 200);
229
240
  }
241
+ // ---- the launch contract (docs/plans/sandbox-parity.md C4) ----
242
+ /** The pinned browser tool, installed once into Toolroll's helpers folder. */
243
+ export const SCOUT_BROWSER_PACKAGE = "@playwright/mcp@0.0.82";
244
+ const HELPER_NAME = "scout-browser-0.0.82";
245
+ /** The names of the worker's environment the browser tool gets: an allowlist, never a denylist. */
246
+ export const SCOUT_BROWSER_ENV = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR",
247
+ "HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"];
248
+ /** What the run records of the exception it ran under. */
249
+ export const SCOUT_BROWSER_CONTAINMENT = "Chrome's sandbox";
250
+ /** Where the pinned browser lives under `helpers`. */
251
+ export function scoutBrowserHelper(helpers) {
252
+ const folder = join(helpers, HELPER_NAME);
253
+ return { folder, cli: join(folder, "node_modules", "@playwright", "mcp", "cli.js"), browsers: join(folder, "browsers") };
254
+ }
255
+ /** The scout's browser tool: the pinned helper, run by Toolroll's own Node, headless, on its bundled Chrome. */
256
+ export function scoutBrowserTool(helpers) {
257
+ return { command: process.execPath, args: [scoutBrowserHelper(helpers).cli, "--headless", "--browser", "chromium"] };
258
+ }
259
+ /** A folder of Toolroll's own: a real folder (no link at it), this user's, private (0700). */
260
+ function privateFolder(path) {
261
+ try {
262
+ const held = lstatSync(path);
263
+ return held.isDirectory() && !held.isSymbolicLink() && (process.getuid === undefined || held.uid === process.getuid()) && (held.mode & 0o077) === 0;
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
269
+ const within = (child, parent) => { const rel = relative(parent, child); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); };
270
+ /**
271
+ * Why a browser launch breaks its contract, or null when it holds. Every input it trusts is the plane's: the helpers
272
+ * folder and the proxy come from `options.contract`, never from the launch. The executable must be Toolroll's own
273
+ * Node; the script the pinned helper's, inside a private helpers folder; the arguments exactly the expected ones (no
274
+ * way to switch Chrome's sandbox off, point at another browser or executable, or drop the proxy); the working folder
275
+ * the image folder; the environment only allowlisted names.
276
+ */
277
+ export function browserContractProblem(launch, options) {
278
+ const contract = options.contract;
279
+ if (contract === undefined)
280
+ return "the browser has no launch contract";
281
+ const real = (path) => { try {
282
+ return realpathSync(path);
283
+ }
284
+ catch {
285
+ return null;
286
+ } };
287
+ if (real(launch.command) === null || real(launch.command) !== real(process.execPath))
288
+ return "the browser isn't run by Toolroll's own Node";
289
+ if (!privateFolder(contract.helpers))
290
+ return "Toolroll's helpers folder isn't a private folder of its own";
291
+ const helper = scoutBrowserHelper(contract.helpers);
292
+ const script = launch.args[0] === undefined ? null : real(launch.args[0]);
293
+ const helpers = real(contract.helpers);
294
+ if (script === null || helpers === null || !within(script, helpers) || script !== real(helper.cli))
295
+ return "the browser tool isn't the pinned one in Toolroll's helpers folder";
296
+ const expected = [launch.args[0], "--headless", "--browser", "chromium", "--isolated", "--output-dir", options.folder, "--proxy-server", contract.proxy, "--proxy-bypass", "<-loopback>"];
297
+ if (launch.args.length !== expected.length || launch.args.some((one, index) => one !== expected[index]))
298
+ return "the browser's arguments aren't the contract's (Chrome's sandbox and the scout's proxy stay on)";
299
+ const extra = Object.keys(options.env).filter(name => options.env[name] !== undefined && !SCOUT_BROWSER_ENV.includes(name));
300
+ if (extra.length > 0)
301
+ return `the browser's environment carries names outside its allowlist (${extra.slice(0, 5).join(", ")})`;
302
+ if (options.env["HTTPS_PROXY"] !== contract.proxy || options.env["HTTP_PROXY"] !== contract.proxy)
303
+ return "the browser's environment doesn't point at the scout's proxy";
304
+ return null;
305
+ }
306
+ /** The registry the helper is installed from: pinned, whatever any npm configuration says. */
307
+ const NPM_REGISTRY = "https://registry.npmjs.org/";
308
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
309
+ /** The pinned lockfile's digest, recorded when the helper is installed and checked before every use. */
310
+ export const SCOUT_BROWSER_LOCK_DIGEST = sha256(JSON.stringify(SCOUT_BROWSER_LOCK));
311
+ /** The npm of the Node running Toolroll (`lib/node_modules/npm/bin/npm-cli.js` beside it), run by that Node by path:
312
+ * never an `npm` found on PATH. Null where this Node has none beside it (a bundled runtime). */
313
+ export function ownNpm(execPath = process.execPath) {
314
+ const cli = join(dirname(dirname(execPath)), "lib", "node_modules", "npm", "bin", "npm-cli.js");
315
+ try {
316
+ return realpathSync(cli);
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ }
322
+ /**
323
+ * Install the pinned browser into the helpers folder once. The worker runs this outside every fence, so nothing a build
324
+ * could have written may steer it: npm is Toolroll's own Node's, run by path; HOME is the staging folder and the user
325
+ * and global npm configurations are an empty file there (no ~/.npmrc, no global npmrc, no registry, script or Node
326
+ * option from either); the registry is pinned; install scripts never run; `npm ci` installs exactly the pinned lockfile
327
+ * (scout-browser-lock.ts) and refuses a tarball whose integrity differs; the installed versions are checked against it
328
+ * before ready.json is written. The npm cache and Chrome for Testing stay in the folder, never in the user's caches.
329
+ * Residual: Chrome for Testing comes from Playwright's own download over HTTPS, without a pinned hash.
330
+ */
331
+ export async function ensureScoutBrowserHelper(helpers, timeoutMs = 600_000) {
332
+ const helper = scoutBrowserHelper(helpers);
333
+ if (helperReady(helper.folder))
334
+ return { ok: true };
335
+ const npm = ownNpm();
336
+ if (npm === null)
337
+ return { ok: false, reason: "Toolroll's own Node has no npm beside it, so the pinned browser can't be installed" };
338
+ try {
339
+ mkdirSync(helpers, { recursive: true, mode: 0o700 });
340
+ }
341
+ catch { /* checked next */ }
342
+ if (!privateFolder(helpers))
343
+ return { ok: false, reason: "Toolroll's helpers folder isn't a private folder of its own" };
344
+ const staging = mkdtempSync(join(helpers, ".install-"));
345
+ try {
346
+ writeFileSync(join(staging, "package.json"), JSON.stringify(SCOUT_BROWSER_MANIFEST), { mode: 0o600 });
347
+ writeFileSync(join(staging, "package-lock.json"), JSON.stringify(SCOUT_BROWSER_LOCK), { mode: 0o600 });
348
+ const { env, args } = helperNpm(staging, npm);
349
+ const installed = await run(process.execPath, [...args, "ci", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: staging, env, envAllowlist: [], timeoutMs });
350
+ if (installed.code !== 0)
351
+ return { ok: false, reason: `the pinned browser couldn't be installed (npm exit ${installed.code})` };
352
+ const drift = lockDrift(staging);
353
+ if (drift !== null)
354
+ return { ok: false, reason: `the installed browser isn't the pinned one (${drift})` };
355
+ const browsers = await run(process.execPath, [join(staging, "node_modules", "playwright", "cli.js"), "install", "chromium"], { cwd: staging, env, envAllowlist: [], timeoutMs });
356
+ if (browsers.code !== 0)
357
+ return { ok: false, reason: `the pinned browser's Chrome couldn't be installed (exit ${browsers.code})` };
358
+ rmSync(join(staging, "npm-cache"), { recursive: true, force: true });
359
+ writeFileSync(join(staging, "ready.json"), JSON.stringify({ package: SCOUT_BROWSER_PACKAGE, lock: SCOUT_BROWSER_LOCK_DIGEST }), { mode: 0o600 });
360
+ try {
361
+ renameSync(staging, helper.folder);
362
+ }
363
+ catch {
364
+ if (!helperReady(helper.folder))
365
+ return { ok: false, reason: "the pinned browser couldn't be put in place" };
366
+ }
367
+ return { ok: true };
368
+ }
369
+ finally {
370
+ rmSync(staging, { recursive: true, force: true });
371
+ }
372
+ }
373
+ /** How the helper's npm runs in `staging`: Toolroll's own npm by path, with its home, user and global configuration
374
+ * the staging folder's own empty ones, the registry and cache pinned; the environment only these names. */
375
+ export function helperNpm(staging, npm) {
376
+ // Two empty files: npm refuses to load one file as both.
377
+ const user = join(staging, "user.npmrc"), global = join(staging, "global.npmrc");
378
+ for (const file of [user, global])
379
+ writeFileSync(file, "", { mode: 0o600 });
380
+ const env = {
381
+ PATH: [dirname(process.execPath), "/usr/bin", "/bin"].join(":"), HOME: staging, PLAYWRIGHT_BROWSERS_PATH: join(staging, "browsers"),
382
+ NPM_CONFIG_USERCONFIG: user, NPM_CONFIG_GLOBALCONFIG: global, npm_config_userconfig: user, npm_config_globalconfig: global,
383
+ };
384
+ return { env, args: [npm, "--no-update-notifier", `--registry=${NPM_REGISTRY}`, "--userconfig", user, "--globalconfig", global, "--cache", join(staging, "npm-cache")] };
385
+ }
386
+ /** Whether a helper folder holds the pinned install: its ready note names this lockfile, and its tree still matches. */
387
+ function helperReady(folder) {
388
+ try {
389
+ const ready = JSON.parse(readFileSync(join(folder, "ready.json"), "utf8"));
390
+ return ready.lock === SCOUT_BROWSER_LOCK_DIGEST && lockDrift(folder) === null;
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
396
+ /** What of an installed tree differs from the pinned lockfile (a package missing or at another version), or null. */
397
+ export function lockDrift(folder) {
398
+ for (const [path, entry] of Object.entries(SCOUT_BROWSER_LOCK.packages)) {
399
+ if (path === "" || entry.version === undefined)
400
+ continue;
401
+ let version;
402
+ try {
403
+ version = JSON.parse(readFileSync(join(folder, path, "package.json"), "utf8")).version;
404
+ }
405
+ catch {
406
+ version = undefined;
407
+ }
408
+ if (version !== entry.version)
409
+ return `${path.replace(/^node_modules\//, "")} is ${typeof version === "string" ? version : "missing"}, pinned ${entry.version}`;
410
+ }
411
+ return null;
412
+ }
413
+ /** The production starter: the pinned helper in place, then the launch held to its contract, then started. */
414
+ export async function startContractedBrowser(launch, options) {
415
+ if (options.contract === undefined)
416
+ return { ok: false, reason: "the browser has no launch contract" };
417
+ const installed = await ensureScoutBrowserHelper(options.contract.helpers);
418
+ if (!installed.ok)
419
+ return installed;
420
+ const env = { ...options.env, PLAYWRIGHT_BROWSERS_PATH: scoutBrowserHelper(options.contract.helpers).browsers };
421
+ const problem = browserContractProblem(launch, { ...options, env: options.env });
422
+ if (problem !== null)
423
+ return { ok: false, reason: problem };
424
+ return startScoutBrowser(launch, { ...options, env });
425
+ }
package/dist/scout.d.ts CHANGED
@@ -3,7 +3,6 @@ import type { Store } from "./store.js";
3
3
  import { type ParsedDecision, type Problem } from "./decision.js";
4
4
  import { type ParsedReport, type ReportProblem } from "./scout-report.js";
5
5
  import type { Runner } from "./builder.js";
6
- import { type ToolSpec } from "./project-tools.js";
7
6
  import * as browserCheck from "./scout-browser.js";
8
7
  /** The scout's own headless browser: the pinned Playwright tool, saving its
9
8
  * screenshots straight into the run's image folder. */
@@ -16,7 +15,10 @@ export declare const SCOUT_BROWSER = "toolroll-browser";
16
15
  * clean-tree proof.
17
16
  */
18
17
  export declare const SCOUT_ALLOWED_TOOLS: readonly string[];
19
- export declare function scoutBrowserLaunch(tool: Pick<ToolSpec, "command" | "args"> | null, imageFolder: string, proxy: string): browserCheck.BrowserLaunch | null;
18
+ export declare function scoutBrowserLaunch(tool: {
19
+ command: string | null;
20
+ args: readonly string[];
21
+ } | null, imageFolder: string, proxy: string): browserCheck.BrowserLaunch | null;
20
22
  /** What the fenced scout gets: the running browser's loopback address, nothing it could launch itself. */
21
23
  export declare function scoutBrowser(url: string): Record<string, unknown>;
22
24
  /** The scout's environment while its proxy runs (review 827): web fetch and the provider's own traffic go through
@@ -62,8 +64,11 @@ export type ScoutRequest = {
62
64
  outputDir?: string;
63
65
  /** The project's own demo or dev server, the one non-public address the scout's browser may open. */
64
66
  demoUrl?: string | null;
65
- /** The browser to launch; left out, the catalog's Playwright entry. Null runs the scout without one. */
66
- browserTool?: Pick<ToolSpec, "command" | "args"> | null;
67
+ /** The browser to launch; left out, the pinned helper in Toolroll's helpers folder. Null runs the scout without one. */
68
+ browserTool?: {
69
+ command: string | null;
70
+ args: readonly string[];
71
+ } | null;
67
72
  };
68
73
  export type ReportArtifact = {
69
74
  key: string;
package/dist/scout.js CHANGED
@@ -16,7 +16,7 @@ import { skillsContext } from "./project-skills.js";
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import { mkdtempSync, realpathSync, rmSync, unlinkSync } from "node:fs";
18
18
  import { homedir, tmpdir } from "node:os";
19
- import { join, relative, isAbsolute } from "node:path";
19
+ import { dirname, join, relative, isAbsolute } from "node:path";
20
20
  import { run } from "./exec.js";
21
21
  import { auditOf } from "./provider.js";
22
22
  import { currentClaim, heartbeat } from "./claim.js";
@@ -32,7 +32,7 @@ import { openLiveLog } from "./live.js";
32
32
  import { proveTreeUntouched, snapshotIgnored } from "./tree-proof.js";
33
33
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
34
34
  import { CLAUDE_LIMITS } from "./scope.js";
35
- import { catalogTool, projectToolsOf, toolLaunchFor } from "./project-tools.js";
35
+ import { projectToolsOf, toolLaunchFor } from "./project-tools.js";
36
36
  import { grantOf, researchToolsOf } from "./mcp-connect.js";
37
37
  import { startScoutProxy } from "./scout-net.js";
38
38
  import * as browserCheck from "./scout-browser.js";
@@ -312,7 +312,11 @@ async function scoutWith(store, request, outputDir) {
312
312
  // Every scout runs behind its public-web-only proxy (reviews 826, 827): its browser, when this install has one,
313
313
  // and its web fetch both go through it. A proxy that cannot start leaves the scout without the web, reading only
314
314
  // the checkout, never with an unfiltered one.
315
- const browserTool = request.browserTool === undefined ? catalogTool("playwright") : request.browserTool;
315
+ // The pinned browser in Toolroll's helpers folder, run under its launch contract (scout-browser.ts); none where the
316
+ // plane has no state folder.
317
+ const database = store.databaseFile();
318
+ const helpers = database === null || database === "" || database === ":memory:" ? null : join(dirname(realpathSync(database)), "helpers");
319
+ const browserTool = request.browserTool === undefined ? (helpers === null ? null : browserCheck.scoutBrowserTool(helpers)) : request.browserTool;
316
320
  const demoUrl = request.demoUrl ?? null;
317
321
  let proxy = null;
318
322
  let browserServer = null;
@@ -324,9 +328,11 @@ async function scoutWith(store, request, outputDir) {
324
328
  // The preflight (run 2356, review 828): the worker starts the browser outside the fence and opens a public page
325
329
  // through the proxy before research begins. One that can't start is said once, in the brief and the report, and
326
330
  // the scout researches without it.
331
+ // Its environment is an allowlist of the worker's, with the scout's proxy (the launch contract).
327
332
  const started = launch === null || proxy === null ? null : await browserCheck.starterFor(agent !== undefined)(launch, {
328
- env: { ...withoutEnv(process.env, [...AGENT_ENV_DENYLIST, ...PROXY_BYPASS_ENV]), ...scoutProxyEnv(proxy.url) },
333
+ env: { ...Object.fromEntries(browserCheck.SCOUT_BROWSER_ENV.filter(name => !(name in scoutProxyEnv(proxy.url)) && process.env[name] !== undefined).map(name => [name, process.env[name]])), ...scoutProxyEnv(proxy.url) },
329
334
  folder: imageFolder,
335
+ ...(helpers === null ? {} : { contract: { helpers, proxy: proxy.url } }),
330
336
  }).catch((error) => ({ ok: false, reason: error instanceof Error ? error.message : String(error) }));
331
337
  if (started !== null && started.ok)
332
338
  browserServer = started.server;
@@ -367,6 +373,10 @@ async function scoutWith(store, request, outputDir) {
367
373
  clock,
368
374
  onStreamEvent: observe,
369
375
  });
376
+ // The run says which exception its browser ran under: Chrome's own sandbox, not Toolroll's fence (recorded after the
377
+ // gateway, whose tool record would replace it).
378
+ if (browserServer !== null)
379
+ store.recordRunBrowser(request.runId, { containment: browserCheck.SCOUT_BROWSER_CONTAINMENT, tool: browserCheck.SCOUT_BROWSER_PACKAGE }, new Date());
370
380
  }
371
381
  finally {
372
382
  if (pulseTimer !== undefined)
package/dist/serve.js CHANGED
@@ -16,6 +16,7 @@ import { boundProjectSummary, browserCrewFromIndex, browserCrewOf, browserNaviga
16
16
  import { sharedActionPayload } from './chat-actions.js';
17
17
  import { prepareCodingContext } from './coding-context.js';
18
18
  import { CodingWorkspace } from './coding-workspace.js';
19
+ import { codingSessionPermissions } from './coding-provider.js';
19
20
  import { CONSOLE_FORMS, readForm } from "./contracts/console-api.js";
20
21
  import { createFlowRooms, flowFingerprint } from "./flow-live.js";
21
22
  import {} from "./google-mail.js";
@@ -195,7 +196,7 @@ export function createDecisionServer(options) {
195
196
  const database = store.handle.prepare('PRAGMA database_list').all().find(row => row['name'] === 'main')?.['file'];
196
197
  if (typeof database === 'string' && database !== '') {
197
198
  try {
198
- coding = new CodingWorkspace({ database: `${database}.coding.sqlite`, worktreeRoot: join(dirname(database), 'coding-worktrees'), admissionPaused: () => updateAdmissionPaused(store.raw()), authorize: (actor, repo) => codingActorAllowed(actor) && codingProjectAllowed(repo), context: input => prepareCodingContext(store, { ...input, root: join(dirname(database), 'coding-context') }) });
199
+ coding = new CodingWorkspace({ database: `${database}.coding.sqlite`, worktreeRoot: join(dirname(database), 'coding-worktrees'), permissions: codingSessionPermissions(store, join(dirname(database), 'coding-worktrees')), admissionPaused: () => updateAdmissionPaused(store.raw()), authorize: (actor, repo) => codingActorAllowed(actor) && codingProjectAllowed(repo), context: input => prepareCodingContext(store, { ...input, root: join(dirname(database), 'coding-context') }) });
199
200
  }
200
201
  catch (error) {
201
202
  codingProblem = error instanceof Error ? error.message : 'The coding workspace could not open.';
@@ -450,6 +451,7 @@ export function createDecisionServer(options) {
450
451
  access: projectAccessOf(repo),
451
452
  network: { setting: store.projectNetwork(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
452
453
  writes: { setting: store.projectWrites(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
454
+ sandbox: { setting: store.projectSandbox(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
453
455
  checkAccess: {
454
456
  setting: store.projectCheckAccess(repo), changed: store.projectCheckAccessChange(repo),
455
457
  canNarrow: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo),
@@ -2,6 +2,7 @@
2
2
  import { networkWords } from "../egress.js";
3
3
  import { writesWords } from "../writes-mode.js";
4
4
  import { checkAccessWords } from "../check-access.js";
5
+ import { sandboxTermsWords } from "../project-sandbox.js";
5
6
  import { ACP_AGENTS, isAcpProfile } from "../acp-agents.js";
6
7
  import { html, htmlString, joinHtml, postForm, replaceMarkup, reauth } from "../html.js";
7
8
  import { closedQuestionReason, closedQuestionWords } from "../decision.js";
@@ -542,7 +543,9 @@ export function profileWords(scope) {
542
543
  ? html `<p class="meta">Runs on <span class="mono">${ACP_AGENTS[profile.provider].name} (via ACP) · ${profile.model}</span> — full access inside the OS fence; the agent runs any tool it chooses and nothing asks. No turn limit: stops after ${Math.round(profile.timeoutSeconds / 60)} min, or ${Math.round(profile.idleTimeoutSeconds / 60)} min silent; repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min, or ${Math.round(profile.repairIdleTimeoutSeconds / 60)} min silent.</p>`
543
544
  : html `<p class="meta">Runs on <span class="mono">${profile.provider} · ${profile.model}</span> — ${profile.sandboxMode === "danger-full-access" ? "FULL permissions via --dangerously-bypass-approvals-and-sandbox; nothing asks" : "workspace-write sandbox"}, no turn limit (${Math.round(profile.timeoutSeconds / 60)} min ${profile.timeoutKind === "idle" ? "without progress" : "per attempt"}); repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min</p>`;
544
545
  // What the build may reach is part of what is approved (egress.ts): said on every approval form.
545
- return html `${base}<p class="meta" data-scope-network>${networkWords(profile.network ?? null)}</p><p class="meta" data-scope-writes>Writes: ${writesWords(profile.writes)}.</p>${profile.checkAccess === undefined ? "" : html `<p class="meta" data-scope-checks>Checks: ${checkAccessWords(profile.checkAccess)}.</p>`}`;
546
+ // Its sandbox terms (project-sandbox.ts): what it reads always, and any widening, part of what the yes agrees to.
547
+ const terms = sandboxTermsWords(profile);
548
+ return html `${base}<p class="meta" data-scope-network>${networkWords(profile.network ?? null)}</p><p class="meta" data-scope-writes>Writes: ${writesWords(profile.writes)}.</p>${profile.checkAccess === undefined ? "" : html `<p class="meta" data-scope-checks>Checks: ${checkAccessWords(profile.checkAccess)}.</p>`}<p class="meta" data-scope-reads>Reads: ${terms.reads}.</p>${terms.mach === null ? "" : html `<p class="meta" data-scope-mach>${terms.mach.charAt(0).toUpperCase()}${terms.mach.slice(1)}.</p>`}`;
546
549
  }
547
550
  export const ROLE_NOUN = { plan: "Planner", build: "Builder", repair: "Repair", review: "Reviewer" };
548
551
  export function agentsStandingWords(view) {
@@ -285,6 +285,7 @@ post("settings", "settings.project-live-view", "/settings/project/live-view", {
285
285
  post("settings", "settings.project-network", "/settings/project/network", { scope: "step-up", callers: ["cookie"] });
286
286
  post("settings", "settings.project-writes", "/settings/project/writes", { scope: "step-up", callers: ["cookie"] });
287
287
  post("settings", "settings.project-check-access", "/settings/project/check-access", { scope: "step-up", callers: ["cookie"] });
288
+ post("settings", "settings.project-sandbox", "/settings/project/sandbox", { scope: "step-up", callers: ["cookie"] });
288
289
  post("settings", "settings.project-delete", "/settings/project/delete", { scope: "step-up", ...operator("An instance operator deletes projects.", "/settings/project") });
289
290
  // Lead sweeps (docs/plans/lead-fan-out.md): the table, and cancelling what isn't ready. The handler shows a sweep only in
290
291
  // a project the reader can see, and acts only for an approver.
@@ -82,6 +82,7 @@ import { parseProjectConcurrency, saveProjectConcurrency } from "../project-conc
82
82
  import { MAX_NETWORK_HOSTS, normalizeHost } from "../egress.js";
83
83
  import { isWritesMode } from "../writes-mode.js";
84
84
  import { isCheckAccess, signInsUnavailableWords, widerThan } from "../check-access.js";
85
+ import { parseMachLookup, parseReadDeny } from "../project-sandbox.js";
85
86
  import { projectDeleteConfirmHtml, projectSettingsHtml } from "../project-delete-ui.js";
86
87
  import { deleteProject, holdingsWords } from "../project-delete.js";
87
88
  import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView } from "../project-knowledge.js";
@@ -2487,6 +2488,7 @@ ${modeWords(terms)
2487
2488
  "settings.project-network": projectNetwork,
2488
2489
  "settings.project-writes": projectWrites,
2489
2490
  "settings.project-check-access": projectCheckAccess,
2491
+ "settings.project-sandbox": projectSandbox,
2490
2492
  "settings.project-delete": projectDelete,
2491
2493
  "settings.policy-send": policySave,
2492
2494
  "settings.approval-send": approvalSave,
@@ -2624,5 +2626,30 @@ ${modeWords(terms)
2624
2626
  return back("problem", "Check access changed meanwhile, and this would now widen it. Nothing changed.");
2625
2627
  return back("said", changed.before === changed.after ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2626
2628
  }
2629
+ // Settings → Project → Home folder (project-sandbox.ts, sandbox parity C9): what the project's builds and checks may not
2630
+ // read, and the macOS services they may also use. An approver for the project changes it with
2631
+ // their password typed now; the ledger keeps before → after. New scopes seal it; approved ones keep theirs.
2632
+ async function projectSandbox(ctx) {
2633
+ const { who, response, now, posted } = ctx;
2634
+ const body = readForm(posted, CONSOLE_FORMS.projectSandbox);
2635
+ const repo = body.get("repo") ?? "";
2636
+ if (!consoleProjects().includes(repo) || !visible(repo))
2637
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
2638
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
2639
+ return refuse(response, who, 403, "An approver for this project changes its sandbox.", "/settings/project");
2640
+ const back = sayTo(response, `/settings/project?repo=${encodeURIComponent(repo)}`, "#sandbox");
2641
+ const lines = (name) => (body.get(name) ?? "").split(/\r?\n/);
2642
+ const parsed = { readDeny: parseReadDeny(lines("read-deny")), machLookup: parseMachLookup(lines("mach")) };
2643
+ for (const one of Object.values(parsed))
2644
+ if (!one.ok)
2645
+ return back("problem", `${one.problem.charAt(0).toUpperCase()}${one.problem.slice(1)}. Nothing changed.`);
2646
+ if (!authenticateCredential(who, body.get("password") ?? "").ok)
2647
+ return back("problem", "Enter your Toolroll password to change the project's sandbox.");
2648
+ const wanted = { readDeny: parsed.readDeny.ok ? parsed.readDeny.list : [], machLookup: parsed.machLookup.ok ? parsed.machLookup.list : [] };
2649
+ const changed = store.setProjectSandbox(repo, wanted, who.name, now);
2650
+ if (changed === null)
2651
+ return back("problem", "An approver for this project changes its sandbox. Nothing changed.");
2652
+ return back("said", JSON.stringify(changed.before) === JSON.stringify(changed.after) ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2653
+ }
2627
2654
  return { registrations, modelsView };
2628
2655
  }