toolroll 0.9.76 → 0.9.78
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 +273 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +46 -10
- package/dist/assignment.js +3 -0
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +18 -3
- package/dist/builder.js +152 -47
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +295 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +67 -6
- package/dist/check-report.js +254 -9
- package/dist/checkout-cleanup.js +2 -2
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/commit-extract.d.ts +6 -0
- package/dist/commit-extract.js +63 -8
- package/dist/console-chrome.d.ts +41 -0
- package/dist/console-chrome.js +166 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -0
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/contracts/verification-receipt.d.ts +20 -2
- package/dist/contracts/verification-receipt.js +15 -3
- package/dist/contracts/verification-view.d.ts +8 -0
- package/dist/controller-service.js +4 -1
- package/dist/controller-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.js +5 -0
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +9 -5
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +19 -4
- package/dist/exec.d.ts +9 -0
- package/dist/exec.js +22 -2
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/grant.js +5 -1
- package/dist/guides.js +22 -11
- package/dist/latency-timeline.d.ts +46 -0
- package/dist/latency-timeline.js +120 -0
- package/dist/live-backup.d.ts +39 -0
- package/dist/live-backup.js +271 -0
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +969 -328
- package/dist/planner.js +2 -2
- package/dist/prepared-evidence.d.ts +2 -1
- package/dist/prepared-evidence.js +8 -3
- package/dist/project-knowledge.js +6 -2
- package/dist/project-learning.js +2 -2
- package/dist/proof.js +6 -0
- package/dist/read-executor-worker.js +8 -4
- package/dist/read-executor.d.ts +18 -1
- package/dist/read-executor.js +8 -0
- package/dist/release-adapters.d.ts +80 -2
- package/dist/release-adapters.js +441 -16
- package/dist/release-authority.js +6 -2
- package/dist/release-auto.d.ts +38 -7
- package/dist/release-auto.js +131 -15
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +95 -17
- package/dist/release-coverage.js +5 -2
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +8 -2
- package/dist/release.d.ts +144 -14
- package/dist/release.js +421 -81
- package/dist/review-context.js +8 -6
- package/dist/runner-process.d.ts +67 -0
- package/dist/runner-process.js +219 -0
- package/dist/runner.d.ts +48 -6
- package/dist/runner.js +73 -9
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +26 -3
- package/dist/scout.js +2 -2
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +112 -51
- package/dist/server/chat.js +5 -5
- package/dist/server/http.d.ts +2 -0
- package/dist/server/pages.js +7 -3
- package/dist/server/remote-hooks.d.ts +1 -1
- package/dist/server/remote-hooks.js +5 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/request-context.d.ts +8 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/task-views.js +7 -2
- package/dist/server/tasks.js +4 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-sweep.js +1 -1
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +135 -2
- package/dist/store.js +275 -13
- package/dist/surface.js +3 -1
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +52 -3
- package/dist/teardown-git.js +145 -33
- package/dist/test-infra.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/up-processes.d.ts +137 -0
- package/dist/up-processes.js +165 -0
- package/dist/up-role-child.d.ts +1 -0
- package/dist/up-role-child.js +81 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/workspace-revision.js +3 -1
- package/package.json +2 -1
package/dist/review-context.js
CHANGED
|
@@ -45,6 +45,7 @@ import { createHash } from "node:crypto";
|
|
|
45
45
|
import { hasForbiddenControls } from "./decision.js";
|
|
46
46
|
import { EVIDENCE_CAPS, isBinaryAssetPath, isImagePath, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
47
47
|
import { parseProof } from "./proof.js";
|
|
48
|
+
import { noLazyFetch } from "./teardown-git.js";
|
|
48
49
|
/** Explicit bounds. Content bytes are UTF-8 of the stored (possibly
|
|
49
50
|
* redacted) text; the aggregate is the sum over every stored item. */
|
|
50
51
|
export const LEGACY_REVIEW_CONTEXT_LIMITS = {
|
|
@@ -468,11 +469,12 @@ export function citesSuppliedProvenance(note, supplied) {
|
|
|
468
469
|
return true;
|
|
469
470
|
return false;
|
|
470
471
|
}
|
|
471
|
-
|
|
472
|
+
/** Read flags: --no-lazy-fetch where git has it (the hardened runner refuses a partial clone where it doesn't). */
|
|
473
|
+
const gitRead = () => ["--literal-pathspecs", ...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks"];
|
|
472
474
|
async function treeEntries(git, cwd, revision, paths) {
|
|
473
475
|
if (paths.length === 0)
|
|
474
476
|
return new Map();
|
|
475
|
-
const listed = await git("git", [...
|
|
477
|
+
const listed = await git("git", [...gitRead(), "ls-tree", "-z", "-l", revision, "--", ...paths], { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
476
478
|
if (listed.code !== 0)
|
|
477
479
|
return null;
|
|
478
480
|
const entries = new Map();
|
|
@@ -570,7 +572,7 @@ async function ancestorPatch(store, git, args, inventory, path) {
|
|
|
570
572
|
if (!base || !head || !SHA1.test(base) || !SHA1.test(head))
|
|
571
573
|
return fail(`run #${runId} has no exact patch endpoints`);
|
|
572
574
|
for (const from of new Set([base, previousHead ?? base])) {
|
|
573
|
-
const ancestor = await git("git", [...
|
|
575
|
+
const ancestor = await git("git", [...gitRead(), "merge-base", "--is-ancestor", from, head], { cwd: args.worktree });
|
|
574
576
|
if (ancestor.code !== 0)
|
|
575
577
|
return fail(`run #${runId}'s patch ancestry could not be verified`);
|
|
576
578
|
}
|
|
@@ -780,7 +782,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
780
782
|
const sourceCriteria = new Map((sourceScopeVerified ? sourceScope.acceptance : []).map(one => [one.id, one]));
|
|
781
783
|
// 3. Ancestry: is the source head an ancestor of this head?
|
|
782
784
|
if (sourceHead !== null) {
|
|
783
|
-
const check = await git("git", [...
|
|
785
|
+
const check = await git("git", [...gitRead(), "merge-base", "--is-ancestor", sourceHead, args.head], { cwd: args.worktree });
|
|
784
786
|
if (check.code === 0)
|
|
785
787
|
inventory.ancestry = { verified: true, detail: `${sourceHead} is an ancestor of ${args.head}` };
|
|
786
788
|
else if (check.code === 1) {
|
|
@@ -832,7 +834,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
832
834
|
if (generalPaths.length === 0 && sourceScopeVerified) {
|
|
833
835
|
const paths = sourceScope.touches.filter(path => safeContextPath(path.replace(/\/$/, "")) && !/[?*\[\]]/.test(path));
|
|
834
836
|
if (paths.length > 0) {
|
|
835
|
-
const listed = await git("git", [...
|
|
837
|
+
const listed = await git("git", [...gitRead(), "ls-tree", "-r", "-z", "--name-only", args.head, "--", ...paths], { cwd: args.worktree, maxBuffer: 4 * 1024 * 1024 });
|
|
836
838
|
if (listed.code === 0)
|
|
837
839
|
generalPaths.push(...[...new Set(listed.stdout.split("\0").filter(safeContextPath))].sort());
|
|
838
840
|
else
|
|
@@ -949,7 +951,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
949
951
|
gap("aggregate-limit", `${entry.size} bytes would cross the ${REVIEW_CONTEXT_LIMITS.aggregateBytes}-byte aggregate limit`);
|
|
950
952
|
continue;
|
|
951
953
|
}
|
|
952
|
-
const read = await git("git", [...
|
|
954
|
+
const read = await git("git", [...gitRead(), "cat-file", "blob", entry.object], { cwd: args.worktree, maxBuffer: REVIEW_CONTEXT_LIMITS.itemBytes + 4096 });
|
|
953
955
|
if (read.code !== 0) {
|
|
954
956
|
gap("capture-failed", `blob ${entry.object} could not be read (git exit ${read.code})`);
|
|
955
957
|
continue;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Runner } from "./runner.js";
|
|
2
|
+
export type RunnerProcessRole = "up" | "console" | "worker";
|
|
3
|
+
export type RunnerProcess = {
|
|
4
|
+
pid: number;
|
|
5
|
+
role: RunnerProcessRole;
|
|
6
|
+
started: string;
|
|
7
|
+
};
|
|
8
|
+
export type RunnerProcessRecord = {
|
|
9
|
+
v: 1;
|
|
10
|
+
runner: string;
|
|
11
|
+
host: string;
|
|
12
|
+
registeredAt: string;
|
|
13
|
+
/** A digest of the plane's credential hash for this registration: it names the incarnation, it proves nothing. */
|
|
14
|
+
credential: string;
|
|
15
|
+
processes: RunnerProcess[];
|
|
16
|
+
/** Authenticated with the plane's stored credential hash; legacy/forged records prove nothing. */
|
|
17
|
+
signature?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare const RUNNER_PROCESS_DIR = "runner-processes";
|
|
20
|
+
/** The namespace is created before builds launch and denied by name, including in a shared state folder. */
|
|
21
|
+
export declare function ensureRunnerProcessFolder(folder: string): void;
|
|
22
|
+
export declare function sealRunnerProcessRecord(record: RunnerProcessRecord, credentialHash: string): RunnerProcessRecord;
|
|
23
|
+
/** Where a runner's record lives: beside the database, under a name derived from the runner's (never the name itself). */
|
|
24
|
+
export declare function runnerProcessFile(databaseFile: string, runner: string): string;
|
|
25
|
+
/** The incarnation's name in a record: a digest of the stored credential hash, so the record never holds that hash. */
|
|
26
|
+
export declare function credentialDigest(credentialHash: string): string;
|
|
27
|
+
/** `ps -o lstart=` (LC_ALL=C, TZ=UTC) as an ISO second, or null when it isn't exactly that shape. */
|
|
28
|
+
export declare function parseProcessStart(raw: string): string | null;
|
|
29
|
+
/** When a process started (whole seconds), or null when there is no such process or it can't be read. */
|
|
30
|
+
export declare function processStart(pid: number): string | null;
|
|
31
|
+
/** The probes a proof uses; real by default, supplied by tests. */
|
|
32
|
+
export type ProcessProbe = {
|
|
33
|
+
exists: (pid: number) => "absent" | "present" | "unknown";
|
|
34
|
+
start: (pid: number) => string | null;
|
|
35
|
+
};
|
|
36
|
+
export declare const realProcessProbe: ProcessProbe;
|
|
37
|
+
/** Proved gone: no process has this id, or the one that has it started at another time (the id was reused). */
|
|
38
|
+
export declare function processGone(entry: RunnerProcess, probe?: ProcessProbe): boolean;
|
|
39
|
+
/** A record read strictly: a regular private file of bounded size, every field its exact type, or null. */
|
|
40
|
+
export declare function readRunnerProcessRecord(file: string): RunnerProcessRecord | null;
|
|
41
|
+
/** Replace the record atomically: a private temp file in a private folder, renamed into place. */
|
|
42
|
+
export declare function writeRunnerProcessRecord(file: string, record: RunnerProcessRecord): void;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the plane's current holder of a runner name is proved gone: the record is for exactly that registration,
|
|
45
|
+
* on this host, and every process it names has exited (by identity). `detail` says which processes, for the ledger.
|
|
46
|
+
*/
|
|
47
|
+
export declare function holderProvedGone(record: RunnerProcessRecord | null, holder: {
|
|
48
|
+
runner: Runner;
|
|
49
|
+
credentialHash: string;
|
|
50
|
+
}, host: string, probe?: ProcessProbe): {
|
|
51
|
+
gone: true;
|
|
52
|
+
detail: string;
|
|
53
|
+
} | {
|
|
54
|
+
gone: false;
|
|
55
|
+
why: string;
|
|
56
|
+
};
|
|
57
|
+
/** Re-read after observing exit: an up may append a child between the first read and its own exit. */
|
|
58
|
+
export declare function recordedHolderProvedGone(file: string, holder: {
|
|
59
|
+
runner: Runner;
|
|
60
|
+
credentialHash: string;
|
|
61
|
+
}, host: string, probe?: ProcessProbe): {
|
|
62
|
+
gone: true;
|
|
63
|
+
detail: string;
|
|
64
|
+
} | {
|
|
65
|
+
gone: false;
|
|
66
|
+
why: string;
|
|
67
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which processes hold a runner's credential, kept beside the plane's database so a later `up` can prove the previous
|
|
3
|
+
* holder is gone (docs: the 2026-10-29 deploy incident). `toolroll up` registers its runner, then records itself and
|
|
4
|
+
* every role child it hands the token to, each by process id AND start time, before the child receives the token.
|
|
5
|
+
*
|
|
6
|
+
* A fresh `up` whose runner name is still held (its heartbeat looks fresh, or its watch lease has not lapsed) asks
|
|
7
|
+
* this record whether that holder is gone: the record must be for exactly the registration the plane holds (name,
|
|
8
|
+
* host, registration time and credential), and every process in it must have exited, each proved by identity (no
|
|
9
|
+
* such process, or a process with that id started at a different time). Then the plane retires the stale holder in
|
|
10
|
+
* the same transaction that proved it (runner.ts registerRunnerIfIdle). Anything missing, unreadable or ambiguous
|
|
11
|
+
* proves nothing, and the door keeps refusing until the heartbeat ages out, exactly as before.
|
|
12
|
+
*
|
|
13
|
+
* The record is the plane's own state (the folder that holds the database). It grants nothing by itself: it can
|
|
14
|
+
* only let a name whose every recorded holder has exited be taken over early.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, rmSync, writeFileSync, fsyncSync, constants } from "node:fs";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
export const RUNNER_PROCESS_DIR = "runner-processes";
|
|
21
|
+
/** The namespace is created before builds launch and denied by name, including in a shared state folder. */
|
|
22
|
+
export function ensureRunnerProcessFolder(folder) {
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(folder, { mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.code !== "EEXIST")
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const stat = lstatSync(folder);
|
|
31
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0
|
|
32
|
+
|| (process.getuid && stat.uid !== process.getuid()) || realpathSync(folder) !== resolve(folder))
|
|
33
|
+
throw Error("Untrusted runner process folder");
|
|
34
|
+
}
|
|
35
|
+
export function sealRunnerProcessRecord(record, credentialHash) {
|
|
36
|
+
const { signature: _signature, ...plain } = record;
|
|
37
|
+
return { ...plain, signature: createHmac("sha256", credentialHash).update(JSON.stringify(plain)).digest("hex") };
|
|
38
|
+
}
|
|
39
|
+
const MAX_RECORD = 64 * 1024;
|
|
40
|
+
const MAX_PROCESSES = 64;
|
|
41
|
+
const ROLES = new Set(["up", "console", "worker"]);
|
|
42
|
+
/** Where a runner's record lives: beside the database, under a name derived from the runner's (never the name itself). */
|
|
43
|
+
export function runnerProcessFile(databaseFile, runner) {
|
|
44
|
+
let database = databaseFile;
|
|
45
|
+
try {
|
|
46
|
+
database = realpathSync(databaseFile);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== "ENOENT")
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return join(realpathSync(dirname(database)), RUNNER_PROCESS_DIR, `${createHash("sha256").update(runner, "utf8").digest("hex").slice(0, 32)}.json`);
|
|
53
|
+
}
|
|
54
|
+
/** The incarnation's name in a record: a digest of the stored credential hash, so the record never holds that hash. */
|
|
55
|
+
export function credentialDigest(credentialHash) {
|
|
56
|
+
return createHash("sha256").update(`runner-process:${credentialHash}`, "utf8").digest("hex").slice(0, 32);
|
|
57
|
+
}
|
|
58
|
+
const LSTART = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4})$/;
|
|
59
|
+
/** `ps -o lstart=` (LC_ALL=C, TZ=UTC) as an ISO second, or null when it isn't exactly that shape. */
|
|
60
|
+
export function parseProcessStart(raw) {
|
|
61
|
+
const match = LSTART.exec(raw.trim().replace(/ +/g, " "));
|
|
62
|
+
if (match === null)
|
|
63
|
+
return null;
|
|
64
|
+
const utc = `${match[1]}, ${match[3].padStart(2, "0")} ${match[2]} ${match[5]} ${match[4]} GMT`;
|
|
65
|
+
const at = Date.parse(utc);
|
|
66
|
+
if (!Number.isFinite(at) || new Date(at).toUTCString() !== utc)
|
|
67
|
+
return null;
|
|
68
|
+
return new Date(at).toISOString();
|
|
69
|
+
}
|
|
70
|
+
/** When a process started (whole seconds), or null when there is no such process or it can't be read. */
|
|
71
|
+
export function processStart(pid) {
|
|
72
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || process.platform === "win32")
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const raw = execFileSync("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
|
|
76
|
+
encoding: "utf8", env: { PATH: "/usr/bin:/bin", LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"],
|
|
77
|
+
maxBuffer: 1024, timeout: 2_000, killSignal: "SIGKILL",
|
|
78
|
+
});
|
|
79
|
+
return parseProcessStart(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export const realProcessProbe = {
|
|
86
|
+
exists: pid => {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return "present";
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const code = error.code;
|
|
93
|
+
return code === "ESRCH" ? "absent" : code === "EPERM" ? "present" : "unknown";
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
start: processStart,
|
|
97
|
+
};
|
|
98
|
+
/** Proved gone: no process has this id, or the one that has it started at another time (the id was reused). */
|
|
99
|
+
export function processGone(entry, probe = realProcessProbe) {
|
|
100
|
+
const exists = probe.exists(entry.pid);
|
|
101
|
+
if (exists === "absent")
|
|
102
|
+
return true;
|
|
103
|
+
if (exists === "unknown")
|
|
104
|
+
return false;
|
|
105
|
+
const started = probe.start(entry.pid);
|
|
106
|
+
if (started === null)
|
|
107
|
+
return probe.exists(entry.pid) === "absent";
|
|
108
|
+
return started !== entry.started;
|
|
109
|
+
}
|
|
110
|
+
/** A record read strictly: a regular private file of bounded size, every field its exact type, or null. */
|
|
111
|
+
export function readRunnerProcessRecord(file) {
|
|
112
|
+
let fd = null;
|
|
113
|
+
try {
|
|
114
|
+
const folder = lstatSync(dirname(file));
|
|
115
|
+
if (!folder.isDirectory() || folder.isSymbolicLink() || (folder.mode & 0o077) !== 0
|
|
116
|
+
|| (process.getuid && folder.uid !== process.getuid()) || realpathSync(dirname(file)) !== resolve(dirname(file)))
|
|
117
|
+
return null;
|
|
118
|
+
const link = lstatSync(file);
|
|
119
|
+
if (!link.isFile() || link.isSymbolicLink() || link.size > MAX_RECORD)
|
|
120
|
+
return null;
|
|
121
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
122
|
+
const stat = fstatSync(fd);
|
|
123
|
+
if (!stat.isFile() || stat.size > MAX_RECORD || stat.ino !== link.ino || stat.dev !== link.dev || (stat.mode & 0o077) !== 0)
|
|
124
|
+
return null;
|
|
125
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
126
|
+
return null;
|
|
127
|
+
const buffer = Buffer.alloc(stat.size);
|
|
128
|
+
let read = 0;
|
|
129
|
+
while (read < stat.size) {
|
|
130
|
+
const n = readSync(fd, buffer, read, stat.size - read, read);
|
|
131
|
+
if (n === 0)
|
|
132
|
+
break;
|
|
133
|
+
read += n;
|
|
134
|
+
}
|
|
135
|
+
return parseRecord(JSON.parse(buffer.subarray(0, read).toString("utf8")));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
if (fd !== null)
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
146
|
+
function parseRecord(value) {
|
|
147
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
148
|
+
return null;
|
|
149
|
+
const v = value;
|
|
150
|
+
const text = (one, max = 512) => typeof one === "string" && one !== "" && one.length <= max && !one.includes("\0");
|
|
151
|
+
if (v["v"] !== 1 || !text(v["runner"]) || !text(v["host"]) || !text(v["registeredAt"]) || !ISO.test(v["registeredAt"]) || !text(v["credential"], 64))
|
|
152
|
+
return null;
|
|
153
|
+
const list = v["processes"];
|
|
154
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > MAX_PROCESSES)
|
|
155
|
+
return null;
|
|
156
|
+
const processes = [];
|
|
157
|
+
for (const one of list) {
|
|
158
|
+
if (typeof one !== "object" || one === null)
|
|
159
|
+
return null;
|
|
160
|
+
const p = one;
|
|
161
|
+
if (!Number.isSafeInteger(p["pid"]) || p["pid"] <= 1 || !ROLES.has(p["role"]) || !text(p["started"], 32) || !ISO.test(p["started"]))
|
|
162
|
+
return null;
|
|
163
|
+
processes.push({ pid: p["pid"], role: p["role"], started: p["started"] });
|
|
164
|
+
}
|
|
165
|
+
return { v: 1, runner: v["runner"], host: v["host"], registeredAt: v["registeredAt"], credential: v["credential"], processes,
|
|
166
|
+
...(typeof v["signature"] === "string" ? { signature: v["signature"] } : {}) };
|
|
167
|
+
}
|
|
168
|
+
/** Replace the record atomically: a private temp file in a private folder, renamed into place. */
|
|
169
|
+
export function writeRunnerProcessRecord(file, record) {
|
|
170
|
+
ensureRunnerProcessFolder(dirname(file));
|
|
171
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
172
|
+
try {
|
|
173
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(fd, JSON.stringify(record));
|
|
176
|
+
fsyncSync(fd);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
}
|
|
181
|
+
renameSync(temp, file);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
rmSync(temp, { force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Whether the plane's current holder of a runner name is proved gone: the record is for exactly that registration,
|
|
189
|
+
* on this host, and every process it names has exited (by identity). `detail` says which processes, for the ledger.
|
|
190
|
+
*/
|
|
191
|
+
export function holderProvedGone(record, holder, host, probe = realProcessProbe) {
|
|
192
|
+
if (record === null)
|
|
193
|
+
return { gone: false, why: "no readable record of its processes" };
|
|
194
|
+
const expected = sealRunnerProcessRecord(record, holder.credentialHash).signature;
|
|
195
|
+
if (typeof record.signature !== "string" || !/^[a-f0-9]{64}$/.test(record.signature)
|
|
196
|
+
|| !timingSafeEqual(Buffer.from(record.signature, "hex"), Buffer.from(expected, "hex")))
|
|
197
|
+
return { gone: false, why: "its process record is not authenticated" };
|
|
198
|
+
if (record.runner !== holder.runner.name || record.host !== holder.runner.host || record.host !== host)
|
|
199
|
+
return { gone: false, why: "its record is for another worker or machine" };
|
|
200
|
+
if (record.registeredAt !== holder.runner.registeredAt || record.credential !== credentialDigest(holder.credentialHash))
|
|
201
|
+
return { gone: false, why: "its record is from an earlier start" };
|
|
202
|
+
if (!record.processes.some(one => one.role === "up"))
|
|
203
|
+
return { gone: false, why: "its record names no `up` process" };
|
|
204
|
+
const left = record.processes.filter(one => !processGone(one, probe));
|
|
205
|
+
if (left.length > 0)
|
|
206
|
+
return { gone: false, why: `process ${left.map(one => one.pid).join(", ")} is still running` };
|
|
207
|
+
return { gone: true, detail: `${record.runner}: process ${record.processes.map(one => `${one.pid} (${one.role})`).join(", ")} proved gone` };
|
|
208
|
+
}
|
|
209
|
+
/** Re-read after observing exit: an up may append a child between the first read and its own exit. */
|
|
210
|
+
export function recordedHolderProvedGone(file, holder, host, probe = realProcessProbe) {
|
|
211
|
+
const record = readRunnerProcessRecord(file);
|
|
212
|
+
const proof = holderProvedGone(record, holder, host, probe);
|
|
213
|
+
if (!proof.gone)
|
|
214
|
+
return proof;
|
|
215
|
+
const after = readRunnerProcessRecord(file);
|
|
216
|
+
if (after === null || JSON.stringify(after) !== JSON.stringify(record))
|
|
217
|
+
return { gone: false, why: "its process record changed during the exit proof" };
|
|
218
|
+
return proof;
|
|
219
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { type AcpProviderId } from "./acp-agents.js";
|
|
23
23
|
import type { AcpAttestation } from "./store.js";
|
|
24
|
-
import type
|
|
24
|
+
import { type Store, type Mutation } from "./store.js";
|
|
25
25
|
import { type ProviderId } from "./provider.js";
|
|
26
26
|
import type { ReadinessObservation } from "./phase-routing.js";
|
|
27
27
|
/** A runner's repo binding, in the same canonical form task filing uses —
|
|
@@ -59,11 +59,18 @@ export declare const RUNNER_TOKEN_MS: number;
|
|
|
59
59
|
/**
|
|
60
60
|
* How long a runner may go quiet before it is presumed gone.
|
|
61
61
|
*
|
|
62
|
-
*
|
|
63
|
-
* machine dead
|
|
64
|
-
*
|
|
62
|
+
* Four heartbeats (RUNNER_HEARTBEAT_MS): one slow check-in never declares a busy
|
|
63
|
+
* machine dead, and a machine that really is gone holds its name for a minute,
|
|
64
|
+
* not three (docs/plans/actors-lessons.md). A clean stop doesn't wait even that
|
|
65
|
+
* long: a draining or stopped holder never blocks the next start
|
|
66
|
+
* (registerRunnerIfIdle).
|
|
65
67
|
*/
|
|
66
|
-
export declare const DEFAULT_LIVENESS_MS
|
|
68
|
+
export declare const DEFAULT_LIVENESS_MS = 60000;
|
|
69
|
+
/** How often a live runner says so: `up`'s runner heartbeat and every watch lease renewal. */
|
|
70
|
+
export declare const RUNNER_HEARTBEAT_MS = 15000;
|
|
71
|
+
/** The longest a watch lease may be taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS): a crashed
|
|
72
|
+
* holder's projects are free within a minute. The store clamps every ttl to it. */
|
|
73
|
+
export { MAX_WATCH_LEASE_MS } from "./store.js";
|
|
67
74
|
export type RegisterOptions = {
|
|
68
75
|
name: string;
|
|
69
76
|
host: string;
|
|
@@ -140,13 +147,35 @@ export declare function normalizeRunnerName(raw: string): string;
|
|
|
140
147
|
* live claim owns), reclaims, and rotates. Two racers serialize: one wins,
|
|
141
148
|
* the other gets the typed refusal.
|
|
142
149
|
*/
|
|
143
|
-
export declare function registerRunnerIfIdle(store: Store, options: RegisterOptions
|
|
150
|
+
export declare function registerRunnerIfIdle(store: Store, options: RegisterOptions & {
|
|
151
|
+
/**
|
|
152
|
+
* Whether the name's current holder is proved gone (runner-process.ts): every process it handed its credential to
|
|
153
|
+
* has exited, verified by identity. Asked only while the holder still blocks the name, inside this transaction;
|
|
154
|
+
* a proof retires that holder and lapses its watch leases here, so a stale heartbeat never outlives its processes.
|
|
155
|
+
*/
|
|
156
|
+
previousGone?: (holder: {
|
|
157
|
+
runner: Runner;
|
|
158
|
+
credentialHash: string;
|
|
159
|
+
}) => {
|
|
160
|
+
gone: true;
|
|
161
|
+
detail: string;
|
|
162
|
+
} | {
|
|
163
|
+
gone: false;
|
|
164
|
+
why: string;
|
|
165
|
+
};
|
|
166
|
+
}): (Registration & {
|
|
144
167
|
ok: true;
|
|
145
168
|
recoveredRuns: number;
|
|
169
|
+
signedOff: string | null;
|
|
146
170
|
}) | {
|
|
147
171
|
ok: false;
|
|
148
172
|
reason: "runner-alive" | "watch-live";
|
|
149
173
|
detail: string;
|
|
174
|
+
} | {
|
|
175
|
+
ok: false;
|
|
176
|
+
reason: "runner-draining";
|
|
177
|
+
detail: string;
|
|
178
|
+
until: string;
|
|
150
179
|
};
|
|
151
180
|
/**
|
|
152
181
|
* The authenticated lease door (arc 2 findings 15/26): credential verified
|
|
@@ -280,3 +309,16 @@ export declare function reportProviderReadinessAuthed(store: Store, args: {
|
|
|
280
309
|
token: string;
|
|
281
310
|
observations: readonly Omit<ReadinessObservation, "runner" | "observedAt">[];
|
|
282
311
|
}, now: Date): AuthResult;
|
|
312
|
+
/** A worker's lifecycle row (service_lifecycle). */
|
|
313
|
+
export declare const workerComponent: (name: string) => string;
|
|
314
|
+
/**
|
|
315
|
+
* The stop begins (durable-actors' draining report), fenced by this holder's credential: from now this runner takes
|
|
316
|
+
* no new work (claim.ts acquire refuses). `until` is a schedule, never proof that the holder exited.
|
|
317
|
+
*/
|
|
318
|
+
export declare function drainRunnerIfCurrent(store: Store, name: string, token: string, now: Date, until: Date): boolean;
|
|
319
|
+
/** A runner's drain as its own lifecycle row reports it (only the current registration's row counts): null when it
|
|
320
|
+
* isn't draining; ended once its bound passed or it reported stopped. */
|
|
321
|
+
export declare function drainOf(store: Store, runner: Runner, now: Date): {
|
|
322
|
+
until: string;
|
|
323
|
+
ended: boolean;
|
|
324
|
+
} | null;
|
package/dist/runner.js
CHANGED
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
* again. Reaping on the second tells you a *machine* is gone, and everything
|
|
20
20
|
* it was holding — claims and worktrees alike — needs recovering together.
|
|
21
21
|
*/
|
|
22
|
+
import { mintSecret } from "./secret-mint.js";
|
|
22
23
|
import { isAcpProvider } from "./acp-agents.js";
|
|
23
24
|
import { verifyLaunch } from "./acp-attest.js";
|
|
24
25
|
import { inspectAcpAgent, inspectionWords } from "./acp-inspect.js";
|
|
25
26
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
26
27
|
import { resolve } from "node:path";
|
|
27
28
|
import { canonicalProject } from "./project.js";
|
|
29
|
+
import { lifecycleOf, reportLifecycle } from "./store.js";
|
|
28
30
|
import { ALL_CREDENTIAL_ENV, PROVIDER_IDS, inspectionOf } from "./provider.js";
|
|
29
31
|
import { attestationOf, versionInRange } from "./attest.js";
|
|
30
32
|
/** A runner's repo binding, in the same canonical form task filing uses —
|
|
@@ -38,11 +40,18 @@ export const RUNNER_TOKEN_MS = 365 * 86_400_000;
|
|
|
38
40
|
/**
|
|
39
41
|
* How long a runner may go quiet before it is presumed gone.
|
|
40
42
|
*
|
|
41
|
-
*
|
|
42
|
-
* machine dead
|
|
43
|
-
*
|
|
43
|
+
* Four heartbeats (RUNNER_HEARTBEAT_MS): one slow check-in never declares a busy
|
|
44
|
+
* machine dead, and a machine that really is gone holds its name for a minute,
|
|
45
|
+
* not three (docs/plans/actors-lessons.md). A clean stop doesn't wait even that
|
|
46
|
+
* long: a draining or stopped holder never blocks the next start
|
|
47
|
+
* (registerRunnerIfIdle).
|
|
44
48
|
*/
|
|
45
|
-
export const DEFAULT_LIVENESS_MS =
|
|
49
|
+
export const DEFAULT_LIVENESS_MS = 60_000;
|
|
50
|
+
/** How often a live runner says so: `up`'s runner heartbeat and every watch lease renewal. */
|
|
51
|
+
export const RUNNER_HEARTBEAT_MS = 15_000;
|
|
52
|
+
/** The longest a watch lease may be taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS): a crashed
|
|
53
|
+
* holder's projects are free within a minute. The store clamps every ttl to it. */
|
|
54
|
+
export { MAX_WATCH_LEASE_MS } from "./store.js";
|
|
46
55
|
export function register(store, options) {
|
|
47
56
|
const { name, host, capacity = 1, repos = [], agents = [], now, newToken = mintToken, mutation = {}, } = options;
|
|
48
57
|
const token = newToken();
|
|
@@ -77,6 +86,9 @@ export function register(store, options) {
|
|
|
77
86
|
// Doing it from the `previous` snapshot above is racy: two registrars can
|
|
78
87
|
// read the same predecessor and otherwise mint the same generation.
|
|
79
88
|
const runner = store.saveRunner(proposed, hashToken(token), mutation);
|
|
89
|
+
// v137: this registration is started; its lifecycle row is bound to it by registered_at, so an earlier holder's
|
|
90
|
+
// drain or stop never describes it.
|
|
91
|
+
reportLifecycle(store, workerComponent(name), "started", runner.registeredAt, now);
|
|
80
92
|
return {
|
|
81
93
|
runner,
|
|
82
94
|
token,
|
|
@@ -203,9 +215,30 @@ export function normalizeRunnerName(raw) {
|
|
|
203
215
|
* the other gets the typed refusal.
|
|
204
216
|
*/
|
|
205
217
|
export function registerRunnerIfIdle(store, options) {
|
|
206
|
-
const { name, now } = options;
|
|
218
|
+
const { name, now, previousGone, ...registration } = options;
|
|
207
219
|
return store.transact(() => {
|
|
208
|
-
|
|
220
|
+
let existing = store.getRunner(name);
|
|
221
|
+
// The protection against two live holders stands: only a holder whose every process is proved gone, or whose own
|
|
222
|
+
// drain has ended (its stop bound passed, or it reported stopped), is signed off.
|
|
223
|
+
let signedOff = null;
|
|
224
|
+
const blocked = existing !== null && ((existing.runner.retiredAt === null && isAlive(existing.runner, now)) || store.liveWatchLeaseOf(name, now) !== null);
|
|
225
|
+
const drain = existing === null ? null : drainOf(store, existing.runner, now);
|
|
226
|
+
// A scheduled drain deadline is not proof that its timer ran or its children exited.
|
|
227
|
+
if (blocked && existing !== null && previousGone !== undefined) {
|
|
228
|
+
const proof = previousGone(existing);
|
|
229
|
+
if (proof.gone) {
|
|
230
|
+
store.retireRunner(name, now);
|
|
231
|
+
store.expireWatchLeasesOf(name, now);
|
|
232
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll up", repo: null, taskId: null, runId: null,
|
|
233
|
+
action: "worker signed off", outcome: "processes proved gone", source: "policy", detail: proof.detail });
|
|
234
|
+
signedOff = proof.detail;
|
|
235
|
+
existing = store.getRunner(name);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// A live draining holder remains exclusive even if its advertised deadline has passed.
|
|
239
|
+
if (existing !== null && drain !== null && (isAlive(existing.runner, now) || store.liveWatchLeaseOf(name, now) !== null)) {
|
|
240
|
+
return { ok: false, reason: "runner-draining", until: drain.until, detail: `Toolroll is restarting (draining): ${name} is still stopping (scheduled by ${drain.until})` };
|
|
241
|
+
}
|
|
209
242
|
if (existing !== null && existing.runner.retiredAt === null && isAlive(existing.runner, now)) {
|
|
210
243
|
return {
|
|
211
244
|
ok: false,
|
|
@@ -226,8 +259,8 @@ export function registerRunnerIfIdle(store, options) {
|
|
|
226
259
|
// otherwise hide it from every later recovery.
|
|
227
260
|
const recoveredRuns = existing === null ? 0 : store.recoverRunnerWork(name, now).runs.length;
|
|
228
261
|
// A restart keeps the capacity an operator set; only an explicit one changes it.
|
|
229
|
-
const
|
|
230
|
-
return { ok: true, ...
|
|
262
|
+
const registered = register(store, { ...registration, name, now, capacity: options.capacity ?? existing?.runner.capacity ?? 1 });
|
|
263
|
+
return { ok: true, ...registered, recoveredRuns, signedOff };
|
|
231
264
|
});
|
|
232
265
|
}
|
|
233
266
|
/**
|
|
@@ -281,6 +314,10 @@ export function retireRunnerIfCurrent(store, name, token, now) {
|
|
|
281
314
|
if (!auth.ok)
|
|
282
315
|
return { ok: false, reason: auth.reason };
|
|
283
316
|
store.retireRunner(name, now);
|
|
317
|
+
// Stopped is retired: its leases lapse now, so the next start or watch takes its projects over (recovering whatever
|
|
318
|
+
// this incarnation left) without waiting for them to run out.
|
|
319
|
+
store.expireWatchLeasesOf(name, now);
|
|
320
|
+
reportLifecycle(store, workerComponent(name), "stopped", auth.runner.registeredAt, now);
|
|
284
321
|
return { ok: true };
|
|
285
322
|
});
|
|
286
323
|
}
|
|
@@ -352,7 +389,7 @@ export function recoverDead(store, now, livenessMs = DEFAULT_LIVENESS_MS) {
|
|
|
352
389
|
}
|
|
353
390
|
/** 32 bytes of randomness, urlsafe, so it survives being pasted anywhere. */
|
|
354
391
|
function mintToken() {
|
|
355
|
-
return
|
|
392
|
+
return mintSecret(32);
|
|
356
393
|
}
|
|
357
394
|
export function hashToken(token) {
|
|
358
395
|
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
@@ -434,3 +471,30 @@ export function reportProviderReadinessAuthed(store, args, now) {
|
|
|
434
471
|
return auth;
|
|
435
472
|
});
|
|
436
473
|
}
|
|
474
|
+
// ---- v137 lifecycle (docs/plans/actors-lessons.md): last in the file, so the reviewed function positions stay ----
|
|
475
|
+
/** A worker's lifecycle row (service_lifecycle). */
|
|
476
|
+
export const workerComponent = (name) => `worker:${name}`;
|
|
477
|
+
/**
|
|
478
|
+
* The stop begins (durable-actors' draining report), fenced by this holder's credential: from now this runner takes
|
|
479
|
+
* no new work (claim.ts acquire refuses). `until` is a schedule, never proof that the holder exited.
|
|
480
|
+
*/
|
|
481
|
+
export function drainRunnerIfCurrent(store, name, token, now, until) {
|
|
482
|
+
return store.transact(() => {
|
|
483
|
+
const auth = authenticate(store, name, token);
|
|
484
|
+
if (!auth.ok)
|
|
485
|
+
return false;
|
|
486
|
+
return reportLifecycle(store, workerComponent(name), "draining", auth.runner.registeredAt, now, { drainUntil: until });
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/** A runner's drain as its own lifecycle row reports it (only the current registration's row counts): null when it
|
|
490
|
+
* isn't draining; ended once its bound passed or it reported stopped. */
|
|
491
|
+
export function drainOf(store, runner, now) {
|
|
492
|
+
const row = lifecycleOf(store, workerComponent(runner.name));
|
|
493
|
+
if (row === null || row.holder !== runner.registeredAt)
|
|
494
|
+
return null;
|
|
495
|
+
if (row.state === "stopped")
|
|
496
|
+
return { until: row.changedAt, ended: true };
|
|
497
|
+
if (row.state !== "draining" || row.drainUntil === null)
|
|
498
|
+
return null;
|
|
499
|
+
return { until: row.drainUntil, ended: Date.parse(row.drainUntil) <= now.getTime() };
|
|
500
|
+
}
|
|
@@ -63,6 +63,14 @@ export declare const SEATBELT_NO_OTHER_TERMINAL: string;
|
|
|
63
63
|
* any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
|
|
64
64
|
*/
|
|
65
65
|
export declare const SEATBELT_NO_KEYCHAIN: string;
|
|
66
|
+
/**
|
|
67
|
+
* First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
|
|
68
|
+
* On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
|
|
69
|
+
* (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
|
|
70
|
+
* 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
|
|
71
|
+
* same; only the report goes.
|
|
72
|
+
*/
|
|
73
|
+
export declare const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
|
|
66
74
|
/**
|
|
67
75
|
* Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
|
|
68
76
|
* port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
|
package/dist/sandbox-runtime.js
CHANGED
|
@@ -122,8 +122,16 @@ export const SEATBELT_NO_OTHER_TERMINAL = [
|
|
|
122
122
|
*/
|
|
123
123
|
export const SEATBELT_NO_KEYCHAIN = [
|
|
124
124
|
"; Toolroll: no keychain",
|
|
125
|
-
"(deny mach-lookup (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
|
|
125
|
+
"(deny mach-lookup (with no-report) (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
|
|
126
126
|
].join("\n");
|
|
127
|
+
/**
|
|
128
|
+
* First after the profile's default deny: a mach service lookup the profile doesn't allow is still denied, unreported.
|
|
129
|
+
* On macOS 26.5 a reported mach-lookup denial now and then never answers: bootstrap_look_up blocks in launchd for good
|
|
130
|
+
* (seen as npm stuck in Node's DNS setup, dns_configuration_copy → com.apple.SystemConfiguration.DNSConfiguration, about
|
|
131
|
+
* 1 launch in 70 under load; never with the report off). Every allow after it still wins, so what is reachable is the
|
|
132
|
+
* same; only the report goes.
|
|
133
|
+
*/
|
|
134
|
+
export const SEATBELT_QUIET_MACH_DENY = "(deny mach-lookup (with no-report))";
|
|
127
135
|
/**
|
|
128
136
|
* Chromium, a project check's launch only: Playwright's default browser (its Chromium headless shell) registers its
|
|
129
137
|
* port rendezvous service as org.chromium.Chromium.MachPortRendezvousServer.<its pid> at start, and its helpers look
|
|
@@ -226,11 +234,15 @@ export function withSeatbeltRules(command, rules, expected) {
|
|
|
226
234
|
const profile = words[exec + 2];
|
|
227
235
|
if (!profile.startsWith("(version 1)\n(deny default"))
|
|
228
236
|
throw shape();
|
|
237
|
+
// Where the default deny's line ends (the profile's end when it is the last line).
|
|
238
|
+
const lineEnd = profile.indexOf("\n", "(version 1)\n".length);
|
|
239
|
+
const opening = lineEnd < 0 ? profile.length : lineEnd;
|
|
229
240
|
if (!/^\/[A-Za-z0-9_./-]+$/.test(words[exec + 3]))
|
|
230
241
|
throw shape();
|
|
231
242
|
if (expected !== undefined && words[exec + 5] !== expected)
|
|
232
243
|
throw shape();
|
|
233
|
-
|
|
244
|
+
const quiet = `${profile.slice(0, opening)}\n${SEATBELT_QUIET_MACH_DENY}${profile.slice(opening)}`;
|
|
245
|
+
return srtQuote([...words.slice(0, exec + 2), `${quiet}\n${rules}\n`, ...words.slice(exec + 3)]);
|
|
234
246
|
}
|
|
235
247
|
/** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
|
|
236
248
|
export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
|