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.
Files changed (144) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +46 -10
  5. package/dist/assignment.js +3 -0
  6. package/dist/backup-ui.d.ts +9 -1
  7. package/dist/backup-ui.js +28 -3
  8. package/dist/backup.js +4 -3
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  10. package/dist/browser/workspace.css +2 -2
  11. package/dist/browser/workspace.js +31 -31
  12. package/dist/browser-workspace.d.ts +2 -0
  13. package/dist/builder.d.ts +18 -3
  14. package/dist/builder.js +152 -47
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +295 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +67 -6
  21. package/dist/check-report.js +254 -9
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/claim.d.ts +9 -0
  24. package/dist/claim.js +9 -1
  25. package/dist/cli.js +8 -5
  26. package/dist/coding-handoff.d.ts +1 -0
  27. package/dist/coding-handoff.js +20 -11
  28. package/dist/commit-extract.d.ts +6 -0
  29. package/dist/commit-extract.js +63 -8
  30. package/dist/console-chrome.d.ts +41 -0
  31. package/dist/console-chrome.js +166 -0
  32. package/dist/contracts/console-api.d.ts +1 -1
  33. package/dist/contracts/console-api.js +1 -1
  34. package/dist/contracts/release-check.d.ts +12 -0
  35. package/dist/contracts/release-check.js +17 -0
  36. package/dist/contracts/release-terms.d.ts +41 -0
  37. package/dist/contracts/release-terms.js +53 -0
  38. package/dist/contracts/verification-receipt.d.ts +20 -2
  39. package/dist/contracts/verification-receipt.js +15 -3
  40. package/dist/contracts/verification-view.d.ts +8 -0
  41. package/dist/controller-service.js +4 -1
  42. package/dist/controller-supervisor.d.ts +8 -0
  43. package/dist/controller-supervisor.js +33 -1
  44. package/dist/demo.js +3 -2
  45. package/dist/deploy-gate.js +5 -0
  46. package/dist/deploy-rescue.d.ts +41 -0
  47. package/dist/deploy-rescue.js +94 -0
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/dispose.d.ts +3 -0
  50. package/dist/dispose.js +9 -5
  51. package/dist/doctor.d.ts +7 -0
  52. package/dist/doctor.js +10 -2
  53. package/dist/envelope.js +2 -1
  54. package/dist/evidence.d.ts +9 -0
  55. package/dist/evidence.js +19 -4
  56. package/dist/exec.d.ts +9 -0
  57. package/dist/exec.js +22 -2
  58. package/dist/git-floor.d.ts +16 -0
  59. package/dist/git-floor.js +36 -0
  60. package/dist/grant.js +5 -1
  61. package/dist/guides.js +22 -11
  62. package/dist/latency-timeline.d.ts +46 -0
  63. package/dist/latency-timeline.js +120 -0
  64. package/dist/live-backup.d.ts +39 -0
  65. package/dist/live-backup.js +271 -0
  66. package/dist/operate.d.ts +13 -0
  67. package/dist/operate.js +969 -328
  68. package/dist/planner.js +2 -2
  69. package/dist/prepared-evidence.d.ts +2 -1
  70. package/dist/prepared-evidence.js +8 -3
  71. package/dist/project-knowledge.js +6 -2
  72. package/dist/project-learning.js +2 -2
  73. package/dist/proof.js +6 -0
  74. package/dist/read-executor-worker.js +8 -4
  75. package/dist/read-executor.d.ts +18 -1
  76. package/dist/read-executor.js +8 -0
  77. package/dist/release-adapters.d.ts +80 -2
  78. package/dist/release-adapters.js +441 -16
  79. package/dist/release-authority.js +6 -2
  80. package/dist/release-auto.d.ts +38 -7
  81. package/dist/release-auto.js +131 -15
  82. package/dist/release-card.d.ts +15 -3
  83. package/dist/release-card.js +25 -9
  84. package/dist/release-carry.d.ts +21 -1
  85. package/dist/release-carry.js +22 -0
  86. package/dist/release-ci-hold.d.ts +41 -0
  87. package/dist/release-ci-hold.js +53 -0
  88. package/dist/release-cli.d.ts +3 -0
  89. package/dist/release-cli.js +95 -17
  90. package/dist/release-coverage.js +5 -2
  91. package/dist/release-gate.d.ts +5 -0
  92. package/dist/release-gate.js +1 -1
  93. package/dist/release-recheck.d.ts +1 -1
  94. package/dist/release-recheck.js +8 -2
  95. package/dist/release.d.ts +144 -14
  96. package/dist/release.js +421 -81
  97. package/dist/review-context.js +8 -6
  98. package/dist/runner-process.d.ts +67 -0
  99. package/dist/runner-process.js +219 -0
  100. package/dist/runner.d.ts +48 -6
  101. package/dist/runner.js +73 -9
  102. package/dist/sandbox-runtime.d.ts +8 -0
  103. package/dist/sandbox-runtime.js +14 -2
  104. package/dist/scope.d.ts +19 -1
  105. package/dist/scope.js +26 -3
  106. package/dist/scout.js +2 -2
  107. package/dist/secret-mint.d.ts +1 -0
  108. package/dist/secret-mint.js +14 -0
  109. package/dist/serve.js +112 -51
  110. package/dist/server/chat.js +5 -5
  111. package/dist/server/http.d.ts +2 -0
  112. package/dist/server/pages.js +7 -3
  113. package/dist/server/remote-hooks.d.ts +1 -1
  114. package/dist/server/remote-hooks.js +5 -3
  115. package/dist/server/render-results.d.ts +2 -0
  116. package/dist/server/render-results.js +1 -0
  117. package/dist/server/render-tasks.d.ts +2 -0
  118. package/dist/server/render-tasks.js +2 -1
  119. package/dist/server/request-context.d.ts +8 -0
  120. package/dist/server/runtime.d.ts +2 -0
  121. package/dist/server/settings.js +22 -2
  122. package/dist/server/task-views.js +7 -2
  123. package/dist/server/tasks.js +4 -1
  124. package/dist/setup-guide.d.ts +2 -2
  125. package/dist/storage-sweep.js +1 -1
  126. package/dist/storage.d.ts +5 -0
  127. package/dist/storage.js +28 -1
  128. package/dist/store.d.ts +135 -2
  129. package/dist/store.js +275 -13
  130. package/dist/surface.js +3 -1
  131. package/dist/task-base.d.ts +4 -0
  132. package/dist/task-base.js +22 -2
  133. package/dist/teardown-git.d.ts +52 -3
  134. package/dist/teardown-git.js +145 -33
  135. package/dist/test-infra.js +1 -1
  136. package/dist/toolroll-update.js +2 -2
  137. package/dist/up-processes.d.ts +137 -0
  138. package/dist/up-processes.js +165 -0
  139. package/dist/up-role-child.d.ts +1 -0
  140. package/dist/up-role-child.js +81 -0
  141. package/dist/verification-evidence.d.ts +1 -0
  142. package/dist/verification-evidence.js +13 -1
  143. package/dist/workspace-revision.js +3 -1
  144. package/package.json +2 -1
