toolroll 0.9.76 → 0.9.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +28 -8
  5. package/dist/assignment.js +3 -0
  6. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/builder.d.ts +14 -0
  10. package/dist/builder.js +140 -41
  11. package/dist/chat-decide.js +4 -0
  12. package/dist/check-failure.d.ts +92 -0
  13. package/dist/check-failure.js +267 -0
  14. package/dist/check-progress.d.ts +28 -1
  15. package/dist/check-progress.js +105 -6
  16. package/dist/check-report.d.ts +52 -6
  17. package/dist/check-report.js +193 -8
  18. package/dist/checkout-cleanup.js +2 -2
  19. package/dist/cli.js +8 -5
  20. package/dist/coding-handoff.d.ts +1 -0
  21. package/dist/coding-handoff.js +20 -11
  22. package/dist/commit-extract.d.ts +6 -0
  23. package/dist/commit-extract.js +63 -8
  24. package/dist/console-chrome.d.ts +41 -0
  25. package/dist/console-chrome.js +166 -0
  26. package/dist/contracts/release-check.d.ts +12 -0
  27. package/dist/contracts/release-check.js +17 -0
  28. package/dist/contracts/verification-receipt.d.ts +20 -2
  29. package/dist/contracts/verification-receipt.js +15 -3
  30. package/dist/contracts/verification-view.d.ts +8 -0
  31. package/dist/controller-supervisor.d.ts +8 -0
  32. package/dist/controller-supervisor.js +33 -1
  33. package/dist/demo.js +3 -2
  34. package/dist/deploy-gate.js +5 -0
  35. package/dist/dispose.d.ts +3 -0
  36. package/dist/dispose.js +6 -3
  37. package/dist/doctor.d.ts +7 -0
  38. package/dist/doctor.js +10 -2
  39. package/dist/envelope.js +2 -1
  40. package/dist/evidence.js +4 -1
  41. package/dist/exec.d.ts +7 -0
  42. package/dist/exec.js +7 -1
  43. package/dist/git-floor.d.ts +16 -0
  44. package/dist/git-floor.js +36 -0
  45. package/dist/grant.js +5 -1
  46. package/dist/guides.js +4 -3
  47. package/dist/operate.d.ts +13 -0
  48. package/dist/operate.js +773 -319
  49. package/dist/prepared-evidence.d.ts +2 -1
  50. package/dist/prepared-evidence.js +8 -3
  51. package/dist/project-knowledge.js +6 -2
  52. package/dist/project-learning.js +2 -2
  53. package/dist/proof.js +6 -0
  54. package/dist/read-executor-worker.js +8 -4
  55. package/dist/read-executor.d.ts +18 -1
  56. package/dist/read-executor.js +8 -0
  57. package/dist/release-adapters.d.ts +48 -1
  58. package/dist/release-adapters.js +343 -10
  59. package/dist/release-authority.js +6 -2
  60. package/dist/release-auto.d.ts +38 -7
  61. package/dist/release-auto.js +131 -15
  62. package/dist/release-card.d.ts +15 -3
  63. package/dist/release-card.js +25 -9
  64. package/dist/release-ci-hold.d.ts +41 -0
  65. package/dist/release-ci-hold.js +53 -0
  66. package/dist/release-cli.js +21 -12
  67. package/dist/release-coverage.js +5 -2
  68. package/dist/release-recheck.d.ts +1 -1
  69. package/dist/release-recheck.js +6 -1
  70. package/dist/release.d.ts +108 -11
  71. package/dist/release.js +294 -39
  72. package/dist/review-context.js +8 -6
  73. package/dist/runner.js +2 -1
  74. package/dist/sandbox-runtime.d.ts +8 -0
  75. package/dist/sandbox-runtime.js +14 -2
  76. package/dist/scope.js +7 -1
  77. package/dist/secret-mint.d.ts +1 -0
  78. package/dist/secret-mint.js +14 -0
  79. package/dist/serve.js +101 -51
  80. package/dist/server/chat.js +5 -5
  81. package/dist/server/render-tasks.d.ts +2 -0
  82. package/dist/server/render-tasks.js +2 -1
  83. package/dist/server/request-context.d.ts +6 -0
  84. package/dist/server/runtime.d.ts +2 -0
  85. package/dist/server/task-views.js +7 -2
  86. package/dist/setup-guide.d.ts +2 -2
  87. package/dist/storage.d.ts +5 -0
  88. package/dist/storage.js +28 -1
  89. package/dist/store.d.ts +10 -0
  90. package/dist/store.js +25 -1
  91. package/dist/task-base.d.ts +4 -0
  92. package/dist/task-base.js +22 -2
  93. package/dist/teardown-git.d.ts +52 -3
  94. package/dist/teardown-git.js +143 -31
  95. package/dist/test-infra.js +1 -1
  96. package/dist/up-processes.d.ts +137 -0
  97. package/dist/up-processes.js +165 -0
  98. package/dist/up-role-child.d.ts +1 -0
  99. package/dist/up-role-child.js +77 -0
  100. package/dist/verification-evidence.d.ts +1 -0
  101. package/dist/verification-evidence.js +13 -1
  102. package/package.json +2 -1
