toolroll 0.9.75 → 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 (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -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 +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. package/package.json +2 -1
package/dist/dispose.js CHANGED
@@ -455,7 +455,10 @@ export function regateTask(store, taskId, now, approval) {
455
455
  if (scope === null)
456
456
  return { ok: false, reason: "no-attempt", message: `${taskId} has no scope` };
457
457
  const head = last.headRevision;
458
- const actor = approval.kind === "operator" ? approval.name : `mode ${approval.name}`;
458
+ const actor = approval.kind === "operator" ? approval.name : approval.kind === "release" ? approval.name : `mode ${approval.name}`;
459
+ if (approval.kind === "release" && ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null)) {
460
+ return { ok: false, reason: "not-approved", message: `${taskId} isn't a release candidate; only a release reruns under its own approval` };
461
+ }
459
462
  // A prepared candidate (a release gate, the nightly check) already names the approved commit, and the builder loads
460
463
  // exactly its tree on every attempt. The rerun keeps that scope and its approval as they are: nothing is proposed or
461
464
  // sealed again, so a regate can never swap the approved commit, its release terms or the person's yes for others
@@ -481,7 +484,7 @@ export function regateTask(store, taskId, now, approval) {
481
484
  return { ok: false, reason: why[queued.reason] ?? "not-rejected", message: `${taskId} could not be queued again: ${queued.reason}` };
482
485
  }
483
486
  }
484
- store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : "automatically once, because the failure was outside this change"}.`, now);
487
+ store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : approval.kind === "release" ? "because Toolroll confirmed the failure was its own infrastructure, not the code" : "automatically once, because the failure was outside this change"}.`, now);
485
488
  return { ok: true, run: last.id, head: candidate };
486
489
  });
487
490
  }
@@ -494,7 +497,7 @@ export function regateTask(store, taskId, now, approval) {
494
497
  let sealed = false;
495
498
  if (approval.kind === "operator")
496
499
  sealed = approve(store, taskId, approval.name, now, proposed.digest, approval.token).ok;
497
- else
500
+ else if (approval.kind === "mode")
498
501
  sealed = store.sealScopeApproval(taskId, actor, now, {}, { kind: "mode", modeDigest: approval.modeDigest });
499
502
  if (!sealed)
500
503
  return { ok: false, reason: "not-approved", message: `${taskId}'s rerun scope could not be approved` };
package/dist/doctor.d.ts CHANGED
@@ -24,6 +24,11 @@ export type DoctorReport = {
24
24
  version: string;
25
25
  problem: string | null;
26
26
  };
27
+ /** The installed git against Toolroll's floor (git-floor.ts): null when it is new enough (or unreadable). */
28
+ git?: {
29
+ floor: string;
30
+ problem: string | null;
31
+ };
27
32
  };