@@ -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, killSignal: "SIGKILL", 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}-`)));
@@ -485,9 +488,9 @@ function gitView(checkout, kind, binding = {}) {
485
488
  throw new Error("the checkout's git config couldn't be read as a plain file");
486
489
  const configPath = join(view, "config");
487
490
  writeFileSync(configPath, config, { mode: 0o600 });
488
- const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } };
491
+ const plain = { cwd: "/", encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", 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, killSignal: "SIGKILL", 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
- 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);
1151
+ const bounded = { cwd, env, encoding: "utf8", timeout: options.timeoutMs ?? 60_000, killSignal: "SIGKILL", maxBuffer: options.maxBuffer ?? 1_048_576, stdio: ["ignore", "pipe", "pipe"] };
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, killSignal: "SIGKILL", 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) => {
@@ -37,7 +37,7 @@ import { removeTerminalUpdateGate } from "./terminal-catalog.js";
37
37
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
38
38
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
39
39
  import { NAME } from "./names.js";
40
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS } from "./store.js";
40
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS } from "./store.js";
41
41
  import { isNewer, REGISTRY } from "./releases.js";
42
42
  import { markNeverIndex } from "./never-index.js";
43
43
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -409,7 +409,7 @@ const HISTORY_RULES = {
409
409
  // v126: a receipt's state, acceptance time and held item, a trigger's credential generation and turned-away count,
410
410
  // and who made a card read none, 0, or none. A table with an earlier rule (flow_trigger's v115 move) keeps it too.
411
411
  // v128: a lead message's pending flag reads waiting exactly for a team queue's unadmitted operator message.
412
- ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS))
412
+ ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS))
413
413
  .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
414
414
  };
415
415
  /** The tables a migration may remove whole (v115's removed features). */
@@ -0,0 +1,137 @@
1
+ export type UpRoleName = "console" | "worker";
2
+ export type UpConsoleConfig = {
3
+ host: string;
4
+ port: number;
5
+ allowedHosts: string[] | null;
6
+ publicUrl: string | null;
7
+ localRunner: string;
8
+ poolRoot: string;
9
+ repos: string[];
10
+ projectRoots: string[];
11
+ registryPath: string;
12
+ editor: boolean;
13
+ };
14
+ export type UpWorkerConfig = {
15
+ runner: string;
16
+ token: string;
17
+ repos: string[];
18
+ pinnedRepos: string[];
19
+ projectRoots: string[];
20
+ registryPath: string;
21
+ pool: string;
22
+ forMs: number | null;
23
+ publicUrl: string | null;
24
+ stopGraceMs: number;
25
+ verbose: boolean;
26
+ terminal: boolean;
27
+ /** How long a start waits for a predecessor's watch lease to expire (a crashed worker's, after a restart). */
28
+ leaseWaitMs: number;
29
+ };
30
+ export type UpRoleStart = {
31
+ type: "start";
32
+ role: "console";
33
+ databaseFile: string;
34
+ containment: string | null;
35
+ build: string;
36
+ config: UpConsoleConfig;
37
+ } | {
38
+ type: "start";
39
+ role: "worker";
40
+ databaseFile: string;
41
+ containment: string | null;
42
+ build: string;
43
+ config: UpWorkerConfig;
44
+ };
45
+ export type ParentToRole = {
46
+ type: "repos";
47
+ repos: string[];
48
+ } | {
49
+ type: "stopping";
50
+ } | {
51
+ type: "hard-stop";
52
+ } | {
53
+ type: "sign-in-link";
54
+ id: number;
55
+ account: string;
56
+ };
57
+ export type RoleToParent = {
58
+ type: "ready";
59
+ role: UpRoleName;
60
+ build: string;
61
+ pid: number;
62
+ url: string | null;
63
+ port: number | null;
64
+ } | {
65
+ type: "log";
66
+ line: string;
67
+ } | {
68
+ type: "repos";
69
+ repos: string[];
70
+ } | {
71
+ type: "failed";
72
+ reason: "port-busy" | "start";
73
+ detail: string;
74
+ } | {
75
+ type: "fatal";
76
+ detail: string;
77
+ } | {
78
+ type: "sign-in-link";
79
+ id: number;
80
+ link: string | null;
81
+ } | {
82
+ type: "closed";
83
+ problem: string | null;
84
+ };
85
+ /** One role's side of the channel: the same shape over IPC (a child process) and in memory (one process). */
86
+ export type RolePort = {
87
+ send: (message: RoleToParent) => void;
88
+ onMessage: (handler: (message: ParentToRole) => void) => void;
89
+ /** First call: drain. Every later call: stop now (the second signal's hard stop). */
90
+ onStop: (handler: (hard: boolean) => void) => void;
91
+ };
92
+ /** The parent's word to a role, checked before anything acts on it. */
93
+ export declare function parentMessage(value: unknown): ParentToRole | null;
94
+ /** A role's word to the parent, checked before the parent relays or acts on it. */
95
+ export declare function roleMessage(value: unknown, role: UpRoleName): RoleToParent | null;
96
+ /** The start message a role child waits for. The console's config carries no runner token; anything else refuses. */
97
+ export declare function startMessage(value: unknown): UpRoleStart | null;
98
+ /** Two ends of an in-memory channel: one process composing both roles (tests and the desktop host), same messages. */
99
+ export declare function memoryChannel(role: UpRoleName): {
100
+ port: RolePort;
101
+ parent: {
102
+ send: (message: ParentToRole) => void;
103
+ onMessage: (handler: (message: RoleToParent) => void) => void;
104
+ stop: (hard: boolean) => void;
105
+ };
106
+ };
107
+ /** A build identity: the version and the exact code of the module that composes `up`. Both roles must report the
108
+ * parent's own, or the parent stops rather than run a console and a worker from different builds. */
109
+ export declare function buildIdentity(version: string, moduleFile: string): string;
110
+ /** How many replacements in a row may end before becoming ready, with no refusal said, before `up` stops. */
111
+ export declare const ROLE_NOT_READY_REPLACEMENTS = 3;
112
+ /**
113
+ * One supervised role's generations (Codex review round 1, finding 5): readiness is per generation, not for the role's
114
+ * whole life. When a generation ends, the verdict is:
115
+ * - `fail-start`: the role never became ready at all; the start fails as one, as before the split;
116
+ * - `restart`: this generation was ready and crashed afterwards (the supervisor starts it again);
117
+ * - `end`: a replacement refused to start (another build, another database, a start refused) — permanent, so the whole
118
+ * `up` stops rather than loop — or ROLE_NOT_READY_REPLACEMENTS replacements in a row ended before readiness.
119
+ */
120
+ export declare class RoleGenerations {
121
+ private everReady;
122
+ private generationReady;
123
+ private refusal;
124
+ private notReadyStreak;
125
+ /** A new generation was spawned: nothing about it is known yet. */
126
+ spawned(): void;
127
+ /** This generation became ready; true when it is the role's first readiness ever. */
128
+ ready(): boolean;
129
+ /** This generation refused its start for good (it said why, or its build is another). */
130
+ refused(detail: string): void;
131
+ /** What this generation said when it refused, if it did. */
132
+ refusedWith(): string | null;
133
+ /** Whether any generation ever became ready. */
134
+ wasReady(): boolean;
135
+ /** This generation ended (not stopped by `up`): what happens next. */
136
+ ended(): "fail-start" | "restart" | "end";
137
+ }