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.
- package/CHANGELOG.md +67 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +184 -115
- package/dist/agent-fence.js +418 -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 +8 -11
- 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 +25 -8
- package/dist/invoke.js +115 -115
- package/dist/operate.js +46 -0
- package/dist/project-delete-ui.d.ts +7 -6
- package/dist/project-delete-ui.js +37 -3
- 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 +109 -0
- package/dist/sandbox-runtime.js +281 -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
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Project: what Toolroll holds for a project, and deleting
|
|
3
|
+
* it. An instance operator deletes in two steps: type the project's name,
|
|
4
|
+
* then read exactly what goes and confirm with their password (or a fresh
|
|
5
|
+
* sign-in with the identity provider). The repository itself stays.
|
|
6
|
+
*/
|
|
7
|
+
import { sandboxRuntimeWords } from "./sandbox-runtime.js";
|
|
1
8
|
import { CHECK_ACCESS_WARNINGS, signInHostsMissing } from "./check-access.js";
|
|
2
9
|
import { html, postForm, reauth } from "./html.js";
|
|
3
10
|
import { holdingsWords } from "./project-delete.js";
|
|
@@ -49,6 +56,33 @@ function writesHtml(view) {
|
|
|
49
56
|
<button type="submit">Save</button>`, { hidden: { repo: view.repo } });
|
|
50
57
|
return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<details><summary>Change</summary>${form}</details></section>`;
|
|
51
58
|
}
|
|
59
|
+
/** Home folder (project-sandbox.ts): what builds and checks can read there, said once; the paths they can't, and the
|
|
60
|
+
* advanced list (macOS services), changed under Change with the approver's password. */
|
|
61
|
+
function sandboxHtml(view) {
|
|
62
|
+
const sandbox = view.sandbox;
|
|
63
|
+
if (sandbox === undefined)
|
|
64
|
+
return "";
|
|
65
|
+
const { readDeny, machLookup } = sandbox.setting;
|
|
66
|
+
const said = readDeny.length === 0
|
|
67
|
+
? html `<p>Builds and checks can read your home folder, as in Anthropic's sandbox runtime. Toolroll's own folders stay out of reach; other tasks' work is read, never written.</p>`
|
|
68
|
+
: html `<p>Builds and checks can read your home folder except <strong>${readDeny.length === 1 ? readDeny[0] : `${readDeny.length} paths`}</strong>.</p>`;
|
|
69
|
+
const extra = machLookup.length === 0 ? "" : html `<p class="meta">${machLookup.length} macOS ${machLookup.length === 1 ? "service" : "services"} allowed</p>`;
|
|
70
|
+
// The engine, named where the sandbox is set (sandbox-runtime.ts).
|
|
71
|
+
const engine = html `<p class="meta" data-sandbox-engine>Builds, checks and flows run in ${sandboxRuntimeWords()}.</p>`;
|
|
72
|
+
const head = html `<h2 id="sandbox-title">Home folder</h2>${said}${extra}${engine}`;
|
|
73
|
+
if (!sandbox.canChange)
|
|
74
|
+
return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<p class="meta">An approver for this project can change this.</p></section>`;
|
|
75
|
+
const form = postForm("/settings/project/sandbox", html `
|
|
76
|
+
<label>Paths builds can't read<textarea name="read-deny" rows="4" placeholder="~/.ssh ~/.aws">${readDeny.join("\n")}</textarea></label>
|
|
77
|
+
<p class="meta">One per line: <code>~/…</code> or a full path.</p>
|
|
78
|
+
<details><summary>Advanced</summary>
|
|
79
|
+
<label>macOS services builds may also use<textarea name="mach" rows="2">${machLookup.join("\n")}</textarea></label>
|
|
80
|
+
</details>
|
|
81
|
+
<p class="meta">Tasks scoped from now on use this; tasks already approved keep what they were approved with.</p>
|
|
82
|
+
${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}
|
|
83
|
+
<button type="submit">Save</button>`, { hidden: { repo: view.repo } });
|
|
84
|
+
return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<details><summary>Change</summary>${form}</details></section>`;
|
|
85
|
+
}
|
|
52
86
|
/** Check access (check-access.ts): how far the project's checks reach, said once. Collapsed: the level, what it means
|
|
53
87
|
* when it isn't the default, and who changed it. Inside Change: a line per level, and each wider level's warning under it
|
|
54
88
|
* only while it is chosen. An instance operator widens it with a password typed now; an approver narrows it. */
|
|
@@ -62,7 +96,7 @@ function checkAccessHtml(view) {
|
|
|
62
96
|
const setting = unavailable !== null && saved === "sign-ins" ? "sandboxed" : saved;
|
|
63
97
|
const network = view.network?.setting ?? null;
|
|
64
98
|
const names = { sandboxed: "Sandboxed", "sign-ins": "Sandboxed, with model and GitHub sign-ins", unsandboxed: "Not sandboxed" };
|
|
65
|
-
const meaning = { sandboxed: null, "sign-ins": "Checks can read your Claude, Codex and GitHub sign-ins.", unsandboxed: "Checks run with your full access to this machine and to Toolroll." };
|
|
99
|
+
const meaning = { sandboxed: null, "sign-ins": "Checks can read your Claude, Codex and GitHub sign-ins, and on a Mac your Keychain (any item every program may read).", unsandboxed: "Checks run with your full access to this machine and to Toolroll." };
|
|
66
100
|
// The sign-ins never widen the network: how many hosts they'd still need, and where to add them (a change of its own).
|
|
67
101
|
const missing = setting === "sign-ins" && network !== null ? signInHostsMissing(network) : [];
|
|
68
102
|
const hosts = missing.length === 0 ? "" : html `<p class="meta" data-sign-in-hosts>${missing.length} sign-in ${missing.length === 1 ? "host isn't" : "hosts aren't"} allowed yet · <a href="#network">Add them</a></p><details class="hosts"><summary>Show hosts</summary><p class="tool-name">${missing.join(", ")}</p></details>`;
|
|
@@ -83,7 +117,7 @@ function checkAccessHtml(view) {
|
|
|
83
117
|
};
|
|
84
118
|
const open = network?.mode === "open";
|
|
85
119
|
const form = postForm("/settings/project/check-access", html `
|
|
86
|
-
${option("sandboxed", "
|
|
120
|
+
${option("sandboxed", "Reads your home folder, as in Anthropic's sandbox runtime, except the folders this project hides (Home folder, above). Never Toolroll's folders or other tasks' work.", null)}
|
|
87
121
|
${unavailable !== null ? option("sign-ins", unavailable, null) : option("sign-ins", `Also reads the files your Claude, Codex and GitHub sign-ins are kept in, on this project's network (${open ? "any host" : network === null || network.hosts.length === 0 ? "registries only" : `registries and ${network.hosts.length} ${network.hosts.length === 1 ? "host" : "hosts"}`}).`, CHECK_ACCESS_WARNINGS["sign-ins"])}
|
|
88
122
|
${option("unsandboxed", "For projects whose checks create sandboxes themselves, such as Toolroll's own.", CHECK_ACCESS_WARNINGS.unsandboxed)}
|
|
89
123
|
${open && unavailable === null ? html `<label class="check"><input type="checkbox" name="confirm-open" value="yes"> The network is open: with sign-ins, checks could send them to any host</label>` : ""}
|
|
@@ -170,7 +204,7 @@ export function projectSettingsHtml(view, notice) {
|
|
|
170
204
|
const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
|
|
171
205
|
? html `<p class="path">${view.remoteUrl}</p>`
|
|
172
206
|
: html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add <project> --remote <url></p></details></section>`;
|
|
173
|
-
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
|
|
207
|
+
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${sandboxHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
|
|
174
208
|
if (!view.canDelete)
|
|
175
209
|
return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
|
|
176
210
|
const body = view.running.length > 0
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type ProjectSandbox = {
|
|
2
|
+
readDeny: string[];
|
|
3
|
+
machLookup: string[];
|
|
4
|
+
};
|
|
5
|
+
export declare const PROJECT_SANDBOX_DEFAULT: ProjectSandbox;
|
|
6
|
+
export declare const MAX_READ_DENY = 100;
|
|
7
|
+
export declare const MAX_MACH_LOOKUP = 30;
|
|
8
|
+
/** A read-deny entry: `~/…` (the home folder of whoever runs the build) or an absolute path; normalized, no `..`, no
|
|
9
|
+
* pattern; `~` or `/` alone never (that would deny everything a build needs). */
|
|
10
|
+
export declare function validReadDeny(one: string): boolean;
|
|
11
|
+
/** Services no project can add, whatever it asks: the keychain and security services, the pasteboard, Launch Services
|
|
12
|
+
* and Apple Events (what a build would open or script another application with), the privacy database, the window
|
|
13
|
+
* server. Each name or prefix here matches the service and everything beneath it. */
|
|
14
|
+
export declare const MACH_NEVER: readonly string[];
|
|
15
|
+
/** A mach service a project may add: an exact reverse-DNS name, or a prefix ending `.*` after at least three labels
|
|
16
|
+
* (`com.example.tool.*`); never one of MACH_NEVER, nor a prefix that would reach one. */
|
|
17
|
+
export declare function validMachLookup(one: string): boolean;
|
|
18
|
+
/** Entries as a setting keeps them: trimmed, distinct, sorted; refused whole when any is invalid or there are too many. */
|
|
19
|
+
export declare function parseSandboxList(lines: readonly string[], valid: (one: string) => boolean, max: number): {
|
|
20
|
+
ok: true;
|
|
21
|
+
list: string[];
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
problem: string;
|
|
25
|
+
};
|
|
26
|
+
export declare const parseReadDeny: (lines: readonly string[]) => {
|
|
27
|
+
ok: true;
|
|
28
|
+
list: string[];
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
problem: string;
|
|
32
|
+
};
|
|
33
|
+
export declare const parseMachLookup: (lines: readonly string[]) => {
|
|
34
|
+
ok: true;
|
|
35
|
+
list: string[];
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
problem: string;
|
|
39
|
+
};
|
|
40
|
+
/** A stored or sealed setting, read strictly: anything unreadable reads as the narrowest (no extra services) but keeps
|
|
41
|
+
* whatever read-denies are valid (never fewer than were asked for). */
|
|
42
|
+
export declare function sandboxOf(raw: {
|
|
43
|
+
readDeny?: unknown;
|
|
44
|
+
machLookup?: unknown;
|
|
45
|
+
}): ProjectSandbox;
|
|
46
|
+
/** A read-deny entry as the path it names for a launch whose home folder is `home`. */
|
|
47
|
+
export declare const readDenyPath: (one: string, home: string) => string;
|
|
48
|
+
/** What an approval's sandbox terms say, one line each, for every surface that asks for a yes (scope.ts describeScope,
|
|
49
|
+
* the console's scope view): the fence policy and its read denies always, the macOS services when there are any. */
|
|
50
|
+
export declare function sandboxTermsWords(profile: {
|
|
51
|
+
fencePolicy?: 2;
|
|
52
|
+
readDeny?: readonly string[];
|
|
53
|
+
machLookup?: readonly string[];
|
|
54
|
+
}): {
|
|
55
|
+
reads: string;
|
|
56
|
+
mach: string | null;
|
|
57
|
+
};
|
|
58
|
+
/** The setting in a sentence, for the CLI and the settings page. */
|
|
59
|
+
export declare function readDenyWords(list: readonly string[]): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project's sandbox settings (docs/plans/sandbox-parity.md C9), an approver's, each sealed into new scopes:
|
|
3
|
+
*
|
|
4
|
+
* - `readDeny`: what its builds and checks may not read (Anthropic's sandbox runtime `denyRead`). Under fence policy v2
|
|
5
|
+
* the home folder is readable as in the sandbox runtime; this is how a project hides `~/.ssh` and the like again.
|
|
6
|
+
* Narrowing only: it applies to running launches' next start too (flows, coding sessions read it live).
|
|
7
|
+
* - `machLookup`: macOS services its builds may also reach (the sandbox runtime `allowMachLookup`), for toolchains the
|
|
8
|
+
* deny-default baseline breaks (Go's TLS, Playwright). Widening: sealed, and only a person's password yes seals it.
|
|
9
|
+
*/
|
|
10
|
+
import { isAbsolute, normalize } from "node:path";
|
|
11
|
+
export const PROJECT_SANDBOX_DEFAULT = { readDeny: [], machLookup: [] };
|
|
12
|
+
export const MAX_READ_DENY = 100;
|
|
13
|
+
export const MAX_MACH_LOOKUP = 30;
|
|
14
|
+
/** A read-deny entry: `~/…` (the home folder of whoever runs the build) or an absolute path; normalized, no `..`, no
|
|
15
|
+
* pattern; `~` or `/` alone never (that would deny everything a build needs). */
|
|
16
|
+
export function validReadDeny(one) {
|
|
17
|
+
if (one.length === 0 || one.length > 400 || one.includes("\0") || /[*?[\]{}]/.test(one))
|
|
18
|
+
return false;
|
|
19
|
+
const path = one.startsWith("~/") ? one.slice(2) : isAbsolute(one) ? one.slice(1) : null;
|
|
20
|
+
if (path === null || path === "" || path.endsWith("/"))
|
|
21
|
+
return false;
|
|
22
|
+
return path.split("/").every(part => part !== "" && part !== "." && part !== "..") && normalize(`/${path}`) === `/${path}`;
|
|
23
|
+
}
|
|
24
|
+
/** Services no project can add, whatever it asks: the keychain and security services, the pasteboard, Launch Services
|
|
25
|
+
* and Apple Events (what a build would open or script another application with), the privacy database, the window
|
|
26
|
+
* server. Each name or prefix here matches the service and everything beneath it. */
|
|
27
|
+
export const MACH_NEVER = ["com.apple.security", "com.apple.SecurityServer", "com.apple.securityd", "com.apple.pasteboard",
|
|
28
|
+
"com.apple.coreservices.launchservicesd", "com.apple.lsd", "com.apple.coreservices.appleevents", "com.apple.tccd", "com.apple.windowserver",
|
|
29
|
+
"com.apple.CoreServices.coreservicesd"];
|
|
30
|
+
/** A mach service a project may add: an exact reverse-DNS name, or a prefix ending `.*` after at least three labels
|
|
31
|
+
* (`com.example.tool.*`); never one of MACH_NEVER, nor a prefix that would reach one. */
|
|
32
|
+
export function validMachLookup(one) {
|
|
33
|
+
if (one.length > 200)
|
|
34
|
+
return false;
|
|
35
|
+
const prefix = one.endsWith(".*");
|
|
36
|
+
const name = prefix ? one.slice(0, -2) : one;
|
|
37
|
+
const labels = name.split(".");
|
|
38
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_@-]+)+$/.test(name) || labels.length < (prefix ? 3 : 2))
|
|
39
|
+
return false;
|
|
40
|
+
const lower = name.toLowerCase();
|
|
41
|
+
return !MACH_NEVER.some(never => { const blocked = never.toLowerCase(); return lower === blocked || lower.startsWith(blocked) || (prefix && blocked.startsWith(`${lower}.`)); });
|
|
42
|
+
}
|
|
43
|
+
/** Entries as a setting keeps them: trimmed, distinct, sorted; refused whole when any is invalid or there are too many. */
|
|
44
|
+
export function parseSandboxList(lines, valid, max) {
|
|
45
|
+
const list = [...new Set(lines.map(one => one.trim()).filter(one => one !== ""))].sort();
|
|
46
|
+
const bad = list.find(one => !valid(one));
|
|
47
|
+
if (bad !== undefined)
|
|
48
|
+
return { ok: false, problem: `“${bad.slice(0, 80)}” isn't a valid entry` };
|
|
49
|
+
if (list.length > max)
|
|
50
|
+
return { ok: false, problem: `at most ${max} entries` };
|
|
51
|
+
return { ok: true, list };
|
|
52
|
+
}
|
|
53
|
+
export const parseReadDeny = (lines) => parseSandboxList(lines, validReadDeny, MAX_READ_DENY);
|
|
54
|
+
export const parseMachLookup = (lines) => parseSandboxList(lines, validMachLookup, MAX_MACH_LOOKUP);
|
|
55
|
+
/** A stored or sealed setting, read strictly: anything unreadable reads as the narrowest (no extra services) but keeps
|
|
56
|
+
* whatever read-denies are valid (never fewer than were asked for). */
|
|
57
|
+
export function sandboxOf(raw) {
|
|
58
|
+
const strings = (value) => Array.isArray(value) ? value.filter((one) => typeof one === "string") : [];
|
|
59
|
+
const readDeny = parseReadDeny(strings(raw.readDeny));
|
|
60
|
+
const mach = parseMachLookup(strings(raw.machLookup));
|
|
61
|
+
return {
|
|
62
|
+
readDeny: readDeny.ok ? readDeny.list : strings(raw.readDeny).filter(validReadDeny),
|
|
63
|
+
machLookup: mach.ok ? mach.list : [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** A read-deny entry as the path it names for a launch whose home folder is `home`. */
|
|
67
|
+
export const readDenyPath = (one, home) => one.startsWith("~/") ? `${home}/${one.slice(2)}` : one;
|
|
68
|
+
/** What an approval's sandbox terms say, one line each, for every surface that asks for a yes (scope.ts describeScope,
|
|
69
|
+
* the console's scope view): the fence policy and its read denies always, the macOS services when there are any. */
|
|
70
|
+
export function sandboxTermsWords(profile) {
|
|
71
|
+
const reads = profile.fencePolicy === 2
|
|
72
|
+
? `your home folder, as in Anthropic's sandbox runtime${(profile.readDeny ?? []).length === 0 ? "" : `, except ${(profile.readDeny ?? []).join(", ")}`}; never Toolroll's own folders; other tasks' work read, never written`
|
|
73
|
+
: "not your home folder's credentials and agent settings (approved before home reads opened)";
|
|
74
|
+
return {
|
|
75
|
+
reads,
|
|
76
|
+
mach: (profile.machLookup ?? []).length === 0 ? null : `also reaches these macOS services: ${(profile.machLookup ?? []).join(", ")}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** The setting in a sentence, for the CLI and the settings page. */
|
|
80
|
+
export function readDenyWords(list) {
|
|
81
|
+
return list.length === 0 ? "Builds and checks can read your home folder, as in Anthropic's sandbox runtime; Toolroll's own folders stay out of reach, and other tasks' work is read, never written."
|
|
82
|
+
: `Builds and checks can read your home folder except ${list.length === 1 ? list[0] : `${list.length} paths (${list.slice(0, 3).join(", ")}${list.length > 3 ? ", …" : ""})`}.`;
|
|
83
|
+
}
|
package/dist/project-tools.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Store, ToolActionInfo } from "./store.js";
|
|
2
2
|
import { type BrokerOptions } from "./tool-broker.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type FenceMethod, type LaunchFence } from "./agent-fence.js";
|
|
4
4
|
import type { ProviderId } from "./contracts/provider.js";
|
|
5
5
|
import { type SecretStoreKind } from "./tool-secret-store.js";
|
|
6
6
|
import { type ToolSecret, type ToolSpec } from "./contracts/integration-metadata.js";
|
|
@@ -105,10 +105,11 @@ export type FoundTool = {
|
|
|
105
105
|
*/
|
|
106
106
|
export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv): FoundTool[];
|
|
107
107
|
/** Start the server once, ask for its tools, and stop it: the proof a build can use it. */
|
|
108
|
-
export declare function testTool(spec: ToolSpec, values: Record<string, string>, options
|
|
108
|
+
export declare function testTool(spec: ToolSpec, values: Record<string, string>, options: {
|
|
109
109
|
timeoutMs?: number;
|
|
110
110
|
env?: NodeJS.ProcessEnv;
|
|
111
111
|
omitEnv?: readonly string[];
|
|
112
|
+
server: ToolServer;
|
|
112
113
|
}): Promise<{
|
|
113
114
|
ok: true;
|
|
114
115
|
tools: string[];
|
|
@@ -121,10 +122,11 @@ export declare function testTool(spec: ToolSpec, values: Record<string, string>,
|
|
|
121
122
|
* its description, what input it takes, and whether it says it only reads.
|
|
122
123
|
* A subagent is offered these, each under its own rule.
|
|
123
124
|
*/
|
|
124
|
-
export declare function listProjectToolActions(spec: ToolSpec, values: Record<string, string>, options
|
|
125
|
+
export declare function listProjectToolActions(spec: ToolSpec, values: Record<string, string>, options: {
|
|
125
126
|
timeoutMs?: number;
|
|
126
127
|
env?: NodeJS.ProcessEnv;
|
|
127
128
|
omitEnv?: readonly string[];
|
|
129
|
+
server: ToolServer;
|
|
128
130
|
}): Promise<{
|
|
129
131
|
ok: true;
|
|
130
132
|
actions: ToolActionInfo[];
|
|
@@ -146,10 +148,11 @@ export type ToolCall = {
|
|
|
146
148
|
* "Use a tool" step. The answer's text parts are kept; a tool that says it
|
|
147
149
|
* failed (isError) is an answer, not trouble reaching it.
|
|
148
150
|
*/
|
|
149
|
-
export declare function callProjectTool(spec: ToolSpec, values: Record<string, string>, name: string, args: Record<string, unknown>, options
|
|
151
|
+
export declare function callProjectTool(spec: ToolSpec, values: Record<string, string>, name: string, args: Record<string, unknown>, options: {
|
|
150
152
|
timeoutMs?: number;
|
|
151
153
|
env?: NodeJS.ProcessEnv;
|
|
152
154
|
omitEnv?: readonly string[];
|
|
155
|
+
server: ToolServer;
|
|
153
156
|
}): Promise<ToolCall>;
|
|
154
157
|
/** The exact servers one launch gets, and those left out with why (`repo`: the project, from the run's record). */
|
|
155
158
|
export type ToolLaunch = {
|
|
@@ -259,10 +262,11 @@ export declare function addToolTo(store: ToolWrites, repo: string, input: ToolSp
|
|
|
259
262
|
/** Take one tool away from every build now, with its stored secrets. */
|
|
260
263
|
export declare function removeToolFrom(store: ToolWrites, repo: string, name: string, by: string, now: Date, home?: string): boolean;
|
|
261
264
|
/** Test one tool of a project and keep the answer on it. */
|
|
262
|
-
export declare function testToolOf(store: ToolWrites, repo: string, name: string, now: Date, options?: {
|
|
265
|
+
export declare function testToolOf(store: ToolWrites & ToolServerStore, repo: string, name: string, now: Date, options?: {
|
|
263
266
|
home?: string | undefined;
|
|
264
267
|
timeoutMs?: number;
|
|
265
268
|
omitEnv?: readonly string[];
|
|
269
|
+
available?: () => boolean;
|
|
266
270
|
}): Promise<ToolTest | null>;
|
|
267
271
|
/** Where a tool stands, in a few words: what a person reads first. */
|
|
268
272
|
export declare function toolStanding(tool: ProjectTool, secretsSet: readonly string[]): {
|
|
@@ -289,3 +293,23 @@ export declare function toolSecretStorage(): {
|
|
|
289
293
|
};
|
|
290
294
|
/** A command line split into program and arguments, honouring "double" and 'single' quotes. */
|
|
291
295
|
export declare function splitCommandLine(line: string): string[];
|
|
296
|
+
/** What a tool server started outside a build needs of the store: the plane's own records of its state and pools. */
|
|
297
|
+
export type ToolServerStore = Pick<Store, "databaseFile" | "listWorktrees" | "unfencedAgentsAllowed">;
|
|
298
|
+
/** How a tool server started outside a build runs (toolServerFor): inside `fence`, or with none where there is nothing to
|
|
299
|
+
* fence (a store with no database file) or an instance operator allowed unconfined agents; or not at all. */
|
|
300
|
+
export type ToolServer = {
|
|
301
|
+
fence: LaunchFence | null;
|
|
302
|
+
} | {
|
|
303
|
+
problem: string;
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* The server fence for a project tool started outside a build (docs/plans/sandbox-parity.md C2): discovery, its Test
|
|
307
|
+
* button, a flow's "Use a tool" step, a subagent's call. As the broker's servers are fenced: Toolroll's state, every
|
|
308
|
+
* other launch's folder and the checkout pools denied, and nothing a build writes (its project's caches) read, so no
|
|
309
|
+
* byte a build wrote can load into a process that holds the tool's secrets. Required: where this machine can't confine
|
|
310
|
+
* it, it doesn't start unless an instance operator allowed unconfined agents.
|
|
311
|
+
*/
|
|
312
|
+
export declare function toolServerFor(store: ToolServerStore, repo: string, options?: {
|
|
313
|
+
home?: string;
|
|
314
|
+
available?: () => boolean;
|
|
315
|
+
}): ToolServer;
|
package/dist/project-tools.js
CHANGED
|
@@ -24,11 +24,15 @@ import { createHash } from "node:crypto";
|
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { homedir, tmpdir } from "node:os";
|
|
27
|
+
import { realpathSync } from "node:fs";
|
|
27
28
|
import { dirname, isAbsolute, join } from "node:path";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
import { scanForSecrets } from "./evidence.js";
|
|
30
31
|
import { namedPath } from "./names.js";
|
|
31
32
|
import { startToolBroker } from "./tool-broker.js";
|
|
33
|
+
import { buildCacheFolder, fenceFor, linuxFenceAvailable, macosFenceAvailable, runFolders } from "./agent-fence.js";
|
|
34
|
+
import { confined, ContainmentRefusal } from "./exec.js";
|
|
35
|
+
import { poolRootOf } from "./worktree.js";
|
|
32
36
|
import { isAcpProvider } from "./acp-agents.js";
|
|
33
37
|
import { SecretStoreError, destroyOwnFile, prepareSecretFolder, registerSecretFolder, pathIn, readOwnFile, readSealed, removeValue, secretBackend, secretFolder, secretStorage, serviceOf, withSecretLock, withSecretLockAsync, writeOwnFile, writeSealed } from "./tool-secret-store.js";
|
|
34
38
|
import { ACCESS, GRANTED, REFRESH, mintRunToken, mintsRunTokens, oneAtATime, runScope, scopeList, signedIn } from "./oauth-run-token.js";
|
|
@@ -507,7 +511,7 @@ function toolNames(result) {
|
|
|
507
511
|
return Array.isArray(tools) ? tools.map(one => one.name).filter((name) => typeof name === "string").slice(0, 200) : [];
|
|
508
512
|
}
|
|
509
513
|
/** Start the server once, ask for its tools, and stop it: the proof a build can use it. */
|
|
510
|
-
export async function testTool(spec, values, options
|
|
514
|
+
export async function testTool(spec, values, options) {
|
|
511
515
|
const answer = await exchange(spec, values, { method: "tools/list", params: {}, doing: "listing its tools" }, options);
|
|
512
516
|
return answer.ok ? { ok: true, tools: toolNames(answer.result) } : answer;
|
|
513
517
|
}
|
|
@@ -516,7 +520,7 @@ export async function testTool(spec, values, options = {}) {
|
|
|
516
520
|
* its description, what input it takes, and whether it says it only reads.
|
|
517
521
|
* A subagent is offered these, each under its own rule.
|
|
518
522
|
*/
|
|
519
|
-
export async function listProjectToolActions(spec, values, options
|
|
523
|
+
export async function listProjectToolActions(spec, values, options) {
|
|
520
524
|
const answer = await exchange(spec, values, { method: "tools/list", params: {}, doing: "listing its tools" }, options);
|
|
521
525
|
return answer.ok ? { ok: true, actions: toolActions(answer.result) } : answer;
|
|
522
526
|
}
|
|
@@ -537,7 +541,7 @@ export function toolActions(result) {
|
|
|
537
541
|
* "Use a tool" step. The answer's text parts are kept; a tool that says it
|
|
538
542
|
* failed (isError) is an answer, not trouble reaching it.
|
|
539
543
|
*/
|
|
540
|
-
export async function callProjectTool(spec, values, name, args, options
|
|
544
|
+
export async function callProjectTool(spec, values, name, args, options) {
|
|
541
545
|
const answer = await exchange(spec, values, { method: "tools/call", params: { name, arguments: args }, doing: `using ${name}` }, options);
|
|
542
546
|
if (!answer.ok)
|
|
543
547
|
return answer;
|
|
@@ -552,9 +556,14 @@ function exchange(spec, values, request, options) {
|
|
|
552
556
|
if (missing.length > 0)
|
|
553
557
|
return Promise.resolve({ ok: false, problem: `Set ${missing.join(", ")} first.` });
|
|
554
558
|
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
555
|
-
|
|
559
|
+
if (spec.transport === "http")
|
|
560
|
+
return exchangeHttp(spec, values, timeoutMs, request);
|
|
561
|
+
// A local server starts only in the server fence its caller made (toolServerFor), never bare.
|
|
562
|
+
if ("problem" in options.server)
|
|
563
|
+
return Promise.resolve({ ok: false, problem: options.server.problem });
|
|
564
|
+
return exchangeStdio(spec, values, timeoutMs, options.env ?? process.env, options.omitEnv ?? [], request, options.server.fence);
|
|
556
565
|
}
|
|
557
|
-
function exchangeStdio(spec, values, timeoutMs, base, omit, request) {
|
|
566
|
+
function exchangeStdio(spec, values, timeoutMs, base, omit, request, fence) {
|
|
558
567
|
return new Promise(resolve => {
|
|
559
568
|
const env = { ...base };
|
|
560
569
|
for (const name of omit)
|
|
@@ -562,9 +571,14 @@ function exchangeStdio(spec, values, timeoutMs, base, omit, request) {
|
|
|
562
571
|
Object.assign(env, values);
|
|
563
572
|
let child;
|
|
564
573
|
try {
|
|
565
|
-
|
|
574
|
+
const launch = confined(spec.command, spec.args, { ...(fence ?? {}), temp: env["TMPDIR"] });
|
|
575
|
+
child = spawn(launch.file, launch.args, { env: { ...env, ...launch.env }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
566
576
|
}
|
|
567
577
|
catch (error) {
|
|
578
|
+
if (error instanceof ContainmentRefusal) {
|
|
579
|
+
resolve({ ok: false, problem: error.message });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
568
582
|
resolve({ ok: false, problem: `It could not start: ${error instanceof Error ? error.message : "unknown error"}.` });
|
|
569
583
|
return;
|
|
570
584
|
}
|
|
@@ -1004,7 +1018,8 @@ export async function testToolOf(store, repo, name, now, options = {}) {
|
|
|
1004
1018
|
const tool = projectToolsOf(store, repo).find(one => one.name === name);
|
|
1005
1019
|
if (tool === undefined)
|
|
1006
1020
|
return null;
|
|
1007
|
-
const
|
|
1021
|
+
const server = toolServerFor(store, repo, { ...(options.home === undefined ? {} : { home: options.home }), ...(options.available === undefined ? {} : { available: options.available }) });
|
|
1022
|
+
const outcome = await testTool(tool.spec, readToolSecrets(repo, name, options.home), { server, ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) });
|
|
1008
1023
|
const test = outcome.ok ? { at: now.toISOString(), ok: true, tools: outcome.tools, problem: null } : { at: now.toISOString(), ok: false, tools: [], problem: outcome.problem };
|
|
1009
1024
|
store.recordProjectToolTest(repo, name, JSON.stringify(test));
|
|
1010
1025
|
return test;
|
|
@@ -1093,3 +1108,24 @@ export function splitCommandLine(line) {
|
|
|
1093
1108
|
parts.push(current);
|
|
1094
1109
|
return parts;
|
|
1095
1110
|
}
|
|
1111
|
+
/**
|
|
1112
|
+
* The server fence for a project tool started outside a build (docs/plans/sandbox-parity.md C2): discovery, its Test
|
|
1113
|
+
* button, a flow's "Use a tool" step, a subagent's call. As the broker's servers are fenced: Toolroll's state, every
|
|
1114
|
+
* other launch's folder and the checkout pools denied, and nothing a build writes (its project's caches) read, so no
|
|
1115
|
+
* byte a build wrote can load into a process that holds the tool's secrets. Required: where this machine can't confine
|
|
1116
|
+
* it, it doesn't start unless an instance operator allowed unconfined agents.
|
|
1117
|
+
*/
|
|
1118
|
+
export function toolServerFor(store, repo, options = {}) {
|
|
1119
|
+
const databaseFile = store.databaseFile();
|
|
1120
|
+
if (databaseFile === null || databaseFile === "" || databaseFile === ":memory:")
|
|
1121
|
+
return { fence: null };
|
|
1122
|
+
const available = options.available ?? (() => macosFenceAvailable() || linuxFenceAvailable());
|
|
1123
|
+
if (!available())
|
|
1124
|
+
return store.unfencedAgentsAllowed() ? { fence: null }
|
|
1125
|
+
: { problem: "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." };
|
|
1126
|
+
const home = options.home ?? homedir();
|
|
1127
|
+
const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
|
|
1128
|
+
const cache = buildCacheFolder(repo, home);
|
|
1129
|
+
const untrusted = existsSync(cache) ? [realpathSync(cache)] : [];
|
|
1130
|
+
return { fence: fenceFor("tool-server", { databaseFile, worktree: null, home, boundary: null, binding: null, runFolders: runFolders(null), poolRoots, extra: untrusted }) };
|
|
1131
|
+
}
|
package/dist/release.d.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
/** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
|
|
32
32
|
* concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
|
|
33
|
-
export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "Windows Job Object containment"];
|
|
33
|
+
export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment"];
|
|
34
34
|
/** The checks a release tag runs (.github/workflows/publish.yml), awaited before Homebrew moves. */
|
|
35
35
|
export declare const RELEASE_TAG_CHECKS: readonly ["node 22 · macos-latest", "node 24 · macos-latest", "publish"];
|
|
36
36
|
export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
|
package/dist/release.js
CHANGED
|
@@ -37,6 +37,7 @@ export const REQUIRED_PR_CHECKS = [
|
|
|
37
37
|
"node 22 · ubuntu-latest",
|
|
38
38
|
"node 24 · ubuntu-latest",
|
|
39
39
|
"linux native containment · delegated cgroup v2",
|
|
40
|
+
"linux fence · sandbox-runtime",
|
|
40
41
|
"Windows Job Object containment",
|
|
41
42
|
];
|
|
42
43
|
/** The checks a release tag runs (.github/workflows/publish.yml), awaited before Homebrew moves. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Linux launch helper (sandbox-runtime.ts): `node sandbox-launch.js`, handed one launch in its environment
|
|
3
|
+
* (TOOLROLL_SANDBOX_LAUNCH). It changes into the launch's folder (Anthropic's sandbox-runtime anchors its mandatory
|
|
4
|
+
* write denies there), starts the runtime's network relays to this run's proxy when the network is confined, asks the
|
|
5
|
+
* runtime for the bubblewrap + seccomp command, runs it (setting it up again, from a fresh look, if the sandbox failed
|
|
6
|
+
* before the launch itself started), passes on the signals it gets, and when the command ends removes the mount points
|
|
7
|
+
* the runtime made and stops the relays. It exits with the command's status. Nothing runs if
|
|
8
|
+
* the runtime refuses: the helper exits 126 with the reason.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { checkLinuxDependencies, cleanupBwrapMountPoints, getLinuxDependencyStatus, initializeLinuxNetworkBridge, wrapCommandWithSandboxLinux } from "@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js";
|
|
12
|
+
import { SANDBOX_LAUNCH_SPEC, shellQuote, withRuntimeTemp } from "./sandbox-runtime.js";
|
|
13
|
+
const REFUSED = 126;
|
|
14
|
+
/** The status a launch stopped before it ran ends with (128 + SIGTERM). */
|
|
15
|
+
const STOPPED = 143;
|
|
16
|
+
/** Why the runtime can't sandbox here now (bubblewrap, socat or its seccomp helper gone), or null: asked again before
|
|
17
|
+
* each attempt, never remembered from an earlier look. */
|
|
18
|
+
function missing() {
|
|
19
|
+
const checked = checkLinuxDependencies();
|
|
20
|
+
if (checked.errors.length > 0)
|
|
21
|
+
return checked.errors.join("; ");
|
|
22
|
+
return getLinuxDependencyStatus().hasSeccompApply ? null : "the sandbox runtime's seccomp helper isn't available";
|
|
23
|
+
}
|
|
24
|
+
async function main() {
|
|
25
|
+
const raw = process.env[SANDBOX_LAUNCH_SPEC];
|
|
26
|
+
delete process.env[SANDBOX_LAUNCH_SPEC];
|
|
27
|
+
if (raw === undefined) {
|
|
28
|
+
process.stderr.write("toolroll sandbox: no launch was given\n");
|
|
29
|
+
return REFUSED;
|
|
30
|
+
}
|
|
31
|
+
const spec = JSON.parse(raw);
|
|
32
|
+
process.chdir(spec.cwd);
|
|
33
|
+
// A stop at any point: what is running is passed it, and nothing starts after it.
|
|
34
|
+
let stopping = false;
|
|
35
|
+
let child = null;
|
|
36
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"])
|
|
37
|
+
process.on(signal, () => { stopping = true; try {
|
|
38
|
+
child?.kill(signal);
|
|
39
|
+
}
|
|
40
|
+
catch { /* gone */ } });
|
|
41
|
+
const network = spec.terms.network;
|
|
42
|
+
let bridge = null;
|
|
43
|
+
// The relays share the launch's process group: they are waited for (SIGKILL after a moment), so when the helper exits
|
|
44
|
+
// nothing of the launch is left in it to read as the agent's leftover.
|
|
45
|
+
const stopBridge = async () => {
|
|
46
|
+
await Promise.all([bridge?.httpBridgeProcess, bridge?.socksBridgeProcess].map(one => new Promise(done => {
|
|
47
|
+
if (one === undefined || one.exitCode !== null || one.signalCode !== null) {
|
|
48
|
+
done();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const force = setTimeout(() => { try {
|
|
52
|
+
one.kill("SIGKILL");
|
|
53
|
+
}
|
|
54
|
+
catch { /* gone */ } }, 1_000);
|
|
55
|
+
one.once("exit", () => { clearTimeout(force); done(); });
|
|
56
|
+
try {
|
|
57
|
+
one.kill("SIGTERM");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
clearTimeout(force);
|
|
61
|
+
done();
|
|
62
|
+
}
|
|
63
|
+
})));
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
if (network.kind === "proxy")
|
|
67
|
+
bridge = await initializeLinuxNetworkBridge(network.httpPort, network.socksPort ?? network.httpPort);
|
|
68
|
+
// The runtime reads the temp folder it gives the command when it builds it (withRuntimeTemp, sandbox-runtime.ts).
|
|
69
|
+
if (spec.temp !== undefined)
|
|
70
|
+
process.env["CLAUDE_CODE_TMPDIR"] = spec.temp;
|
|
71
|
+
else
|
|
72
|
+
withRuntimeTemp(undefined, () => undefined);
|
|
73
|
+
// The command first says it started, on a descriptor of its own (3), before anything of the launch runs. A sandbox
|
|
74
|
+
// whose setup failed (bubblewrap couldn't mount over a path another launch removed after the runtime looked)
|
|
75
|
+
// never ran the launch, so it is set up again from a fresh look, a few times; one that started is never re-run.
|
|
76
|
+
const started = `printf 1 >&3 || exit 125; exec 3>&-; exec ${shellQuote([spec.file, ...spec.args])}`;
|
|
77
|
+
for (let attempt = 1;; attempt++) {
|
|
78
|
+
// Stopped during setup or between attempts: nothing starts.
|
|
79
|
+
if (stopping)
|
|
80
|
+
return STOPPED;
|
|
81
|
+
const gone = missing();
|
|
82
|
+
if (gone !== null)
|
|
83
|
+
throw new Error(`the sandbox can't be made here: ${gone}`);
|
|
84
|
+
const command = await wrapCommandWithSandboxLinux({
|
|
85
|
+
command: started,
|
|
86
|
+
needsNetworkRestriction: network.kind === "proxy",
|
|
87
|
+
...(bridge === null || network.kind !== "proxy" ? {} : {
|
|
88
|
+
httpSocketPath: bridge.httpSocketPath, socksSocketPath: bridge.socksSocketPath, httpProxyPort: network.httpPort,
|
|
89
|
+
...(network.socksPort === undefined ? {} : { socksProxyPort: network.socksPort }),
|
|
90
|
+
...(network.token === undefined ? {} : { proxyAuthToken: network.token }),
|
|
91
|
+
}),
|
|
92
|
+
readConfig: spec.terms.readConfig,
|
|
93
|
+
writeConfig: spec.terms.writeConfig,
|
|
94
|
+
});
|
|
95
|
+
// bubblewrap, and the runtime's seccomp filter inside it: anything less is not this sandbox.
|
|
96
|
+
if (!/\bbwrap\b/.test(command) || !command.includes("apply-seccomp"))
|
|
97
|
+
throw new Error("the sandbox runtime returned a command without its sandbox or its seccomp filter");
|
|
98
|
+
if (stopping)
|
|
99
|
+
return STOPPED;
|
|
100
|
+
const run = spawn("/bin/sh", ["-c", command], { stdio: ["inherit", "inherit", "pipe", "pipe"] });
|
|
101
|
+
child = run;
|
|
102
|
+
let ran = false;
|
|
103
|
+
const held = [];
|
|
104
|
+
run.stdio[3].on("data", () => { if (!ran) {
|
|
105
|
+
ran = true;
|
|
106
|
+
for (const one of held.splice(0))
|
|
107
|
+
process.stderr.write(one);
|
|
108
|
+
} });
|
|
109
|
+
run.stdio[3].on("error", () => undefined);
|
|
110
|
+
run.stderr.on("data", (chunk) => { if (ran)
|
|
111
|
+
process.stderr.write(chunk);
|
|
112
|
+
else
|
|
113
|
+
held.push(chunk); });
|
|
114
|
+
const status = await new Promise(done => {
|
|
115
|
+
run.on("error", () => done(REFUSED));
|
|
116
|
+
run.on("close", (code, signal) => done(code ?? (signal === null ? 1 : 128 + ({ SIGKILL: 9, SIGTERM: 15, SIGINT: 2, SIGHUP: 1 }[signal] ?? 1))));
|
|
117
|
+
});
|
|
118
|
+
if (!ran && stopping)
|
|
119
|
+
return STOPPED;
|
|
120
|
+
if (ran || attempt >= 3) {
|
|
121
|
+
if (!ran)
|
|
122
|
+
for (const one of held)
|
|
123
|
+
process.stderr.write(one);
|
|
124
|
+
return status;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
cleanupBwrapMountPoints();
|
|
128
|
+
}
|
|
129
|
+
catch { /* the next look is fresh either way */ }
|
|
130
|
+
await new Promise(done => setTimeout(done, 50 * attempt));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (child === null) {
|
|
135
|
+
process.stderr.write(`toolroll sandbox: nothing was started: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
136
|
+
return REFUSED;
|
|
137
|
+
}
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
try {
|
|
142
|
+
cleanupBwrapMountPoints();
|
|
143
|
+
}
|
|
144
|
+
catch { /* best effort: the runtime's own exit handler retries */ }
|
|
145
|
+
await stopBridge();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
void main().then(code => process.exit(code));
|