package/dist/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mintSecret } from "./secret-mint.js";
1
2
  import { WRITES_DEFAULT } from "./writes-mode.js";
2
3
  import { checkAccessOf, widerThan } from "./check-access.js";
3
4
  import { PROJECT_SANDBOX_DEFAULT, sandboxOf } from "./project-sandbox.js";
@@ -72,6 +73,7 @@ import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision
72
73
  import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
73
74
  import { foldReview, manualReviewOnly } from "./proof.js";
74
75
  import { liveReleaseOf } from "./scope.js";
76
+ import { releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
75
77
  import { APPROVAL_BASES, approvalOf, machineActing, personProof, proofDetailOf, quarantinedAuthor, sealOriginOf, digestOf, canonicalProfileJson, chainFromJson, profileDigestOf, profileFromJson, scopeAuthorityOf, routeParityProblem, parseAcceptanceCriteria, exactAcceptance, exactStringList, exactSafeIntegerOrNull } from "./scope.js";
76
78
  import { resolveScopeProfile, resolveRouteCandidates, exactPinOf, routeOfTask, agentChoicesFor } from "./agentconfig.js";
77
79
  import { canonicalOverridesJson, canonicalRouteJson, isRiskLevel, isTaskSize, headroomFrom, makesNoPlan, legOf, overridesFromJson, phaseOfRole, recommendRoute, routeDigestOf, routeFromJson, routeProblems, routeStampProblem, ROUTE_ERA, PROVIDER_ID_LIST, } from "./phase-routing.js";
@@ -6932,6 +6934,10 @@ export class Store {
6932
6934
  // road it came by.
6933
6935
  if (basis === undefined && machineActing())
6934
6936
  return false;
6937
+ // A RELEASE WAITING FOR CI (Codex review round 1, finding 4): filed while its pull request's checks still ran,
6938
+ // only the automatic road (which proves CI itself) seals it until the plane read exact-head CI passing.
6939
+ if (basis?.kind !== "auto-check" && basis?.kind !== "auto-green" && releaseCiHoldOf(this, taskId) !== null)
6940
+ return false;
6935
6941
  // `carried` is written by the carry transaction alone (carryReleaseApproval), never through a seal.
6936
6942
  if (basis?.kind === "carried")
6937
6943
  return false;
@@ -8018,7 +8024,7 @@ export class Store {
8018
8024
  /** A single-use door into the instance (U2): 128-bit token, sha256
8019
8025
  * stored, role pinned at mint, 72 hours. Only the caller's own
8020
8026
  * authentication gates this — an approver mints; a viewer cannot. */
8021
- mintInvite(role, mintedBy, now, token = () => randomBytes(16).toString("base64url"), projects = null) {
8027
+ mintInvite(role, mintedBy, now, token = () => mintSecret(16), projects = null) {
8022
8028
  const access = normalizeProjectAccess(projects);
8023
8029
  return this.transact(() => {
8024
8030
  if (!this.isInstanceOperator(mintedBy))
@@ -20507,6 +20513,9 @@ export function reserveRelease(store, input) {
20507
20513
  const a = releaseAuthority(store, input.authority);
20508
20514
  if (a === null)
20509
20515
  return { ok: false, why: `No release ${input.authority}.` };
20516
+ // Early approval authorizes the check, never an irreversible operation before the plane confirms exact-head CI.
20517
+ if (releaseCiRunningOf(store, input.taskId) !== null)
20518
+ return { ok: false, why: "This release still waits for GitHub CI. Resume it with `toolroll release`." };
20510
20519
  const at = input.now.toISOString();
20511
20520
  // Expiry stops every live state; an operation already reserved settles only through reconciliation.
20512
20521
  if (a.expiresAt <= at)
@@ -20548,6 +20557,8 @@ export function runReleaseOperation(store, id, epoch, now, issuer = currentRelea
20548
20557
  const a = op === null ? null : releaseAuthority(store, op.authority);
20549
20558
  if (op === null || a === null || op.state !== "reserved" || op.epoch !== epoch || a.leaseEpoch !== epoch)
20550
20559
  return false;
20560
+ if (releaseCiRunningOf(store, op.taskId) !== null)
20561
+ return false;
20551
20562
  // Past the expiry nothing new starts; the reserved operation is settled by reconciliation instead.
20552
20563
  if (a.expiresAt <= now.toISOString())
20553
20564
  return false;
@@ -20858,3 +20869,16 @@ export function agentsLiveRuns(store, endedSince) {
20858
20869
  repo: row["live_task_repo"] === null || row["live_task_repo"] === undefined ? null : String(row["live_task_repo"]),
20859
20870
  }));
20860
20871
  }
20872
+ /**
20873
+ * This runner's watches a predecessor left needing recovery (Codex review round 1, finding 2): a watch lease still
20874
+ * live, or one whose holder left a claim open (or a run open under a claim a reaper already released). A replacement
20875
+ * worker takes each over — recovering the holder's incarnation in the same transaction — before it admits new work,
20876
+ * whichever projects its start names (a project being removed when the predecessor died is in none of them).
20877
+ */
20878
+ export function predecessorWatches(store, runner, now) {
20879
+ return store.handle.prepare(`SELECT w.repo AS repo, w.owner AS owner FROM watch_lease w
20880
+ WHERE w.runner = ? AND (w.expires_at > ? OR EXISTS (
20881
+ SELECT 1 FROM claim c WHERE c.runner = w.runner AND c.incarnation = w.owner
20882
+ AND (c.released_at IS NULL OR EXISTS (SELECT 1 FROM run r WHERE r.lease_id = c.lease_id AND r.outcome IS NULL))))
20883
+ ORDER BY w.repo`).all(runner, now.toISOString()).map(row => ({ repo: String(row["repo"]), owner: String(row["owner"]) }));
20884
+ }
@@ -33,3 +33,7 @@ export declare function freshBaseFor(store: Store, taskId: string, branch: strin
33
33
  };
34
34
  } | Record<string, never>;
35
35
  export declare function taskBaseOf(store: Store, taskId: string, git?: GitRead): TaskBaseView | null;
36
+ /** `git`'s answers kept for `ttlMs` (at most `entries` of them), for a page that shows the base line on every refresh:
37
+ * each read still goes through the same frozen view when it runs; only its answer is reused for a moment. Display only —
38
+ * never for a decision. */
39
+ export declare function recentGitReads(git?: GitRead, ttlMs?: number, entries?: number, now?: () => number): GitRead;
package/dist/task-base.js CHANGED
@@ -4,10 +4,13 @@
4
4
  * branch's pinned origin, or the newest plan's base — and the behind count is
5
5
  * read from Git now. Reads only.
6
6
  */
7
- import { execFileSync } from "node:child_process";
7
+ import { viewedGitSync } from "./teardown-git.js";
8
8
  export const readGit = (cwd, args) => {
9
9
  try {
10
- return execFileSync("git", ["--no-optional-locks", "--no-lazy-fetch", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 65_536 }).trim();
10
+ // Every git version through a frozen read view, bounded (eval-harness review 1, finding 7): a partial clone, an
11
+ // include or a command-naming key is refused from the config git then runs with.
12
+ const ran = viewedGitSync(cwd, args, { timeoutMs: 5_000, maxBuffer: 65_536 });
13
+ return ran.status === 0 ? ran.stdout.trim() : null;
11
14
  }
12
15
  catch {
13
16
  return null;
@@ -49,3 +52,20 @@ export function taskBaseOf(store, taskId, git = readGit) {
49
52
  const view = { base, branch, main: behind === null ? null : main, behind };
50
53
  return { ...view, words: `Base ${baseStandingWords(view)}` };
51
54
  }
55
+ /** `git`'s answers kept for `ttlMs` (at most `entries` of them), for a page that shows the base line on every refresh:
56
+ * each read still goes through the same frozen view when it runs; only its answer is reused for a moment. Display only —
57
+ * never for a decision. */
58
+ export function recentGitReads(git = readGit, ttlMs = 15_000, entries = 64, now = Date.now) {
59
+ const kept = new Map();
60
+ return (cwd, args) => {
61
+ const key = JSON.stringify([cwd, args]), at = now(), hit = kept.get(key);
62
+ if (hit !== undefined && at - hit.at < ttlMs && at >= hit.at)
63
+ return hit.value;
64
+ const value = git(cwd, args);
65
+ kept.delete(key);
66
+ if (kept.size >= entries)
67
+ kept.delete(kept.keys().next().value);
68
+ kept.set(key, { at, value });
69
+ return value;
70
+ };
71
+ }
@@ -38,7 +38,7 @@ export declare function teardownGit(runner?: Runner, binding?: GitBinding): Git;
38
38
  * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
39
39
  * that can't be read so counts as including.
40
40
  */
41
- export declare function checkoutIncludes(checkout: string): boolean;
41
+ export declare function checkoutIncludes(checkout: string, gitFile?: string): boolean;
42
42
  export type GitBinding = {
43
43
  repo?: string | null;
44
44
  };
@@ -50,7 +50,7 @@ export declare const WRITE_VIEW_FLAGS: readonly string[];
50
50
  /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
51
51
  * program (anything but INERT_LFS). A file that
52
52
  * isn't a plain one, or doesn't parse, counts as naming one. */
53
- export declare function lfsExtensionIn(checkout: string): boolean;
53
+ export declare function lfsExtensionIn(checkout: string, gitFile?: string): boolean;
54
54
  /** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
55
55
  * plane made, inspected there, so neither can change between the inspection and the run. `mode` "read" is for reading
56
56
  * a checkout an agent may still be writing (the code page): every command also takes readFlags(), and the view holds
@@ -69,7 +69,7 @@ export declare const PAGE_READ_FLAGS: readonly string[];
69
69
  * its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
70
70
  * also runs against a view of the checkout's repository (gitView; checkoutGitSync, checkoutGit "read"), whose config and info are the plane's. The cost is cosmetic: a checkout's line-ending or encoding rules are not applied, so
71
71
  * a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
72
- export declare function readFlags(): readonly string[];
72
+ export declare function readFlags(gitFile?: string): readonly string[];
73
73
  /**
74
74
  * checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
75
75
  * the same refusals (an include, an .lfsconfig extension, a config key that can run a command), no system or global
@@ -81,6 +81,30 @@ export declare function checkoutGitSync(cwd: string, args: readonly string[], op
81
81
  maxBuffer?: number;
82
82
  binding?: GitBinding;
83
83
  }): string;
84
+ /**
85
+ * checkoutGitSync's run, answering git's exit status instead of throwing on a non-zero one (a refusal, or a repository
86
+ * that changed while git ran, still throws): for reads whose "no" is an exit code (`merge-base --is-ancestor`), and,
87
+ * with `buffer`, for blobs read as bytes (their output is never rewritten).
88
+ */
89
+ export declare function viewedGitSync(cwd: string, args: readonly string[], options: {
90
+ timeoutMs?: number;
91
+ maxBuffer?: number;
92
+ binding?: GitBinding;
93
+ }): {
94
+ status: number | null;
95
+ stdout: string;
96
+ };
97
+ export declare function viewedGitSync(cwd: string, args: readonly string[], options: {
98
+ timeoutMs?: number;
99
+ maxBuffer?: number;
100
+ binding?: GitBinding;
101
+ buffer: true;
102
+ input?: Buffer;
103
+ git?: string;
104
+ }): {
105
+ status: number | null;
106
+ stdout: Buffer;
107
+ };
84
108
  /**
85
109
  * checkoutGit ("build": the repository written through its view) for callers that name the repository with a leading
86
110
  * `git -C <dir>` instead of a cwd: worktree add and remove in a project's repository, a merge in a checkout of it.
@@ -88,3 +112,28 @@ export declare function checkoutGitSync(cwd: string, args: readonly string[], op
88
112
  * what they name. Anything but git goes to `inner` as given.
89
113
  */
90
114
  export declare function hardenedGit(inner?: Runner, mode?: "build" | "read", binding?: GitBinding): Runner;
115
+ /** Asked once per git executable (eval-harness review 2, follow-up): a fixed git given to viewedGitSync is probed
116
+ * itself, never answered for by the one PATH names. */
117
+ export declare function lazyFetchSupported(gitFile?: string): boolean;
118
+ /** For tests: forget the probes' answers, or set one for every executable (null forgets). */
119
+ export declare function setLazyFetchSupportedForTest(value: boolean | null): void;
120
+ /** The words for a partial clone refused on a git without `--no-lazy-fetch`. */
121
+ export declare const PARTIAL_CLONE_REFUSAL = "this project is a partial clone; protected reads need git 2.45 or newer";
122
+ /** `--no-lazy-fetch` where the installed git has it, else nothing (and lazyFetchSetting refuses partial clones). */
123
+ export declare function noLazyFetch(): readonly string[];
124
+ /**
125
+ * The repository setting (local or worktree scope of a `git config --list --show-scope -z` listing) that makes lazy
126
+ * fetching possible, or null: `extensions.partialClone` set at all, any `remote.<name>.promisor` not plainly false, or
127
+ * any `remote.<name>.partialCloneFilter` set (git makes that remote a promisor). Fails closed: a listing that can't be
128
+ * read names a setting. `remote.<name>.vcs` (a foreign-VCS helper) is named too, though the classifier already refuses
129
+ * it (commandSetting: not in the remote allowlist), as it does every `protocol.*` key and every include.
130
+ */
131
+ export declare function lazyFetchSetting(listing: string): string | null;
132
+ /**
133
+ * For the reads that run git directly on a repository rather than through a view: with `--no-lazy-fetch` they run as
134
+ * they always have (the flag stops any fetch). Without it, they must not check a repository's config and then run git
135
+ * on it (the config could change in between, or an include could set what the check never saw): `inner`, wrapped so
136
+ * every git command runs through a frozen read view (checkoutGit "read"), whose own config listing (includes refused,
137
+ * command and protocol settings refused, a partial clone refused) is the config git then runs with.
138
+ */
139
+ export declare function lazyFetchSafeGit(inner?: Runner): Runner;
@@ -11,12 +11,12 @@
11
11
  * nothing is removed.
12
12
  */
13
13
  import { spawnSync } from "node:child_process";
14
- import { closeSync, constants as fsConstants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
14
+ import { accessSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
15
15
  import { randomBytes } from "node:crypto";
16
16
  import { homedir } from "node:os";
17
17
  import { ensureGitViewsRoot, withCheckoutLock } from "./git-views.js";
18
18
  import { gitIncident } from "./git-incident.js";
19
- import { basename, dirname, join, resolve } from "node:path";
19
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
20
20
  import { run } from "./exec.js";
21
21
  import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
22
22
  import { WORKTREE_TIMEOUT_MS } from "./worktree.js";
@@ -304,29 +304,37 @@ export function teardownGit(runner = run, binding = {}) {
304
304
  * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
305
305
  * that can't be read so counts as including.
306
306
  */
307
- export function checkoutIncludes(checkout) {
307
+ export function checkoutIncludes(checkout, gitFile = "git") {
308
308
  const dirs = gitDirsOf(checkout);
309
309
  if (dirs === null)
310
310
  return true;
311
311
  for (const path of [join(dirs.common, "config"), join(dirs.gitdir, "config.worktree")]) {
312
312
  if (!existsSync(path) && path.endsWith("config.worktree"))
313
313
  continue;
314
- if (configIncludes(path))
314
+ if (includeIn(path, gitFile) !== null)
315
315
  return true;
316
316
  }
317
317
  return false;
318
318
  }
319
319
  /** One config file, read only as the regular file it is (bounded), or null. */
320
320
  function regularFile(path) {
321
+ // Opened without following a link and without blocking, then judged by what was opened: a FIFO swapped in after a
322
+ // look never hangs the read (eval-harness review 1, finding 7).
323
+ let fd = null;
321
324
  try {
322
- const held = lstatSync(path);
325
+ fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
326
+ const held = fstatSync(fd);
323
327
  if (!held.isFile() || held.size > 1024 * 1024)
324
328
  return null;
325
- return readFileSync(path, "utf8");
329
+ return readFileSync(fd, "utf8");
326
330
  }
327
331
  catch {
328
332
  return null;
329
333
  }
334
+ finally {
335
+ if (fd !== null)
336
+ closeSync(fd);
337
+ }
330
338
  }
331
339
  /** A checkout's git directory and common directory, as git finds them from its `.git` (a folder, or a one-line
332
340
  * `gitdir: ` file); null when the pointer isn't one git would read the same way. */
@@ -350,18 +358,13 @@ function gitDirsOf(checkout) {
350
358
  const named = regularFile(join(gitdir, "commondir"))?.trim();
351
359
  return { gitdir, common: named === undefined || named === "" ? gitdir : resolve(gitdir, named) };
352
360
  }
353
- /** Whether one config file includes another (or can't be read as a plain file): by a plain reading, and as git itself
354
- * parses it, includes not followed (only this one regular file, from a neutral folder, with no other config read). */
355
- function configIncludes(path) {
356
- return includeIn(path) !== null;
357
- }
358
361
  /** The include key one config file sets (lowercased, as git names it), "include" when only a plain reading finds one,
359
362
  * or "an unreadable file" when it can't be read as a plain file or parsed; null when it includes nothing. */
360
- function includeIn(path) {
363
+ function includeIn(path, gitFile = "git") {
361
364
  const text = regularFile(path);
362
365
  if (text === null)
363
366
  return "an unreadable file";
364
- const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
367
+ const parsed = spawnSync(gitFile, ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
365
368
  if (parsed.status !== 0)
366
369
  return "an unreadable file";
367
370
  const named = parsed.stdout.split("\0").find(name => /^(include\.path|includeif\..*\.path)$/i.test(name));
@@ -471,7 +474,7 @@ function boundDirs(checkout, binding = {}) {
471
474
  const ADMIN_LINKED = ["index", "logs", "refs", "reftable"];
472
475
  /** What git keeps in a common directory (its path.c common_list, and reftable), linked whether or not it exists yet. */
473
476
  const COMMON_NAMES = ["branches", "gc.pid", "logs", "lost-found", "modules", "objects", "packed-refs", "reftable", "refs", "remotes", "rr-cache", "shallow", "svn", "worktrees"];
474
- function gitView(checkout, kind, binding = {}) {
477
+ function gitView(checkout, kind, binding = {}, gitFile = "git") {
475
478
  const dirs = boundDirs(checkout, binding);
476
479
  const root = ensureGitViewsRoot(realpathSync(homedir()));
477
480
  const view = realpathSync(mkdtempSync(join(root, `${kind}-`)));
@@ -487,7 +490,7 @@ function gitView(checkout, kind, binding = {}) {
487
490
  writeFileSync(configPath, config, { mode: 0o600 });
488
491
  const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } };
489
492
  if (/worktreeconfig/i.test(config)) {
490
- const flag = spawnSync("git", ["config", "--file", configPath, "--no-includes", "--type=bool", "--get", "extensions.worktreeConfig"], plain);
493
+ const flag = spawnSync(gitFile, ["config", "--file", configPath, "--no-includes", "--type=bool", "--get", "extensions.worktreeConfig"], plain);
491
494
  if (flag.status !== 0 && flag.status !== 1)
492
495
  throw new Error("the checkout's git config couldn't be read");
493
496
  if (flag.stdout.trim() === "true") {
@@ -499,11 +502,11 @@ function gitView(checkout, kind, binding = {}) {
499
502
  config = `${config}\n${own}`;
500
503
  writeFileSync(configPath, config, { mode: 0o600 });
501
504
  }
502
- const unset = spawnSync("git", ["config", "--file", configPath, "--unset-all", "extensions.worktreeConfig"], plain);
505
+ const unset = spawnSync(gitFile, ["config", "--file", configPath, "--unset-all", "extensions.worktreeConfig"], plain);
503
506
  if (unset.status !== 0 && unset.status !== 5)
504
507
  throw new Error("the checkout's git config couldn't be read");
505
508
  }
506
- const include = includeIn(configPath);
509
+ const include = includeIn(configPath, gitFile);
507
510
  if (include !== null)
508
511
  throw new Error(`the checkout's git config includes another file (${include})`);
509
512
  mkdirSync(join(view, "info"));
@@ -1026,7 +1029,7 @@ export const WRITE_VIEW_FLAGS = ["-c", "worktree.useRelativePaths=false", "-c",
1026
1029
  /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
1027
1030
  * program (anything but INERT_LFS). A file that
1028
1031
  * isn't a plain one, or doesn't parse, counts as naming one. */
1029
- export function lfsExtensionIn(checkout) {
1032
+ export function lfsExtensionIn(checkout, gitFile = "git") {
1030
1033
  const path = join(checkout, ".lfsconfig");
1031
1034
  try {
1032
1035
  if (!lstatSync(path).isFile())
@@ -1035,7 +1038,7 @@ export function lfsExtensionIn(checkout) {
1035
1038
  catch {
1036
1039
  return false;
1037
1040
  }
1038
- const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
1041
+ const parsed = spawnSync(gitFile, ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
1039
1042
  return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name) || ["include", "includeif"].includes(keyParts(name).section));
1040
1043
  }
1041
1044
  /** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
@@ -1070,12 +1073,15 @@ export function checkoutGit(inner = run, mode = "build", binding = {}) {
1070
1073
  const key = commandSetting(own.stdout, { lfsChecked: true });
1071
1074
  if (key !== null)
1072
1075
  return refused(`the checkout's git config sets ${key}, which can run a command`);
1076
+ // A git without --no-lazy-fetch: a repository that could fetch lazily is refused, from the view's frozen config.
1077
+ if (!lazyFetchSupported() && lazyFetchSetting(own.stdout) !== null)
1078
+ return refused(PARTIAL_CLONE_REFUSAL);
1073
1079
  // The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
1074
1080
  // A read carries no filter: it reads attributes from the empty tree anyway.
1075
1081
  const carried = mode === "read" ? withoutFilters(carriedSettings(own.stdout)) : carriedSettings(own.stdout);
1076
1082
  const env = { ...viewed.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
1077
1083
  // Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
1078
- const safeArgs = [...args];
1084
+ const safeArgs = withoutUnsupportedFlags(args);
1079
1085
  const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
1080
1086
  if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
1081
1087
  safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
@@ -1117,8 +1123,8 @@ export const PAGE_READ_FLAGS = [
1117
1123
  * its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
1118
1124
  * also runs against a view of the checkout's repository (gitView; checkoutGitSync, checkoutGit "read"), whose config and info are the plane's. The cost is cosmetic: a checkout's line-ending or encoding rules are not applied, so
1119
1125
  * a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
1120
- export function readFlags() {
1121
- return PAGE_READ_FLAGS;
1126
+ export function readFlags(gitFile = "git") {
1127
+ return lazyFetchSupported(gitFile) ? PAGE_READ_FLAGS : PAGE_READ_FLAGS.filter(flag => flag !== "--no-lazy-fetch");
1122
1128
  }
1123
1129
  /**
1124
1130
  * checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
@@ -1127,22 +1133,32 @@ export function readFlags() {
1127
1133
  * or info/attributes change after they were read), submodules skipped, bounded. A refusal throws.
1128
1134
  */
1129
1135
  export function checkoutGitSync(cwd, args, options = {}) {
1130
- if (checkoutIncludes(cwd))
1136
+ const ran = viewedGitSync(cwd, args, options);
1137
+ if (ran.status !== 0)
1138
+ throw new Error(`git ${args[0] ?? ""} failed`);
1139
+ return ran.stdout;
1140
+ }
1141
+ export function viewedGitSync(cwd, args, options = {}) {
1142
+ const gitFile = options.git ?? "git";
1143
+ if (checkoutIncludes(cwd, gitFile))
1131
1144
  throw new Error("the checkout's git config includes another file, or couldn't be read as a plain file");
1132
- if (lfsExtensionIn(cwd))
1145
+ if (lfsExtensionIn(cwd, gitFile))
1133
1146
  throw new Error("the checkout's .lfsconfig sets a git-lfs key that can run a command");
1134
1147
  // Its config and info/attributes as read once, in a folder of the plane's own: neither can change before git runs.
1135
- const view = gitView(cwd, "read", options.binding);
1148
+ const view = gitView(cwd, "read", options.binding, gitFile);
1136
1149
  try {
1137
1150
  const env = { PATH: process.env["PATH"] ?? "", HOME: process.env["HOME"] ?? "", LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", ...view.env };
1138
1151
  const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
1139
- const own = spawnSync("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
1152
+ const own = spawnSync(gitFile, ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], bounded);
1140
1153
  if (own.status !== 0)
1141
1154
  throw new Error("its config couldn't be read");
1142
1155
  const key = commandSetting(own.stdout, { lfsChecked: true });
1143
1156
  if (key !== null)
1144
1157
  throw new Error(`the checkout's git config sets ${key}, which can run a command`);
1145
- const safeArgs = [...args];
1158
+ // A git without --no-lazy-fetch: a repository that could fetch lazily is refused, from the view's frozen config.
1159
+ if (!lazyFetchSupported(gitFile) && lazyFetchSetting(own.stdout) !== null)
1160
+ throw new Error(PARTIAL_CLONE_REFUSAL);
1161
+ const safeArgs = withoutUnsupportedFlags(args, gitFile);
1146
1162
  const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
1147
1163
  if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
1148
1164
  safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
@@ -1151,13 +1167,20 @@ export function checkoutGitSync(cwd, args, options = {}) {
1151
1167
  safeArgs.splice(command + 1, 0, "--no-ext-diff", "--no-textconv");
1152
1168
  // The operator's own values, but no filter: PAGE_READ_FLAGS reads attributes from the empty tree anyway.
1153
1169
  const carried = withoutFilters(carriedSettings(own.stdout));
1154
- const ran = spawnSync("git", [...TEARDOWN_FLAGS, ...readFlags(), "-c", "diff.ignoreSubmodules=all", ...carried, ...safeArgs], bounded);
1155
- if (ran.status !== 0)
1156
- throw new Error(`git ${args[0] ?? ""} failed`);
1170
+ const argv = [...TEARDOWN_FLAGS, ...readFlags(gitFile), "-c", "diff.ignoreSubmodules=all", ...carried, ...safeArgs];
1171
+ if (options.buffer === true) {
1172
+ // stdin is the caller's own bytes when given (cat-file --batch), else closed.
1173
+ const ran = spawnSync(gitFile, argv, { ...bounded, encoding: "buffer", ...(options.input === undefined ? {} : { input: options.input, stdio: ["pipe", "pipe", "pipe"] }) });
1174
+ const why = view.settle();
1175
+ if (why !== null)
1176
+ throw new Error(why);
1177
+ return { status: ran.error === undefined ? ran.status : null, stdout: ran.stdout };
1178
+ }
1179
+ const ran = spawnSync(gitFile, argv, bounded);
1157
1180
  const why = view.settle();
1158
1181
  if (why !== null)
1159
1182
  throw new Error(why);
1160
- return view.restore(ran.stdout, args);
1183
+ return { status: ran.error === undefined ? ran.status : null, stdout: view.restore(ran.stdout ?? "", args) };
1161
1184
  }
1162
1185
  finally {
1163
1186
  view.dispose();
@@ -1183,3 +1206,92 @@ export function hardenedGit(inner = run, mode = "build", binding = {}) {
1183
1206
  return hardened("git", rest, { ...options, ...(cwd === undefined ? {} : { cwd }) });
1184
1207
  };
1185
1208
  }
1209
+ /**
1210
+ * Git older than 2.45 has no `--no-lazy-fetch` (and no GIT_NO_LAZY_FETCH): there, every hardened read leaves the flag
1211
+ * out and instead refuses a repository whose config makes lazy fetching possible (lazyFetchSetting), while
1212
+ * `protocol.allow=never` stays on every read and write, so a lazy fetch that slipped past would still find no
1213
+ * transport. Asked once per process, as the plane's other plain git probes run: no system or global config.
1214
+ */
1215
+ const lazyFetchByGit = new Map();
1216
+ let lazyFetchOverride;
1217
+ /** The git executable `file` names, as the real path it runs (a bare name looked up on PATH), or `file` itself. */
1218
+ function gitExecutablePath(file) {
1219
+ const candidates = file.includes("/") ? [file] : (process.env["PATH"] ?? "").split(delimiter).filter(dir => isAbsolute(dir)).map(dir => join(dir, file));
1220
+ for (const candidate of candidates) {
1221
+ try {
1222
+ accessSync(candidate, fsConstants.X_OK);
1223
+ return realpathSync(candidate);
1224
+ }
1225
+ catch { /* next */ }
1226
+ }
1227
+ return file;
1228
+ }
1229
+ /** Asked once per git executable (eval-harness review 2, follow-up): a fixed git given to viewedGitSync is probed
1230
+ * itself, never answered for by the one PATH names. */
1231
+ export function lazyFetchSupported(gitFile = "git") {
1232
+ if (lazyFetchOverride !== undefined)
1233
+ return lazyFetchOverride;
1234
+ const executable = gitExecutablePath(gitFile);
1235
+ let known = lazyFetchByGit.get(executable);
1236
+ if (known === undefined) {
1237
+ const probe = spawnSync(executable, ["--no-lazy-fetch", "version"], { cwd: "/", encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"],
1238
+ env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
1239
+ known = probe.status === 0;
1240
+ lazyFetchByGit.set(executable, known);
1241
+ }
1242
+ return known;
1243
+ }
1244
+ /** For tests: forget the probes' answers, or set one for every executable (null forgets). */
1245
+ export function setLazyFetchSupportedForTest(value) { lazyFetchOverride = value ?? undefined; lazyFetchByGit.clear(); }
1246
+ /** The words for a partial clone refused on a git without `--no-lazy-fetch`. */
1247
+ export const PARTIAL_CLONE_REFUSAL = "this project is a partial clone; protected reads need git 2.45 or newer";
1248
+ /** `--no-lazy-fetch` where the installed git has it, else nothing (and lazyFetchSetting refuses partial clones). */
1249
+ export function noLazyFetch() { return lazyFetchSupported() ? ["--no-lazy-fetch"] : []; }
1250
+ /** A caller's own arguments with `--no-lazy-fetch` taken out when the installed git doesn't know it. */
1251
+ function withoutUnsupportedFlags(args, gitFile = "git") {
1252
+ return lazyFetchSupported(gitFile) ? [...args] : args.filter(arg => arg !== "--no-lazy-fetch");
1253
+ }
1254
+ /**
1255
+ * The repository setting (local or worktree scope of a `git config --list --show-scope -z` listing) that makes lazy
1256
+ * fetching possible, or null: `extensions.partialClone` set at all, any `remote.<name>.promisor` not plainly false, or
1257
+ * any `remote.<name>.partialCloneFilter` set (git makes that remote a promisor). Fails closed: a listing that can't be
1258
+ * read names a setting. `remote.<name>.vcs` (a foreign-VCS helper) is named too, though the classifier already refuses
1259
+ * it (commandSetting: not in the remote allowlist), as it does every `protocol.*` key and every include.
1260
+ */
1261
+ export function lazyFetchSetting(listing) {
1262
+ try {
1263
+ const fields = listing.split("\0");
1264
+ for (let at = 0; at + 1 < fields.length; at += 2) {
1265
+ const scope = fields[at];
1266
+ if (scope !== "local" && scope !== "worktree")
1267
+ continue;
1268
+ const entry = fields[at + 1];
1269
+ const newline = entry.indexOf("\n");
1270
+ const key = newline < 0 ? entry : entry.slice(0, newline);
1271
+ const value = newline < 0 ? null : entry.slice(newline + 1);
1272
+ const { section, subsection, variable } = keyParts(key);
1273
+ if (section === "extensions" && variable === "partialclone")
1274
+ return key.toLowerCase();
1275
+ if (section === "remote" && subsection !== null && (variable === "partialclonefilter" || variable === "vcs"))
1276
+ return key.toLowerCase();
1277
+ if (section === "remote" && subsection !== null && variable === "promisor" && !/^(false|no|off|0)$/i.test((value ?? "true").trim()))
1278
+ return key.toLowerCase();
1279
+ }
1280
+ return null;
1281
+ }
1282
+ catch {
1283
+ return "a setting that couldn't be read";
1284
+ }
1285
+ }
1286
+ /**
1287
+ * For the reads that run git directly on a repository rather than through a view: with `--no-lazy-fetch` they run as
1288
+ * they always have (the flag stops any fetch). Without it, they must not check a repository's config and then run git
1289
+ * on it (the config could change in between, or an include could set what the check never saw): `inner`, wrapped so
1290
+ * every git command runs through a frozen read view (checkoutGit "read"), whose own config listing (includes refused,
1291
+ * command and protocol settings refused, a partial clone refused) is the config git then runs with.
1292
+ */
1293
+ export function lazyFetchSafeGit(inner = run) {
1294
+ // On every git version (eval-harness review 1, finding 7): with --no-lazy-fetch the direct read still took the
1295
+ // repository's config and its includes as git found them, and an include naming a FIFO could hang the plane.
1296
+ return checkoutGit(inner, "read");
1297
+ }
@@ -20,7 +20,7 @@ const VITEST_CONFIG = /(^|\/)vitest\.config\.[cm]?[jt]s$/;
20
20
  const EXTENSIONS = ["ts", "tsx", "mts", "cts", "js", "mjs", "cjs", "jsx"];
21
21
  /** The product's entry points: the CLI binary, the processes it starts by file name, and the browser bundles. */
22
22
  export const PRODUCT_ENTRIES = Object.freeze([
23
- "src/bin.ts", "src/cli.ts", "src/controller-service.ts", "src/desktop-host.ts", "src/sandbox-launch.ts", "src/tool-launcher.ts",
23
+ "src/bin.ts", "src/cli.ts", "src/controller-service.ts", "src/up-role-child.ts", "src/desktop-host.ts", "src/sandbox-launch.ts", "src/tool-launcher.ts",
24
24
  "src/browser/app.tsx", "src/browser/terminal-app.tsx",
25
25
  ]);
26
26
  const join = (dir, spec) => {