toolroll 0.9.69 → 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 +47 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +180 -115
- package/dist/agent-fence.js +413 -535
- package/dist/agentconfig.js +6 -1
- package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
- package/dist/builder.js +24 -4
- package/dist/check-access.d.ts +6 -9
- package/dist/check-access.js +7 -10
- 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 +1 -0
- package/dist/contracts/console-api.js +1 -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 +18 -8
- package/dist/invoke.js +98 -115
- package/dist/operate.js +46 -0
- package/dist/project-delete-ui.d.ts +7 -6
- package/dist/project-delete-ui.js +36 -2
- 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/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 +28 -0
- package/dist/scope.js +25 -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 +3 -1
- package/dist/server/render-tasks.js +4 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +27 -0
- package/dist/store.d.ts +31 -1
- package/dist/store.js +83 -4
- package/dist/subagent-tools.d.ts +1 -0
- package/dist/subagent-tools.js +7 -5
- 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/scout-browser.js
CHANGED
|
@@ -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 {
|
|
18
|
-
import {
|
|
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
|
|
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 :
|
|
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:
|
|
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
|
|
66
|
-
browserTool?:
|
|
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 {
|
|
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
|
-
|
|
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: { ...
|
|
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
|
-
|
|
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.
|
package/dist/server/settings.js
CHANGED
|
@@ -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
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type WritesMode } from "./writes-mode.js";
|
|
2
2
|
import { type CheckAccess } from "./check-access.js";
|
|
3
|
+
import { type ProjectSandbox } from "./project-sandbox.js";
|
|
4
|
+
import type { FencePolicy } from "./agent-fence.js";
|
|
3
5
|
import { type NetworkProfile, type ProjectNetwork, type RunNetworkRecord } from "./egress.js";
|
|
4
6
|
import { type AcpLaunch } from "./acp-attest.js";
|
|
5
7
|
import { type ChatBinding } from "./chat-core.js";
|
|
@@ -180,7 +182,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
180
182
|
hash: string;
|
|
181
183
|
} | null;
|
|
182
184
|
};
|
|
183
|
-
export declare const SCHEMA_VERSION =
|
|
185
|
+
export declare const SCHEMA_VERSION = 133;
|
|
184
186
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
185
187
|
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
186
188
|
/**
|
|
@@ -321,6 +323,8 @@ export declare const CONFINEMENT_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_
|
|
|
321
323
|
export declare const PROJECT_WRITES_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_writes (\n repo TEXT PRIMARY KEY,\n mode TEXT NOT NULL CHECK (mode IN ('workspace', 'anywhere')),\n updated_at TEXT NOT NULL,\n updated_by TEXT NOT NULL\n);\n";
|
|
322
324
|
/** v132: how far a project's checks reach beyond the check fence (check-access.ts CheckAccess). */
|
|
323
325
|
export declare const PROJECT_CHECK_ACCESS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_check_access (\n repo TEXT PRIMARY KEY,\n level TEXT NOT NULL CHECK (level IN ('sandboxed', 'sign-ins', 'unsandboxed')),\n updated_at TEXT NOT NULL,\n updated_by TEXT NOT NULL\n);\n";
|
|
326
|
+
/** v133: a project's sandbox settings (project-sandbox.ts): JSON lists, each sorted, distinct and valid. */
|
|
327
|
+
export declare const PROJECT_SANDBOX_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_sandbox (\n repo TEXT PRIMARY KEY,\n read_deny_json TEXT NOT NULL DEFAULT '[]',\n mach_lookup_json TEXT NOT NULL DEFAULT '[]',\n updated_at TEXT NOT NULL,\n updated_by TEXT NOT NULL\n);\n";
|
|
324
328
|
/** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
|
|
325
329
|
* parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
|
|
326
330
|
export declare const RUN_EFFICIENCY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_efficiency (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n parser_version INTEGER NOT NULL,\n steps TEXT NOT NULL CHECK (steps IN ('complete','truncated','expired','absent')),\n steps_reason TEXT,\n payload_json TEXT NOT NULL CHECK (length(payload_json) <= 16384),\n derived_at TEXT NOT NULL\n);\n";
|
|
@@ -6138,6 +6142,9 @@ export declare class Store {
|
|
|
6138
6142
|
recordRunFence(run: number, fence: {
|
|
6139
6143
|
method: string;
|
|
6140
6144
|
paths: number;
|
|
6145
|
+
policy?: 1 | 2;
|
|
6146
|
+
digest?: string;
|
|
6147
|
+
engine?: string;
|
|
6141
6148
|
}, now: Date): void;
|
|
6142
6149
|
/** What one attempt's network was and what its proxy saw (egress.ts), kept beside its fence. */
|
|
6143
6150
|
recordRunNetwork(run: number, network: RunNetworkRecord, now: Date): void;
|
|
@@ -6145,6 +6152,9 @@ export declare class Store {
|
|
|
6145
6152
|
runFence(run: number): {
|
|
6146
6153
|
method: string;
|
|
6147
6154
|
paths: number;
|
|
6155
|
+
policy?: 1 | 2;
|
|
6156
|
+
digest?: string;
|
|
6157
|
+
engine?: string;
|
|
6148
6158
|
} | null;
|
|
6149
6159
|
runTools(run: number): {
|
|
6150
6160
|
tools: {
|
|
@@ -7201,6 +7211,26 @@ export declare class Store {
|
|
|
7201
7211
|
before: CheckAccess;
|
|
7202
7212
|
after: CheckAccess;
|
|
7203
7213
|
} | null;
|
|
7214
|
+
/** The scout's browser ran outside Toolroll's fence under its launch contract (scout-browser.ts): which exception. */
|
|
7215
|
+
recordRunBrowser(run: number, browser: {
|
|
7216
|
+
containment: string;
|
|
7217
|
+
tool: string;
|
|
7218
|
+
}, now: Date): void;
|
|
7219
|
+
runBrowser(run: number): {
|
|
7220
|
+
containment: string;
|
|
7221
|
+
tool: string;
|
|
7222
|
+
} | null;
|
|
7223
|
+
/** A project's sandbox settings (project-sandbox.ts): its saved lists, or none. A damaged row keeps what it can read
|
|
7224
|
+
* of its read denies (never fewer) and grants nothing else. */
|
|
7225
|
+
projectSandbox(repo: string): ProjectSandbox;
|
|
7226
|
+
/** An approver changed a project's sandbox settings (their password was proved by the caller): saved whole, and in
|
|
7227
|
+
* the ledger as before → after. The account is proved again inside the write. Refused: null, nothing written. */
|
|
7228
|
+
setProjectSandbox(repo: string, wanted: ProjectSandbox, by: string, now: Date): {
|
|
7229
|
+
before: ProjectSandbox;
|
|
7230
|
+
after: ProjectSandbox;
|
|
7231
|
+
} | null;
|
|
7232
|
+
/** The fence policy a flow was made under (agent-fence.ts FencePolicy): v2 for one made from 0.9.70; v1 otherwise. */
|
|
7233
|
+
flowFencePolicy(flow: number): FencePolicy;
|
|
7204
7234
|
}
|
|
7205
7235
|
/** The receipt a bot's first pairing writes over pre-pairing history: an
|
|
7206
7236
|
* explicit skip, with no delivered timestamp and no attempt, so suppressed
|