28
33
  export type DoctorDeps = {
29
34
  platform?: NodeJS.Platform;
@@ -35,6 +40,8 @@ export type DoctorDeps = {
35
40
  procRoot?: string;
36
41
  /** The doctor's own environment: the terminal a `toolroll up` would start from. */
37
42
  env?: NodeJS.ProcessEnv;
43
+ /** The installed git's floor problem (a test's stand-in for git-floor.ts installedGitFloorProblem). */
44
+ gitFloorProblem?: string | null;
38
45
  };
39
46
  /** The names among `pairs` that hold a credential: a provider key, a credential-looking name, or a key-shaped value.
40
47
  * Values are only tested here and never leave. */
package/dist/doctor.js CHANGED
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
  import { daemonStatus, installedServiceDefinition, planDaemon } from "./daemon.js";
22
22
  import { scanForSecrets } from "./evidence.js";
23
23
  import { envelopeJson } from "./envelope.js";
24
+ import { GIT_FLOOR_WORDS, installedGitFloorProblem } from "./git-floor.js";
24
25
  import { run as runCommand } from "./exec.js";
25
26
  import { OWN_KEY_ENV } from "./keys.js";
26
27
  import { ALL_CREDENTIAL_ENV } from "./provider.js";
@@ -123,7 +124,8 @@ function reinstallCommand(args) {
123
124
  const flag = equal < 0 ? arg : arg.slice(0, equal);
124
125
  const value = equal < 0 ? args[index + 1] : arg.slice(equal + 1);
125
126
  const known = freeform.has(flag) || numeric.has(flag) || Object.hasOwn(enums, flag);
126
- if (equal < 0 && (known || CREDENTIAL_FLAG.test(flag)) && value !== undefined && !value.startsWith("-"))
127
+ // A credential's value is the next argument even when it starts with "-" (random base64url can).
128
+ if (equal < 0 && value !== undefined && (CREDENTIAL_FLAG.test(flag) || (known && !value.startsWith("-"))))
127
129
  index += 1;
128
130
  if (flag === "--token") {
129
131
  kept.push("--token-file", "<set-again>");
@@ -386,7 +388,9 @@ export async function auditServiceEnvironment(deps = {}) {
386
388
  /** The report as the owner reads it: one state, then each finding with its steps; detail after. */
387
389
  export function doctorLines(report) {
388
390
  const lines = [];
389
- if (report.ok)
391
+ if (report.git?.problem != null)
392
+ lines.push(`Git: ${report.git.problem}`, "");
393
+ if (report.findings.length === 0 && report.unreadable.length === 0)
390
394
  lines.push("No secrets found in Toolroll's service. A build can't read a key from it.");
391
395
  else if (report.findings.length > 0)
392
396
  lines.push(`Toolroll's service holds ${report.findings.length === 1 ? "a secret" : "secrets"} a build could read. Nothing was changed; follow these steps.`);
@@ -422,6 +426,10 @@ export async function runDoctorCommand(argv, write, deps = {}) {
422
426
  }
423
427
  const report = await auditServiceEnvironment(deps);
424
428
  report.sandbox = { engine: SANDBOX_RUNTIME.name, version: SANDBOX_RUNTIME.version, problem: sandboxRuntimeProblem() };
429
+ // A git older than the floor: named here too, and the console and worker won't start on it.
430
+ report.git = { floor: GIT_FLOOR_WORDS, problem: deps.gitFloorProblem === undefined ? installedGitFloorProblem() : deps.gitFloorProblem };
431
+ if (report.git.problem !== null)
432
+ report.ok = false;
425
433
  if (json)
426
434
  write(envelopeJson({ command: "doctor", ...report }));
427
435
  else
package/dist/envelope.js CHANGED
@@ -74,7 +74,8 @@ export const DOCUMENTED_REASONS = [
74
74
  "drift", // the branch, pull request or result moved from the gated commit
75
75
  "check-failed", // the release check failed; it stays visible and nothing reruns it
76
76
  "ci-failed", // a required pull-request check failed or is missing — nothing merged
77
- "base-moved", // main gained a commit the release branch lacks — nothing merged
77
+ "git-too-old", // the installed git is older than Toolroll's floor (git-floor.ts) — serve, watch and up don't start
78
+ "base-moved", // the release branch lacks main's head — nothing filed (gate) or merged
78
79
  "tree-mismatch", // main's merge commit has a different tree than the gated commit — nothing tagged
79
80
  "conflict", // a task or tag exists for a different commit
80
81
  "mixed-runtime", // the console, worker and CLI names don't run one build
package/dist/evidence.js CHANGED
@@ -23,6 +23,7 @@ import { parseReport, REPORT_LIMITS } from "./scout-report.js";
23
23
  import { parseProof, PROOF_LIMITS } from "./proof.js";
24
24
  import { HANDOFF_VERSION, readHandoffArtifact } from "./contracts/handoff.js";
25
25
  import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
26
+ import { noLazyFetch } from "./teardown-git.js";
26
27
  /** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
27
28
  export const MAILBOX_PREFIX = "STANDING-ORDERS-PARK-";
28
29
  /** The terminal handoff: how every non-parking attempt says how it ended. */
@@ -929,7 +930,9 @@ export async function captureBaseTree(store, git, repoRoot, repo, base, root, ru
929
930
  }
930
931
  }
931
932
  async function captureBaseTreeInner(store, git, repoRoot, repo, base, root, runId, now) {
932
- const args = ["--no-lazy-fetch", "--no-replace-objects", "--no-optional-locks", "ls-tree", "-r", "-l", "-z", base];
933
+ const args = [...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks", "ls-tree", "-r", "-l", "-z", base];
934
+ // `git` is the builder's hardened checkout git (a frozen view): on a git without --no-lazy-fetch it refuses a partial
935
+ // clone from the same config it runs with (teardown-git.ts lazyFetchSetting).
933
936
  const listed = await git("git", args, { cwd: repoRoot, envAllowlist: ["PATH", "HOME"] });
934
937
  const command = `git ls-tree -r -l -z ${base} (exit ${listed.code})`;
935
938
  const refuse = () => {
package/dist/exec.d.ts CHANGED
@@ -28,6 +28,13 @@ export type ExecResult = {
28
28
  * returned — a transport exit alone never proves that.
29
29
  */
30
30
  containment?: ContainmentOutcome;
31
+ /** The signal that ended the process, when one did (its `code` then reads 1, as it always has): kept apart so a
32
+ * killed command is never mistaken for one that exited 1 on its own (release check 3261). Only the buffered
33
+ * process-group transport says. */
34
+ signal?: NodeJS.Signals | null;
35
+ /** This process (the plane) ended it with its hard stop (terminateLiveProviders: a worker shutting down), not
36
+ * anything the command or another process did. */
37
+ stoppedByPlane?: boolean;
31
38
  };
32
39
  export type ContainmentOutcome = {
33
40
  refused: string;
package/dist/exec.js CHANGED
@@ -21,6 +21,8 @@ import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
21
21
  export const CONTAINMENT_REFUSED_CODE = 126;
22
22
  /** Live provider children, for the deterministic stop. Registered only when `processGroup` was set. */
23
23
  const liveProviders = new Set();
24
+ /** Children this process's own hard stop ended: their results say so (`stoppedByPlane`). */
25
+ const hardStopped = new WeakSet();
24
26
  /** Live children by owner tag (v52): the exact-attempt stop's handle. A
25
27
  * child is registered here beside liveProviders when its options named an
26
28
  * owner, and dropped the moment it closes. */
@@ -370,6 +372,7 @@ function killGroup(child) {
370
372
  export function terminateLiveProviders() {
371
373
  let terminated = 0;
372
374
  for (const child of liveProviders) {
375
+ hardStopped.add(child);
373
376
  killGroup(child);
374
377
  terminated += 1;
375
378
  }
@@ -677,6 +680,7 @@ function runBufferedGroup(file, args, bag) {
677
680
  });
678
681
  let settled = false;
679
682
  let transient = false;
683
+ let endSignal = null;
680
684
  const finish = (code) => {
681
685
  if (settled)
682
686
  return;
@@ -697,6 +701,8 @@ function runBufferedGroup(file, args, bag) {
697
701
  timedOut: timedOut && !overflowed,
698
702
  notFound,
699
703
  ...containmentOf(container, empty, attachFailure),
704
+ ...(endSignal === null ? {} : { signal: endSignal }),
705
+ ...(hardStopped.has(child) ? { stoppedByPlane: true } : {}),
700
706
  },
701
707
  transient,
702
708
  });
@@ -709,7 +715,7 @@ function runBufferedGroup(file, args, bag) {
709
715
  stderr = String(error);
710
716
  finish(null);
711
717
  });
712
- child.on("close", code => finish(code));
718
+ child.on("close", (code, signal) => { endSignal = code === null ? signal ?? null : null; finish(code); });
713
719
  if (bag.stdin !== undefined && child.stdin !== null) {
714
720
  child.stdin.on("error", () => {
715
721
  inputFailed = true;
package/dist/first-run.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * the composer: nothing is filed until the person sends it and confirms.
7
7
  */
8
8
  import { run } from "./exec.js";
9
+ import { hardenedGit } from "./teardown-git.js";
9
10
  import { githubIssues } from "./issues.js";
10
11
  export const SIGN_IN_COMMAND = "claude auth login";
11
12
  export const SANDBOX_COMMAND = "npx toolroll demo";
@@ -95,7 +96,8 @@ export async function findFirstTasks(repo, runner = run) {
95
96
  let todos = [];
96
97
  if (issues.length < 3) {
97
98
  try {
98
- const result = await runner("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
99
+ // The project's repository is shared with builds that can write its config: the hardened read (teardown-git.ts).
100
+ const result = await (runner === run ? hardenedGit(run, "read") : runner)("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
99
101
  if (result.code === 0 || result.stdout !== "")
100
102
  todos = parseTodoLines(result.stdout);
101
103
  }
@@ -0,0 +1,16 @@
1
+ export declare const GIT_FLOOR: {
2
+ readonly major: 2;
3
+ readonly minor: 41;
4
+ };
5
+ export declare const GIT_FLOOR_WORDS: string;
6
+ /** The version `git --version` names ("git version 2.50.1 (Apple Git-155)" → 2.50.1), or null when it names none. */
7
+ export declare function parseGitVersion(text: string): {
8
+ major: number;
9
+ minor: number;
10
+ words: string;
11
+ } | null;
12
+ /** Why `version` (`git --version`'s answer) is below the floor, in one sentence, or null when it isn't (or can't be read). */
13
+ export declare function gitFloorWords(versionText: string): string | null;
14
+ /** The installed git's floor problem, asked once per process with no system or global config; null when git is new
15
+ * enough, or when no version could be read (a missing git is reported where it is used). */
16
+ export declare function installedGitFloorProblem(): string | null;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The oldest git Toolroll runs on (README, Requirements): the global `git --attr-source` option every hardened read
3
+ * takes arrived in git 2.41 (others in use are older: `worktree list -z` 2.36, GIT_CONFIG_COUNT and `--path-format`
4
+ * 2.31). On git older than 2.45, which lacks `--no-lazy-fetch`, partial clones are refused for protected reads
5
+ * (teardown-git.ts lazyFetchSupported), every protected read runs through a frozen view and refuses every transport,
6
+ * and nothing leans on GIT_NO_LAZY_FETCH, which 2.41.0 ignores. The CI git lanes test this floor's oldest release
7
+ * exactly, 2.41.0, and 2.47 (.github/workflows/ci.yml, scripts/ci-git.sh).
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ export const GIT_FLOOR = { major: 2, minor: 41 };
11
+ export const GIT_FLOOR_WORDS = `${GIT_FLOOR.major}.${GIT_FLOOR.minor}`;
12
+ /** The version `git --version` names ("git version 2.50.1 (Apple Git-155)" → 2.50.1), or null when it names none. */
13
+ export function parseGitVersion(text) {
14
+ const found = /^git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(text.trim());
15
+ return found === null ? null : { major: Number(found[1]), minor: Number(found[2]), words: `${found[1]}.${found[2]}${found[3] === undefined ? "" : `.${found[3]}`}` };
16
+ }
17
+ /** Why `version` (`git --version`'s answer) is below the floor, in one sentence, or null when it isn't (or can't be read). */
18
+ export function gitFloorWords(versionText) {
19
+ const version = parseGitVersion(versionText);
20
+ if (version === null)
21
+ return null;
22
+ if (version.major > GIT_FLOOR.major || (version.major === GIT_FLOOR.major && version.minor >= GIT_FLOOR.minor))
23
+ return null;
24
+ return `Toolroll needs git ${GIT_FLOOR_WORDS} or newer, and this machine's git is ${version.words}. Install a newer git (and put it first on PATH), then start Toolroll again.`;
25
+ }
26
+ let probed;
27
+ /** The installed git's floor problem, asked once per process with no system or global config; null when git is new
28
+ * enough, or when no version could be read (a missing git is reported where it is used). */
29
+ export function installedGitFloorProblem() {
30
+ if (probed === undefined) {
31
+ const answer = spawnSync("git", ["--version"], { cwd: "/", encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"],
32
+ env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
33
+ probed = answer.status === 0 ? gitFloorWords(answer.stdout ?? "") : null;
34
+ }
35
+ return probed;
36
+ }
@@ -0,0 +1,49 @@
1
+ export type GitIncident = {
2
+ at: string;
3
+ checkout: string;
4
+ repository: string;
5
+ /** Folders of the repository found swapped, and where each now leads (null: its pointers changed instead). */
6
+ swapped: {
7
+ path: string;
8
+ leadsTo: string | null;
9
+ }[];
10
+ };
11
+ type Listener = (incident: GitIncident) => void;
12
+ /** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
13
+ export declare function onGitIncident(listener: Listener): () => void;
14
+ /** Record and tell one incident. */
15
+ export declare function gitIncident(input: {
16
+ checkout: string;
17
+ repository: string;
18
+ swapped: GitIncident["swapped"];
19
+ record: string;
20
+ }): GitIncident;
21
+ /** What noticeGitIncidents needs of the store: its durable outbox, and its own project records. */
22
+ export type IncidentOutbox = {
23
+ enqueueNotification(notification: {
24
+ dedupeKey: string;
25
+ kind: string;
26
+ subject: string;
27
+ body: string;
28
+ pushClass?: "attention";
29
+ source?: {
30
+ project: string;
31
+ } | {
32
+ installation: true;
33
+ };
34
+ }, now: Date): boolean;
35
+ listProjects(): {
36
+ path: string;
37
+ name: string;
38
+ }[];
39
+ };
40
+ /**
41
+ * Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
42
+ * register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
43
+ * while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
44
+ * newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
45
+ * else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
46
+ * unsubscribe.
47
+ */
48
+ export declare function noticeGitIncidents(outbox: IncidentOutbox, now?: () => Date, record?: string): () => void;
49
+ export {};
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A git incident: the plane's own git wrote through a view of a repository (teardown-git.ts gitView) while a folder of
3
+ * that repository the view links to was swapped for a link elsewhere, or its git pointers changed (the residual the
4
+ * view can't prevent: git takes paths, and a build that outlived its turn can still rename a folder of the shared
5
+ * `.git` mid-command). Seen after git ran, it is:
6
+ *
7
+ * - recorded, one JSON line in `~/.toolroll/git-incidents.jsonl` (beside the fenced views folder, git-views.ts);
8
+ * - told to every listener (the console's, when it registers one; otherwise the plane's stderr);
9
+ * - and the command is reported refused, so no caller takes its result as done.
10
+ *
11
+ * Nothing is rolled back automatically: where a swapped folder leads is the agent's choice, and the plane never runs
12
+ * git there on its say (a confused deputy); what git wrote is for a person, named in the record.
13
+ */
14
+ import { appendFileSync, realpathSync } from "node:fs";
15
+ import { basename, dirname } from "node:path";
16
+ const listeners = new Set();
17
+ /** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
18
+ export function onGitIncident(listener) {
19
+ listeners.add(listener);
20
+ return () => listeners.delete(listener);
21
+ }
22
+ /** Record and tell one incident. */
23
+ export function gitIncident(input) {
24
+ const incident = { at: new Date().toISOString(), checkout: input.checkout, repository: input.repository, swapped: input.swapped };
25
+ try {
26
+ appendFileSync(input.record, `${JSON.stringify(incident)}\n`, { mode: 0o600 });
27
+ }
28
+ catch { /* still told */ }
29
+ if (listeners.size === 0)
30
+ process.stderr.write(`Toolroll git incident: ${JSON.stringify(incident)}\n`);
31
+ for (const listener of listeners) {
32
+ try {
33
+ listener(incident);
34
+ }
35
+ catch { /* every listener hears it */ }
36
+ }
37
+ return incident;
38
+ }
39
+ const noticed = new WeakSet();
40
+ /**
41
+ * Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
42
+ * register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
43
+ * while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
44
+ * newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
45
+ * else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
46
+ * unsubscribe.
47
+ */
48
+ export function noticeGitIncidents(outbox, now = () => new Date(), record = "~/.toolroll/git-incidents.jsonl") {
49
+ if (noticed.has(outbox))
50
+ return () => { };
51
+ noticed.add(outbox);
52
+ const stop = onGitIncident(incident => {
53
+ const main = basename(incident.repository) === ".git" ? dirname(incident.repository) : incident.repository;
54
+ const real = (path) => { try {
55
+ return realpathSync(path);
56
+ }
57
+ catch {
58
+ return null;
59
+ } };
60
+ const known = outbox.listProjects().find(project => real(project.path) !== null && real(project.path) === real(main)) ?? null;
61
+ outbox.enqueueNotification({
62
+ dedupeKey: `git-incident:${incident.at}:${known?.path ?? "unrecorded"}`, kind: "git-incident", pushClass: "attention",
63
+ source: known === null ? { installation: true } : { project: known.path },
64
+ subject: `Git incident in ${known === null ? "a project" : known.name}: a folder of its repository changed while Toolroll's git ran`,
65
+ body: `Toolroll stopped trusting that git command and changed nothing more. Nothing was rolled back: check the repository by hand. The checkout and the folders that changed are in ${record}.`,
66
+ }, now());
67
+ });
68
+ return () => { stop(); noticed.delete(outbox); };
69
+ }
@@ -0,0 +1,20 @@
1
+ /** The views folder under `home`. */
2
+ export declare function gitViewsRoot(home: string): string;
3
+ /**
4
+ * The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
5
+ * the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
6
+ */
7
+ export declare function ensureGitViewsRoot(home: string): string;
8
+ /**
9
+ * One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
10
+ * HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
11
+ * is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
12
+ * process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
13
+ */
14
+ export declare function lockCheckout(home: string, checkout: string, timeoutMs?: number): Promise<() => void>;
15
+ /**
16
+ * Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
17
+ * another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
18
+ * under the lock its caller already holds instead of waiting on itself.
19
+ */
20
+ export declare function withCheckoutLock<T>(home: string, checkout: string, work: () => Promise<T>): Promise<T>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Where the plane makes its private views of a repository for git (teardown-git.ts gitView): a folder of its own under
3
+ * the home folder's `~/.toolroll`, which every fence denies to builds, read and write (agent-fence.ts agentFence names
4
+ * this folder by name, made first so no build can be the one to create it). A view in the system temp folder could be
5
+ * rewritten by a build that writes anywhere and outlived its turn (Codex review of e48e9d8d).
6
+ */
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+ import { createHash } from "node:crypto";
9
+ import { closeSync, constants as fsConstants, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ /** The views folder under `home`. */
12
+ export function gitViewsRoot(home) {
13
+ return join(home, ".toolroll", "git-views");
14
+ }
15
+ /**
16
+ * The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
17
+ * the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
18
+ */
19
+ export function ensureGitViewsRoot(home) {
20
+ const uid = process.getuid?.();
21
+ const base = join(home, ".toolroll");
22
+ const root = gitViewsRoot(home);
23
+ for (const dir of [base, root]) {
24
+ try {
25
+ mkdirSync(dir, { mode: 0o700 });
26
+ }
27
+ catch (error) {
28
+ if (error.code !== "EEXIST")
29
+ throw error;
30
+ }
31
+ const held = lstatSync(dir);
32
+ if (!held.isDirectory() || held.isSymbolicLink() || (uid !== undefined && held.uid !== uid))
33
+ throw new Error(`${dir} isn't a folder of this user's own`);
34
+ }
35
+ return root;
36
+ }
37
+ /**
38
+ * One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
39
+ * HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
40
+ * is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
41
+ * process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
42
+ */
43
+ export async function lockCheckout(home, checkout, timeoutMs = 10 * 60_000) {
44
+ const folder = join(ensureGitViewsRoot(home), "locks");
45
+ try {
46
+ mkdirSync(folder, { mode: 0o700 });
47
+ }
48
+ catch (error) {
49
+ if (error.code !== "EEXIST")
50
+ throw error;
51
+ }
52
+ const held = lstatSync(folder);
53
+ if (!held.isDirectory() || held.isSymbolicLink())
54
+ throw new Error(`${folder} isn't a folder of this user's own`);
55
+ const path = join(folder, `${createHash("sha256").update(checkout).digest("hex")}.lock`);
56
+ const deadline = Date.now() + timeoutMs;
57
+ for (;;) {
58
+ try {
59
+ const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
60
+ try {
61
+ writeSync(fd, `${process.pid}\n`);
62
+ }
63
+ finally {
64
+ closeSync(fd);
65
+ }
66
+ let released = false;
67
+ return () => { if (!released) {
68
+ released = true;
69
+ rmSync(path, { force: true });
70
+ } };
71
+ }
72
+ catch (error) {
73
+ if (error.code !== "EEXIST")
74
+ throw error;
75
+ }
76
+ // Held: by a live process, wait; by one that is gone, take it over.
77
+ let owner = NaN;
78
+ try {
79
+ owner = Number(readFileSync(path, "utf8").trim());
80
+ }
81
+ catch {
82
+ owner = NaN;
83
+ }
84
+ if (Number.isSafeInteger(owner) && owner > 0 && owner !== process.pid && !alive(owner)) {
85
+ rmSync(path, { force: true });
86
+ continue;
87
+ }
88
+ if (Date.now() > deadline)
89
+ throw new Error("another of Toolroll's git commands on this checkout is still running");
90
+ await new Promise(done => setTimeout(done, 25));
91
+ }
92
+ }
93
+ function alive(pid) {
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ }
98
+ catch (error) {
99
+ return error.code === "EPERM";
100
+ }
101
+ }
102
+ /** The checkouts the current async call chain already holds (lockCheckout through withCheckoutLock). */
103
+ const heldHere = new AsyncLocalStorage();
104
+ /**
105
+ * Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
106
+ * another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
107
+ * under the lock its caller already holds instead of waiting on itself.
108
+ */
109
+ export async function withCheckoutLock(home, checkout, work) {
110
+ const held = heldHere.getStore() ?? new Set();
111
+ if (held.has(checkout))
112
+ return work();
113
+ const release = await lockCheckout(home, checkout);
114
+ try {
115
+ return await heldHere.run(new Set([...held, checkout]), work);
116
+ }
117
+ finally {
118
+ release();
119
+ }
120
+ }
package/dist/grant.js CHANGED
@@ -30,6 +30,7 @@
30
30
  * the same reason an unknown lease is: silence is not consent.
31
31
  */
32
32
  import { run } from "./exec.js";
33
+ import { lazyFetchSafeGit, noLazyFetch } from "./teardown-git.js";
33
34
  export const MUTATION_CLASSES = [
34
35
  "create",
35
36
  "transition",
@@ -175,10 +176,15 @@ async function observedByGit(repo, paths, runner) {
175
176
  const local = paths.filter(path => !isRemoteRef(path));
176
177
  if (local.length === 0)
177
178
  return false;
179
+ // On a git without --no-lazy-fetch, through a frozen read view (teardown-git.ts lazyFetchSafeGit): a partial clone is
180
+ // refused there, and a refusal reads as "not ignored", so observed.
181
+ const git = lazyFetchSafeGit(runner);
178
182
  for (const path of local) {
179
183
  const asked = [path, `${normalise(path)}/`];
180
184
  const ignored = await Promise.all(asked.map(async (candidate) => {
181
- const result = await runner("git", ["--no-optional-locks", "check-ignore", "-q", candidate], {
185
+ // check-ignore reads the index, so a planted fsmonitor would run: off (and hooks, lazy fetch) on the command line,
186
+ // which wins over any config the repository's builds can write.
187
+ const result = await git("git", ["--no-optional-locks", ...noLazyFetch(), "-c", "protocol.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "check-ignore", "-q", candidate], {
182
188
  cwd: repo,
183
189
  });
184
190
  return result.code === 0;
package/dist/guides.js CHANGED
@@ -510,9 +510,10 @@ instead of making them twice.
510
510
  rerunning the failed job on GitHub, run the same command again.
511
511
  - \`publish-failed\` / \`homebrew-failed\`: fix the failed workflow before
512
512
  resuming. A failed tag check cannot finish publication or update Homebrew.
513
- - \`base-moved\`: main gained a commit the branch doesn't have, so the
514
- merge wouldn't be the checked tree. Nothing was merged: update the
515
- branch and release it with \`--new\`.
513
+ - \`base-moved\`: the branch is behind main (it doesn't contain main's
514
+ head), so the merge wouldn't be the tree CI tested. Checked at the gate
515
+ (nothing filed) and again right before merging (nothing merged): update
516
+ the branch, push, and release it with \`--new\`.
516
517
  - \`tree-mismatch\`: main changed what was checked. Nothing was tagged;
517
518
  the deployed build stays.
518
519
  - \`timeout\`: rerun to keep waiting; \`--limit <step>=<minutes>\`
package/dist/operate.d.ts CHANGED
@@ -6,6 +6,7 @@ import type { StepIo } from "./flow-steps.js";
6
6
  import type { FetchLike } from "./flow-share.js";
7
7
  import { type Store } from "./store.js";
8
8
  import { type DispatchAdapter } from "./sync.js";
9
+ import { type RolePort, type UpRoleStart } from "./up-processes.js";
9
10
  import { type TelegramTransport } from "./telegram.js";
10
11
  import { type LeadCliSeams } from "./lead-cli.js";
11
12
  import { type Principal, type RemoteSource } from "./operate-remote.js";
@@ -89,6 +90,8 @@ export type OperateOptions = {
89
90
  };
90
91
  /** Injected by tests: the classifier that sizes filed tasks (none by default under test). */
91
92
  filingSizer?: Sizer;
93
+ /** Set only by up-role-child.ts: this process is one role of a `toolroll up` (the console or the worker). */
94
+ upRole?: UpRoleHost;
92
95
  };
93
96
  export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection" | "pullRequests">>;
94
97
  export declare const OPERATE_HELP: string;
@@ -117,6 +120,8 @@ export declare const KEYS_ACTIONS: readonly ["status", "set", "clear", "verify",
117
120
  * tests): every value-taking flag any verb reads, and every boolean.
118
121
  * A --flag in neither set is a typo, refused by name.
119
122
  */
123
+ /** Flags whose value is a secret someone types or pastes: taken as the next argument even when it starts with "--". */
124
+ export declare const SECRET_VALUE_FLAGS: ReadonlySet<string>;
120
125
  export declare const OPERATE_VALUE_FLAGS: ReadonlySet<string>;
121
126
  export declare const OPERATE_BOOLEAN_FLAGS: ReadonlySet<string>;
122
127
  export declare function parseOperateArgs(argv: readonly string[], ownValues?: ReadonlySet<string>): Args | {
@@ -168,3 +173,11 @@ export declare function readUpConsoleUrl(dir: string): string | null;
168
173
  export declare const PROPOSALS_ACTIONS: readonly ["list", "confirm", "dismiss"];
169
174
  /** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
170
175
  export declare function ownValueFlags(command: string, argv: readonly string[]): ReadonlySet<string> | undefined;
176
+ /** What up-role-child.ts hands runOperate: the role's start, and its channel to the `up` that started it. */
177
+ export type UpRoleHost = {
178
+ start: UpRoleStart;
179
+ port: RolePort;
180
+ };
181
+ /** A role child's whole run (up-role-child.ts): the ordinary command prelude (containment, secrets, limits), then the
182
+ * role itself. Only a process started by `up` gets here; no command line selects a role. */
183
+ export declare function runUpRole(start: UpRoleStart, port: RolePort): Promise<number>;