toolroll 0.9.94 → 0.9.96

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 (191) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/agent-fence.d.ts +6 -0
  3. package/dist/agent-fence.js +2 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -0
  6. package/dist/attest.d.ts +3 -0
  7. package/dist/attest.js +10 -0
  8. package/dist/browser/terminal.js +1 -1
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +24 -24
  11. package/dist/browser-crew.js +2 -0
  12. package/dist/browser-workspace.d.ts +5 -0
  13. package/dist/build-review.js +2 -0
  14. package/dist/builder.d.ts +7 -2
  15. package/dist/builder.js +183 -79
  16. package/dist/card-catalog.d.ts +209 -0
  17. package/dist/card-catalog.js +133 -0
  18. package/dist/card-cli.d.ts +43 -0
  19. package/dist/card-cli.js +44 -0
  20. package/dist/card-render.d.ts +43 -0
  21. package/dist/card-render.js +260 -0
  22. package/dist/card-sources.d.ts +68 -0
  23. package/dist/card-sources.js +234 -0
  24. package/dist/chat-actions.d.ts +95 -8
  25. package/dist/chat-actions.js +449 -45
  26. package/dist/chat-approval.d.ts +13 -0
  27. package/dist/chat-approval.js +39 -9
  28. package/dist/chat-card-part.d.ts +11 -0
  29. package/dist/chat-card-part.js +18 -0
  30. package/dist/chat-card.d.ts +46 -0
  31. package/dist/chat-card.js +99 -0
  32. package/dist/chat-channel.d.ts +2 -0
  33. package/dist/chat-channel.js +26 -14
  34. package/dist/chat-controls.d.ts +4 -0
  35. package/dist/chat-controls.js +4 -0
  36. package/dist/chat-decide.d.ts +3 -14
  37. package/dist/chat-decide.js +43 -54
  38. package/dist/chat-delivery.js +13 -4
  39. package/dist/chat-flow.d.ts +0 -27
  40. package/dist/chat-flow.js +31 -2
  41. package/dist/chat-quiet.js +1 -1
  42. package/dist/chat-task-actions.d.ts +3 -2
  43. package/dist/chat-task-actions.js +19 -5
  44. package/dist/chat-terms.d.ts +14 -2
  45. package/dist/chat-terms.js +35 -0
  46. package/dist/check-failure.d.ts +2 -0
  47. package/dist/check-failure.js +34 -2
  48. package/dist/check-repair.d.ts +47 -0
  49. package/dist/check-repair.js +240 -0
  50. package/dist/claim.d.ts +6 -0
  51. package/dist/claim.js +13 -2
  52. package/dist/cli-help.d.ts +1 -1
  53. package/dist/cli-help.js +2 -0
  54. package/dist/cli.js +4 -0
  55. package/dist/coding-provider.d.ts +1 -1
  56. package/dist/coding-provider.js +9 -5
  57. package/dist/coding-workspace.d.ts +1 -1
  58. package/dist/coding-workspace.js +4 -4
  59. package/dist/contracts/chat-actions.d.ts +83 -0
  60. package/dist/contracts/chat-actions.js +18 -0
  61. package/dist/contracts/chat-card.d.ts +91 -0
  62. package/dist/contracts/chat-card.js +54 -0
  63. package/dist/contracts/chat-content.d.ts +191 -0
  64. package/dist/contracts/chat-content.js +4 -0
  65. package/dist/contracts/console-api.d.ts +2 -0
  66. package/dist/contracts/console-api.js +2 -0
  67. package/dist/contracts/flow-propose.d.ts +8 -1
  68. package/dist/contracts/flow-propose.js +3 -1
  69. package/dist/contracts/flow-send.d.ts +126 -0
  70. package/dist/contracts/flow-send.js +3 -0
  71. package/dist/contracts/lead-tools.d.ts +173 -0
  72. package/dist/contracts/lead-tools.js +15 -0
  73. package/dist/contracts/store-json.d.ts +16 -0
  74. package/dist/discord-chat.js +14 -0
  75. package/dist/dispatch.d.ts +1 -1
  76. package/dist/dispatch.js +7 -1
  77. package/dist/dispose.d.ts +19 -0
  78. package/dist/dispose.js +47 -7
  79. package/dist/eval-oracle.js +4 -1
  80. package/dist/evidence-pair.d.ts +53 -0
  81. package/dist/evidence-pair.js +128 -0
  82. package/dist/exhaustion.d.ts +17 -4
  83. package/dist/exhaustion.js +44 -4
  84. package/dist/export.js +1 -1
  85. package/dist/failing-first.d.ts +31 -0
  86. package/dist/failing-first.js +44 -0
  87. package/dist/fallback-schema.d.ts +8 -0
  88. package/dist/fallback-schema.js +40 -0
  89. package/dist/fallback.d.ts +138 -0
  90. package/dist/fallback.js +605 -0
  91. package/dist/findings.d.ts +224 -0
  92. package/dist/findings.js +449 -0
  93. package/dist/fleet-schema.d.ts +17 -0
  94. package/dist/fleet-schema.js +135 -0
  95. package/dist/flow-browser.js +2 -2
  96. package/dist/flow-code.d.ts +2 -2
  97. package/dist/flow-code.js +9 -5
  98. package/dist/flow-result-card.d.ts +22 -0
  99. package/dist/flow-result-card.js +55 -0
  100. package/dist/flow-send.js +12 -9
  101. package/dist/flow-steps.js +1 -1
  102. package/dist/flow-triggers.js +1 -1
  103. package/dist/flow-unattended.d.ts +26 -0
  104. package/dist/flow-unattended.js +12 -3
  105. package/dist/flows-ui.js +10 -2
  106. package/dist/follow-through-brief.d.ts +12 -0
  107. package/dist/follow-through-brief.js +31 -0
  108. package/dist/follow-through-cli.d.ts +23 -0
  109. package/dist/follow-through-cli.js +153 -0
  110. package/dist/git-incident.d.ts +16 -0
  111. package/dist/git-incident.js +69 -6
  112. package/dist/handover-note.d.ts +33 -0
  113. package/dist/handover-note.js +106 -0
  114. package/dist/invoke.d.ts +20 -1
  115. package/dist/invoke.js +108 -44
  116. package/dist/lead-cli.js +26 -24
  117. package/dist/lead-contract.js +1 -0
  118. package/dist/lead-doors.d.ts +4 -0
  119. package/dist/lead-doors.js +2 -2
  120. package/dist/lead-progress.js +2 -0
  121. package/dist/lead-tools.js +111 -32
  122. package/dist/lead.js +1 -1
  123. package/dist/local-owner.js +5 -0
  124. package/dist/modes.d.ts +6 -6
  125. package/dist/modes.js +3 -1
  126. package/dist/needs-you.d.ts +5 -2
  127. package/dist/needs-you.js +14 -2
  128. package/dist/operate-remote-arguments.js +1 -0
  129. package/dist/operate-remote.js +1 -1
  130. package/dist/operate.d.ts +1 -1
  131. package/dist/operate.js +165 -14
  132. package/dist/plan-login.d.ts +14 -0
  133. package/dist/plan-login.js +58 -0
  134. package/dist/planner.d.ts +1 -1
  135. package/dist/planner.js +2 -1
  136. package/dist/project-delete-ui.d.ts +12 -0
  137. package/dist/project-delete-ui.js +18 -1
  138. package/dist/project-delete.js +6 -0
  139. package/dist/project-knowledge.d.ts +6 -6
  140. package/dist/project-tools.d.ts +4 -4
  141. package/dist/project-tools.js +23 -7
  142. package/dist/protected-storage-look.d.ts +1 -0
  143. package/dist/protected-storage-look.js +13 -0
  144. package/dist/protected-storage.d.ts +70 -39
  145. package/dist/protected-storage.js +196 -147
  146. package/dist/provider-capture.d.ts +34 -0
  147. package/dist/provider-capture.js +53 -0
  148. package/dist/release-carry.js +10 -1
  149. package/dist/release.js +9 -1
  150. package/dist/scope.d.ts +22 -3
  151. package/dist/scope.js +71 -20
  152. package/dist/scout.d.ts +1 -1
  153. package/dist/scout.js +2 -1
  154. package/dist/serve.js +2 -0
  155. package/dist/server/chat.js +2 -2
  156. package/dist/server/console.css +50 -0
  157. package/dist/server/render-chat.js +14 -1
  158. package/dist/server/render-follow-through.d.ts +11 -0
  159. package/dist/server/render-follow-through.js +68 -0
  160. package/dist/server/render-results.js +1 -1
  161. package/dist/server/render-tasks.d.ts +3 -0
  162. package/dist/server/render-tasks.js +24 -5
  163. package/dist/server/route-table.js +3 -0
  164. package/dist/server/settings.js +37 -12
  165. package/dist/server/task-views.js +5 -0
  166. package/dist/server/tasks.js +76 -0
  167. package/dist/settings-groups-ui.d.ts +1 -0
  168. package/dist/settings-groups-ui.js +3 -1
  169. package/dist/settings-kit.js +3 -2
  170. package/dist/settings-proofs.js +3 -1
  171. package/dist/setup-guide.d.ts +2 -2
  172. package/dist/slack-chat.js +14 -1
  173. package/dist/steer-mailbox.d.ts +8 -1
  174. package/dist/steer-mailbox.js +164 -6
  175. package/dist/store.d.ts +11 -4
  176. package/dist/store.js +93 -30
  177. package/dist/surface.js +24 -4
  178. package/dist/task-control.d.ts +2 -1
  179. package/dist/task-status.js +5 -0
  180. package/dist/teardown-git.d.ts +2 -0
  181. package/dist/teardown-git.js +75 -19
  182. package/dist/telegram-lead.js +23 -6
  183. package/dist/telegram.js +12 -1
  184. package/dist/toolroll-update.js +2 -0
  185. package/dist/work-index.js +22 -7
  186. package/dist/work-summary.d.ts +1 -1
  187. package/dist/work-summary.js +5 -2
  188. package/dist/workspace-ui.d.ts +3 -1
  189. package/dist/workspace-ui.js +10 -1
  190. package/dist/worktree.js +23 -1
  191. package/package.json +2 -1
@@ -15,14 +15,18 @@
15
15
  * OP_CONFIG_DIR, BITWARDENCLI_APPDATA_DIR, and the profiles Firefox's (and Zen's) profiles.ini places elsewhere.
16
16
  * Storage Toolroll doesn't know about (a browser started with its own --user-data-dir, a portable app) isn't covered.
17
17
  *
18
+ * Each launch's check is its own and finishes before the launch (prepareProtectedStorage): on a Mac it runs in a child
19
+ * process, so a privacy prompt never holds the thread that serves the console, and a check that can't finish refuses
20
+ * the launch in words that say which program macOS may need to allow. No earlier walk stands in for it.
21
+ *
18
22
  * The chat and vault variables (SLACK_*, TELEGRAM_*, DISCORD_*, TEAMS_*, OP_*, BW_*, BITWARDENCLI_*) are kept out of
19
23
  * every launch's environment (`withoutProtectedEnv`, exec.ts).
20
24
  */
21
- import { spawnSync } from "node:child_process";
22
- import { closeSync, existsSync, fstatSync, lstatSync, openSync, constants as fsConstants, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
25
+ import { spawn } from "node:child_process";
26
+ import { closeSync, existsSync, fstatSync, lstatSync, openSync, constants as fsConstants, readSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
23
27
  import { homedir } from "node:os";
24
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
25
- import { configBase, namedFolder } from "./names.js";
28
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
29
+ import { fileURLToPath } from "node:url";
26
30
  const AS = "Library/Application Support";
27
31
  const CONTAINERS = "Library/Containers";
28
32
  const GROUPS = "Library/Group Containers";
@@ -127,28 +131,38 @@ export function protectedStorageNames(home = homedir(), env = process.env, platf
127
131
  add(profile);
128
132
  return [...names].sort();
129
133
  }
130
- /** The absolute `Path=` entries of a profiles.ini in `root`, read only as the plain file it is, bounded. */
134
+ /** The absolute `Path=` entries of a profiles.ini in `root`, read only as the plain file it is, bounded. On a Mac this
135
+ * runs only inside the storage check's child process (prepareProtectedStorage), where an open that waits on macOS
136
+ * holds up nothing else. */
131
137
  function profilesElsewhere(root) {
132
- const file = join(root, "profiles.ini");
138
+ const text = profilesIniHere(join(root, "profiles.ini"));
139
+ return (text ?? "").split(/\r?\n/)
140
+ .map(line => /^\s*Path\s*=\s*(.+?)\s*$/i.exec(line)?.[1] ?? null)
141
+ .filter((path) => path !== null && isAbsolute(path));
142
+ }
143
+ function profilesIniHere(file) {
144
+ try {
145
+ lstatSync(file);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
133
150
  let fd;
134
151
  try {
135
152
  fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
136
153
  }
137
154
  catch {
138
- return [];
155
+ return null;
139
156
  }
140
157
  try {
141
158
  const held = fstatSync(fd);
142
159
  if (!held.isFile())
143
- return [];
160
+ return null;
144
161
  const buffer = Buffer.alloc(Math.min(held.size, 64 * 1024));
145
- const read = readSync(fd, buffer, 0, buffer.length, 0);
146
- return buffer.subarray(0, read).toString("utf8").split(/\r?\n/)
147
- .map(line => /^\s*Path\s*=\s*(.+?)\s*$/i.exec(line)?.[1] ?? null)
148
- .filter((path) => path !== null && isAbsolute(path));
162
+ return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
149
163
  }
150
164
  catch {
151
- return [];
165
+ return null;
152
166
  }
153
167
  finally {
154
168
  closeSync(fd);
@@ -156,26 +170,24 @@ function profilesElsewhere(root) {
156
170
  }
157
171
  const unwalkedSaid = new Set();
158
172
  /**
159
- * Every protected path one launch denies: each root by name and by real path, every link inside a present root that
160
- * leads outward (where it leads, by real path and as spelled), and the overrides. Resolved at each launch, never kept.
161
- * A link macOS makes in a sandboxed app's container to the same place under the home folder (its Desktop, Downloads,
162
- * Fonts…) is the system's own mirror of the person's folders, not the app's data, and isn't followed (systemMirror).
173
+ * The storage check, run right here: every root by name and by real path, every link inside a present root that leads
174
+ * outward (where it leads, by real path and as spelled), and the overrides. `unfinished` names each present root whose
175
+ * walk met an error other than "not there" or "not allowed": its links aren't all known. A link macOS makes in a
176
+ * sandboxed app's container to the same place under the home folder (its Desktop, Downloads, Fonts…) is the system's
177
+ * own mirror of the person's folders, not the app's data, and isn't followed (systemMirror).
163
178
  *
164
- * On a Mac, listing inside another app's container waits for the person to answer macOS's "access data from other apps"
165
- * prompt when the process asking has no answer yet (a plane run by launchd: every launch, and the console with it, hung
166
- * there in release check 3249). So the containers are walked only after a time-limited look in a child process
167
- * (`containers`, containersListable) shows they can be listed without waiting. A look that fails any other way refuses
168
- * the launch (it throws). When macOS is waiting on the person, the containers stay denied by name and real path, and
169
- * the outward links the last complete walk found (kept in the plane's own state folder, linkCacheFile) stay denied too;
170
- * with none kept, only the roots are denied, and that is said once.
179
+ * On a Mac a listing (or an open) inside another app's data can wait until a person answers a privacy prompt, so there
180
+ * this runs only in the check's own child process (prepareProtectedStorage), never on a thread that serves anything.
181
+ * `at` is told each root before its walk starts, so a check that runs out of time can name the app it was waiting on.
171
182
  */
172
- export function protectedStorageDenies(home = homedir(), env = process.env, platform = process.platform, say = line => process.stderr.write(`${line}\n`), containers = roots => containersListable(roots), list = path => readdirSync(path, { withFileTypes: true })) {
183
+ export function protectedStorageHere(home = homedir(), env = process.env, platform = process.platform, say = () => { }, list = path => readdirSync(path, { withFileTypes: true }), at = () => { }) {
173
184
  const base = real(home);
185
+ const names = protectedStorageNames(base, env, platform);
174
186
  const out = new Set();
175
187
  const add = (path) => { if (!tooWide(path, base))
176
188
  out.add(path); };
177
- const present = [];
178
- for (const name of protectedStorageNames(base, env, platform)) {
189
+ const unfinished = [];
190
+ for (const name of names) {
179
191
  add(name);
180
192
  try {
181
193
  lstatSync(name);
@@ -185,36 +197,18 @@ export function protectedStorageDenies(home = homedir(), env = process.env, plat
185
197
  }
186
198
  const root = real(name);
187
199
  add(root);
188
- present.push({ name, root });
189
- }
190
- const held = platform === "darwin" ? present.filter(one => inAppContainer(one.root, base)).map(one => one.root) : [];
191
- // Throws when the look itself failed: the launch is refused, never fenced with less.
192
- const listable = held.length === 0 || containers(held);
193
- const cache = held.length === 0 ? null : linkCacheFile(base, env);
194
- if (!listable) {
195
- const kept = cache === null ? null : readLinkCache(cache, base);
196
- for (const target of kept ?? [])
200
+ at(root);
201
+ const walked = { complete: true };
202
+ for (const target of outwardLinks(name, root, base, say, list, walked))
197
203
  add(target);
198
- if (!unwalkedSaid.has(CONTAINERS_UNLISTED)) {
199
- unwalkedSaid.add(CONTAINERS_UNLISTED);
200
- say(`toolroll: fence: macOS didn't let Toolroll look inside other apps' containers without asking you; they stay denied, ${kept === null ? "but links inside them weren't checked" : "with the links found inside them when they were last checked"}. Allow Toolroll's service to access data from other apps (Privacy & Security) to check them now.`);
201
- }
204
+ if (!walked.complete && !unfinished.includes(root))
205
+ unfinished.push(root);
202
206
  }
203
- const walked = { complete: true }, inside = [];
204
- for (const { name, root } of present) {
205
- const container = held.includes(root);
206
- if (container && !listable)
207
- continue;
208
- for (const target of outwardLinks(name, root, base, say, list, container ? walked : undefined)) {
209
- add(target);
210
- if (container)
211
- inside.push(target);
212
- }
213
- }
214
- // Only a walk of every container that met no error but "not there" or "not allowed" is kept for later.
215
- if (listable && held.length > 0 && walked.complete && cache !== null)
216
- writeLinkCache(cache, base, inside);
217
- return [...out].sort();
207
+ return { names, denies: [...out].sort(), unfinished };
208
+ }
209
+ /** Every protected path, checked right here (protectedStorageHere), for a caller that has no prepared check. */
210
+ export function protectedStorageDenies(home = homedir(), env = process.env, platform = process.platform, say, list) {
211
+ return protectedStorageHere(home, env, platform, say, list).denies;
218
212
  }
219
213
  /** Where the links inside a present root lead, when outside it: a walk that never follows a link, bounded. */
220
214
  function outwardLinks(name, root, home, say, list, walked) {
@@ -289,110 +283,165 @@ function systemMirror(link, root, target, home) {
289
283
  const known = CONTAINER_MIRRORS.has(named) || named === `Library/Application Scripts/${root.split(sep).at(-1)}`;
290
284
  return known && target === join(home, ...under);
291
285
  }
292
- /** The key `unwalkedSaid` keeps for "the containers couldn't be listed" (said once per process). */
293
- const CONTAINERS_UNLISTED = "\0containers";
294
- /** A root directly in the home folder's Library/Containers or Library/Group Containers: another app's container. */
295
- function inAppContainer(root, home) {
296
- return dirname(root) === real(join(home, CONTAINERS)) || dirname(root) === real(join(home, GROUPS));
286
+ /** The answers a walk takes as complete: the folder isn't there, or macOS refused it outright. */
287
+ const ANSWERED = new Set(["ENOENT", "ENOTDIR", "EPERM", "EACCES"]);
288
+ // ---- the check, off the thread ---------------------------------------------------------------------------------
289
+ /** How long the storage check may take before the launch is refused (a slow Mac finishes well inside it). */
290
+ export const STORAGE_LOOK_MS = 10_000;
291
+ /** At most this much of the check's answer is read. */
292
+ const LOOK_OUTPUT_MAX = 8 * 1024 * 1024;
293
+ /** The plane's own variables the check reads (protectedStorageNames), handed to its child as they are here. */
294
+ const STORAGE_ENV = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "OP_CONFIG_DIR", "BITWARDENCLI_APPDATA_DIR"];
295
+ /** Why a launch didn't start: the storage check couldn't finish. Its words survive every catch (storageRefusalOf). */
296
+ export class ProtectedStorageRefusal extends Error {
297
+ apps;
298
+ why;
299
+ code = "protected-storage";
300
+ constructor(apps, why, message) {
301
+ super(message);
302
+ this.apps = apps;
303
+ this.why = why;
304
+ this.name = "ProtectedStorageRefusal";
305
+ }
297
306
  }
298
- /** How long the look at other apps' containers may take before Toolroll takes it that macOS is waiting on the person. */
299
- export const CONTAINER_LOOK_MS = 3_000;
300
- /** How long a "can't list them" answer stands before the next launch looks again (the person may have allowed it since). */
301
- const CONTAINER_RELOOK_MS = 10 * 60_000;
302
- /** Each set of container roots' last answer: listable, or not and when that was seen. */
303
- const containerLooks = new Map();
304
- /** The look itself, in a child process: the same bounded walk `outwardLinks` makes, never following a link. */
305
- const CONTAINER_LOOK = `const fs=require("fs"),path=require("path");const [depth,limit,...roots]=process.argv.slice(1);
306
- for(const root of roots){let seen=0;const queue=[{p:root,d:0}];while(queue.length>0){const {p,d}=queue.shift();let entries;
307
- try{entries=fs.readdirSync(p,{withFileTypes:true});}catch{continue;}for(const e of entries){if(++seen>Number(limit)){queue.length=0;break;}
308
- if(!e.isSymbolicLink()&&e.isDirectory()&&d+1<Number(depth))queue.push({p:path.join(p,e.name),d:d+1});}}}`;
309
- /**
310
- * Whether this process can list inside these app containers without macOS stopping to ask the person: the walk run
311
- * first in a child process that is killed at CONTAINER_LOOK_MS. Finishing in time (listed, or refused outright) means
312
- * the walk can run here too; only running into that limit means macOS is waiting on the person (only the child waited).
313
- * A look that fails any other way is tried once more, then throws: the launch is refused rather than fenced with less.
314
- * A yes is kept (macOS keeps the person's answer); a no is looked at again after CONTAINER_RELOOK_MS.
315
- */
316
- export function containersListable(roots, look = lookInContainers, now = Date.now) {
317
- const key = [...roots].sort().join("\0");
318
- const last = containerLooks.get(key);
319
- if (last !== undefined && (last.listable || now() - last.at < CONTAINER_RELOOK_MS))
320
- return last.listable;
321
- let answer = look(roots);
322
- if (answer === "failed")
323
- answer = look(roots);
324
- if (answer === "failed")
325
- throw new Error("Toolroll couldn't look inside other apps' containers to fence them (the look failed twice), so nothing was launched.");
326
- const listable = answer === "listable";
327
- containerLooks.set(key, { listable, at: now() });
328
- return listable;
307
+ /** The storage refusal inside a caught error, when it is one. */
308
+ export function storageRefusalOf(error) {
309
+ return error instanceof ProtectedStorageRefusal ? error : null;
329
310
  }
330
- /** The child-process look (containersListable): node (or Electron as node) walks the roots, killed at its limit. */
331
- function lookInContainers(roots) {
332
- const result = spawnSync(process.execPath, ["-e", CONTAINER_LOOK, String(PROTECTED_WALK.depth), String(PROTECTED_WALK.entries), ...roots], { stdio: "ignore", timeout: CONTAINER_LOOK_MS, killSignal: "SIGKILL", env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } });
333
- return lookOutcome(result);
311
+ /** The apps behind these roots, by name, for the words a person reads. */
312
+ function appsOf(roots, home) {
313
+ const names = roots.map(root => PROTECTED_STORAGE.find(row => [...row.darwin, ...row.linux].some(one => real(join(home, one)) === root || join(home, one) === root))?.app ?? basename(root));
314
+ return [...new Set(names)];
334
315
  }
335
- /** A look's outcome from its child's result: only its own limit (ETIMEDOUT) is macOS waiting; any other error, signal
336
- * or exit code is a failed look. */
337
- export function lookOutcome(result) {
338
- if (result.error?.code === "ETIMEDOUT")
339
- return "waiting";
340
- return result.error === undefined && result.signal === null && result.status === 0 ? "listable" : "failed";
316
+ /** Which program macOS must allow: the one this plane runs as. A launchd service is its program itself; a plane started
317
+ * from a terminal asks as that terminal app. */
318
+ export function storageAccessHolder(env = process.env, execPath = process.execPath, versions = process.versions) {
319
+ if (versions["electron"] !== undefined)
320
+ return "the Toolroll app";
321
+ const service = env["XPC_SERVICE_NAME"];
322
+ if (service !== undefined && service !== "" && service !== "0")
323
+ return `the program that keeps Toolroll running (${execPath})`;
324
+ const terminal = env["TERM_PROGRAM"];
325
+ if (terminal !== undefined && terminal !== "")
326
+ return `the terminal app Toolroll was started from (${terminal.replace(/\.app$/, "")})`;
327
+ return `the program Toolroll runs as (${execPath})`;
341
328
  }
342
- /** The answers a container walk takes as complete: the folder isn't there, or macOS refused it outright. */
343
- const ANSWERED = new Set(["ENOENT", "ENOTDIR", "EPERM", "EACCES"]);
344
- /** At most this much is ever read back from the link cache. */
345
- const LINK_CACHE_MAX = 256 * 1024;
346
- /** Where the outward links the last complete container walk found are kept: the plane's own state folder (fenced from
347
- * every build), one file per user. */
348
- export function linkCacheFile(home, env = process.env) {
349
- return join(namedFolder(configBase({ ...env }, home)), "protected-links.json");
329
+ /** Why nothing was launched, and what the person does about it. Never says macOS is asking: that isn't known. */
330
+ export function storageRefusalWords(apps, why, holder) {
331
+ const named = apps.length === 0 ? "other apps" : apps.length === 1 ? apps[0] : `${apps.slice(0, -1).join(", ")} and ${apps.at(-1)}`;
332
+ const reason = why === "timed-out" ? ` (the check didn't finish within ${STORAGE_LOOK_MS / 1000} seconds)` : why === "unfinished" ? " (some of its folders couldn't be read)" : " (the check stopped with an error)";
333
+ return `Nothing was started: Toolroll couldn't finish checking ${apps.length === 0 ? "other apps'" : `${named}'s`} data for links to keep out of the build${reason}. `
334
+ + `macOS may need to let ${holder} read it: in System Settings → Privacy & Security → Full Disk Access, turn it on for ${holder}, then try again.`;
350
335
  }
351
- /** The kept links for this home: only a plain file of this user's (no link), at most LINK_CACHE_MAX, of exactly the
352
- * kept shape; anything else is no cache at all. */
353
- export function readLinkCache(file, home) {
354
- let fd;
355
- try {
356
- const held = lstatSync(file);
357
- if (!held.isFile() || held.size > LINK_CACHE_MAX || (process.getuid !== undefined && held.uid !== process.getuid()))
358
- return null;
359
- fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
360
- }
361
- catch {
362
- return null;
336
+ const inFlight = new Map();
337
+ /**
338
+ * The storage check for one launch, awaited: what it denies, from a walk that finished just now. On a Mac the whole check
339
+ * (finding the roots, reading profiles.ini, every walk) runs in a child process, so a listing that waits on a privacy
340
+ * prompt holds up only that child; the thread that asked keeps serving. A check that doesn't finish within
341
+ * STORAGE_LOOK_MS, fails, or leaves a present root's walk unfinished refuses the launch (ProtectedStorageRefusal): no
342
+ * earlier walk stands in for it, since a link can move inside a root at any time. Checks asked for at once share one child.
343
+ */
344
+ export async function prepareProtectedStorage(home = homedir(), env = process.env, platform = process.platform, look = lookInChild, holder = storageAccessHolder(env)) {
345
+ const base = real(home);
346
+ const request = { home: base, env: Object.fromEntries(STORAGE_ENV.flatMap(name => env[name] === undefined ? [] : [[name, env[name]]])), platform };
347
+ let answer;
348
+ if (platform !== "darwin") {
349
+ // No privacy prompt can hold a listing here: the check runs as it is.
350
+ const here = protectedStorageHere(base, env, platform, line => process.stderr.write(`${line}\n`));
351
+ answer = { kind: "done", storage: { names: here.names, denies: here.denies }, unfinished: here.unfinished };
363
352
  }
364
- try {
365
- const opened = fstatSync(fd);
366
- if (!opened.isFile() || opened.size > LINK_CACHE_MAX || (process.getuid !== undefined && opened.uid !== process.getuid()))
367
- return null;
368
- const buffer = Buffer.alloc(opened.size);
369
- const read = readSync(fd, buffer, 0, buffer.length, 0);
370
- const value = JSON.parse(buffer.subarray(0, read).toString("utf8"));
371
- if (value === null || typeof value !== "object" || Array.isArray(value))
372
- return null;
373
- const { version, home: kept, targets } = value;
374
- if (version !== 1 || kept !== home || !Array.isArray(targets) || targets.some(one => typeof one !== "string" || !isAbsolute(one)))
375
- return null;
376
- return targets;
353
+ else {
354
+ const key = JSON.stringify(request);
355
+ let pending = inFlight.get(key);
356
+ if (pending === undefined) {
357
+ pending = look(request).finally(() => inFlight.delete(key));
358
+ inFlight.set(key, pending);
359
+ }
360
+ answer = await pending;
377
361
  }
378
- catch {
379
- return null;
362
+ if (answer.kind === "timed-out") {
363
+ const apps = answer.at === null ? [] : appsOf([answer.at], base);
364
+ throw new ProtectedStorageRefusal(apps, "timed-out", storageRefusalWords(apps, "timed-out", holder));
380
365
  }
381
- finally {
382
- closeSync(fd);
366
+ if (answer.kind === "failed")
367
+ throw new ProtectedStorageRefusal([], "failed", storageRefusalWords([], "failed", holder));
368
+ if (answer.unfinished.length > 0) {
369
+ const apps = appsOf(answer.unfinished, base);
370
+ throw new ProtectedStorageRefusal(apps, "unfinished", storageRefusalWords(apps, "unfinished", holder));
383
371
  }
372
+ return answer.storage;
373
+ }
374
+ /** Where the check's child script is: beside this module once built, else the built copy (a run from source). */
375
+ export function storageLookScript() {
376
+ const beside = fileURLToPath(new URL("./protected-storage-look.js", import.meta.url));
377
+ return existsSync(beside) ? beside : fileURLToPath(new URL("../dist/protected-storage-look.js", import.meta.url));
378
+ }
379
+ /** The default check: node (or Electron as node) runs protected-storage-look.js, killed at STORAGE_LOOK_MS. */
380
+ async function lookInChild(request) {
381
+ return readLook(await spawnLook(process.execPath, [storageLookScript(), JSON.stringify(request)], STORAGE_LOOK_MS));
384
382
  }
385
- /** Keep a complete walk's links: written aside (owner-only) and renamed into place, only into a state folder that is
386
- * already there. A failed write keeps whatever was kept before. */
387
- export function writeLinkCache(file, home, targets) {
388
- if (!existsSync(dirname(file)))
389
- return;
390
- const aside = `${file}.${process.pid}.part`;
383
+ /** A finished child's lines as the check's answer: `at\t<root>` before each walk, `done\t<answer>` once at the end. */
384
+ export function readLook(ran) {
385
+ const lines = ran.stdout.split("\n");
386
+ const last = [...lines].reverse().find(line => line.startsWith("at\t"));
387
+ const at = last === undefined ? null : (() => { try {
388
+ const root = JSON.parse(last.slice(3));
389
+ return typeof root === "string" && isAbsolute(root) ? root : null;
390
+ }
391
+ catch {
392
+ return null;
393
+ } })();
394
+ if (ran.outcome === "timed-out")
395
+ return { kind: "timed-out", at };
396
+ if (ran.outcome === "failed")
397
+ return { kind: "failed", detail: ran.detail };
398
+ const done = lines.find(line => line.startsWith("done\t"));
399
+ if (done === undefined)
400
+ return { kind: "failed", detail: "the check gave no answer" };
391
401
  try {
392
- writeFileSync(aside, JSON.stringify({ version: 1, home, targets: [...new Set(targets)].sort() }), { mode: 0o600, flag: "wx" });
393
- renameSync(aside, file);
402
+ const value = JSON.parse(done.slice(5));
403
+ const paths = (one) => Array.isArray(one) && one.every(path => typeof path === "string" && isAbsolute(path)) ? one : null;
404
+ const names = paths(value["names"]), denies = paths(value["denies"]), unfinished = paths(value["unfinished"]);
405
+ if (names === null || denies === null || unfinished === null)
406
+ return { kind: "failed", detail: "the check's answer can't be read" };
407
+ return { kind: "done", storage: { names, denies }, unfinished };
394
408
  }
395
409
  catch {
396
- rmSync(aside, { force: true });
410
+ return { kind: "failed", detail: "the check's answer can't be read" };
397
411
  }
398
412
  }
413
+ /** Run a child without waiting on this thread: its output, bounded, and how it ended. Killed at `limitMs`. */
414
+ export function spawnLook(command, args, limitMs) {
415
+ return new Promise(settle => {
416
+ let stdout = "", over = false, timedOut = false, settled = false;
417
+ const finish = (outcome, detail) => { if (settled)
418
+ return; settled = true; clearTimeout(timer); settle({ stdout, outcome, detail }); };
419
+ let child;
420
+ try {
421
+ child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } });
422
+ }
423
+ catch (error) {
424
+ settle({ stdout: "", outcome: "failed", detail: error instanceof Error ? error.message : String(error) });
425
+ return;
426
+ }
427
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, limitMs);
428
+ child.stdout?.setEncoding("utf8");
429
+ child.stdout?.on("data", (chunk) => { if (stdout.length + chunk.length > LOOK_OUTPUT_MAX) {
430
+ over = true;
431
+ child.kill("SIGKILL");
432
+ }
433
+ else
434
+ stdout += chunk; });
435
+ child.on("error", error => finish("failed", error.message));
436
+ child.on("close", (code, signal) => {
437
+ if (timedOut)
438
+ finish("timed-out", "");
439
+ else if (over)
440
+ finish("failed", "the check said too much");
441
+ else if (code === 0 && signal === null)
442
+ finish("done", "");
443
+ else
444
+ finish("failed", signal === null ? `the check exited with ${code}` : `the check was stopped (${signal})`);
445
+ });
446
+ });
447
+ }
@@ -0,0 +1,34 @@
1
+ import type { Store } from "./store.js";
2
+ export declare const CAPTURE_TEXT_LIMIT = 4000;
3
+ export type ProviderCapture = {
4
+ id: number;
5
+ run: number;
6
+ repo: string | null;
7
+ provider: string;
8
+ model: string | null;
9
+ authMode: string | null;
10
+ exitCode: number | null;
11
+ code: string | null;
12
+ text: string;
13
+ hint: boolean;
14
+ label: "exhausted" | "not-exhausted" | null;
15
+ labelledBy: string | null;
16
+ at: string;
17
+ };
18
+ /** Save one failed attempt's last words. Bookkeeping: it never costs the run anything. */
19
+ export declare function captureProviderFailure(store: Store, input: {
20
+ run: number;
21
+ provider: string;
22
+ model: string | null;
23
+ authMode: string | null;
24
+ exitCode: number | null;
25
+ code: string | null;
26
+ texts: readonly (string | null | undefined)[];
27
+ }, now: Date): void;
28
+ /** Newest first, the ones mentioning limits at the top. */
29
+ export declare function providerCaptures(store: Store, repos: readonly string[] | null, limit?: number): ProviderCapture[];
30
+ export declare function providerCapture(store: Store, id: number): ProviderCapture | null;
31
+ /** A person's reading of one capture. It marks it for keeping, and nothing routes on it. */
32
+ export declare function labelProviderCapture(store: Store, id: number, label: "exhausted" | "not-exhausted", by: string): boolean;
33
+ /** What goes in a fixture file: the provider's response and which version of what produced it. */
34
+ export declare function captureFixture(capture: ProviderCapture): Record<string, unknown>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a provider said when an attempt failed, saved for a person (docs/plans/fleet-follow-through.md, part 5).
3
+ *
4
+ * Fallback between agents was removed in v115, and exhaustion.ts recognises only a dead sign-in. Telling a used-up
5
+ * subscription from any other failure needs a REAL response, so this saves them as they happen and nothing else. Nothing
6
+ * reads this table to route work: `hint` is a sort key for a person, never a recogniser, and a person's label decides
7
+ * nothing either. The text is the provider's, so it is data, scrubbed of secrets and bounded.
8
+ */
9
+ import { readingFleet } from "./fleet-schema.js";
10
+ import { cleanFindingText } from "./findings.js";
11
+ export const CAPTURE_TEXT_LIMIT = 4_000;
12
+ /** Kept per provider, newest first. A labelled capture is never pruned: it is the evidence a person kept. */
13
+ const CAPTURES_KEPT = 40;
14
+ /** Words worth a person's look. Only ever a sort key. */
15
+ const LIMIT_WORDS = /\b(usage limit|rate limit|quota|credit|billing|exhaust|limit reached|too many requests|429|payment)/i;
16
+ /** Save one failed attempt's last words. Bookkeeping: it never costs the run anything. */
17
+ export function captureProviderFailure(store, input, now) {
18
+ try {
19
+ const text = cleanFindingText(input.texts.filter((one) => typeof one === "string" && one.trim() !== "").join("\n"), CAPTURE_TEXT_LIMIT);
20
+ if (text === "" && input.code === null)
21
+ return;
22
+ const run = store.getRun(input.run);
23
+ const repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
24
+ store.handle.prepare(`INSERT INTO provider_capture (run, repo, provider, model, auth_mode, exit_code, code, text, hint, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (run) DO NOTHING`)
25
+ .run(input.run, repo, input.provider, input.model, input.authMode, input.exitCode, input.code === null ? null : cleanFindingText(input.code, 120), text, LIMIT_WORDS.test(`${input.code ?? ""} ${text}`) ? 1 : 0, now.toISOString());
26
+ store.handle.prepare(`DELETE FROM provider_capture WHERE provider = ? AND label IS NULL AND id NOT IN (SELECT id FROM provider_capture WHERE provider = ? ORDER BY id DESC LIMIT ?)`).run(input.provider, input.provider, CAPTURES_KEPT);
27
+ }
28
+ catch { /* a capture is never worth a run */ }
29
+ }
30
+ const captureOf = (row) => ({
31
+ id: Number(row["id"]), run: Number(row["run"]), repo: row["repo"] === null ? null : String(row["repo"]), provider: String(row["provider"]), model: row["model"] === null ? null : String(row["model"]),
32
+ authMode: row["auth_mode"] === null ? null : String(row["auth_mode"]), exitCode: row["exit_code"] === null ? null : Number(row["exit_code"]), code: row["code"] === null ? null : String(row["code"]),
33
+ text: String(row["text"]), hint: Number(row["hint"]) === 1, label: row["label"] === null ? null : String(row["label"]), labelledBy: row["labelled_by"] === null ? null : String(row["labelled_by"]), at: String(row["at"]),
34
+ });
35
+ /** Newest first, the ones mentioning limits at the top. */
36
+ export function providerCaptures(store, repos, limit = 50) {
37
+ return readingFleet(() => store.handle.prepare("SELECT * FROM provider_capture ORDER BY hint DESC, id DESC LIMIT 500").all().map(captureOf)
38
+ .filter(one => repos === null || (one.repo !== null && repos.includes(one.repo))).slice(0, limit), []);
39
+ }
40
+ export function providerCapture(store, id) {
41
+ return readingFleet(() => {
42
+ const row = store.handle.prepare("SELECT * FROM provider_capture WHERE id = ?").get(id);
43
+ return row === undefined ? null : captureOf(row);
44
+ }, null);
45
+ }
46
+ /** A person's reading of one capture. It marks it for keeping, and nothing routes on it. */
47
+ export function labelProviderCapture(store, id, label, by) {
48
+ return Number(store.handle.prepare("UPDATE provider_capture SET label = ?, labelled_by = ? WHERE id = ?").run(label, by, id).changes) === 1;
49
+ }
50
+ /** What goes in a fixture file: the provider's response and which version of what produced it. */
51
+ export function captureFixture(capture) {
52
+ return { version: 1, provider: capture.provider, model: capture.model, authMode: capture.authMode, exitCode: capture.exitCode, code: capture.code, text: capture.text, label: capture.label, labelledBy: capture.labelledBy, capturedAt: capture.at };
53
+ }
@@ -19,6 +19,7 @@
19
19
  * read only Toolroll's records and verified objects. (Coverage, in release-recheck.ts, trusts test code by Alex's
20
20
  * explicit decision; nothing here does.)
21
21
  */
22
+ import { prepareProtectedStorage, storageRefusalOf } from "./protected-storage.js";
22
23
  import { createHash, randomUUID } from "node:crypto";
23
24
  import { closeSync, constants, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
24
25
  import { homedir } from "node:os";
@@ -304,11 +305,19 @@ export function fencedBuildRunner(store) {
304
305
  if (!macosFenceAvailable() && !linuxFenceAvailable()) {
305
306
  return { code: 1, stdout: "", stderr: "This machine can't confine the build (no macOS sandbox, no working bubblewrap), so it didn't run.\n", timedOut: false, notFound: false };
306
307
  }
308
+ // The storage check, awaited off this thread (protected-storage.ts); its words are the build's when it can't finish.
309
+ let storage;
310
+ try {
311
+ storage = await prepareProtectedStorage();
312
+ }
313
+ catch (error) {
314
+ return { code: 1, stdout: "", stderr: `${storageRefusalOf(error)?.message ?? "The build's fence could not be prepared, so it didn't run."}\n`, timedOut: false, notFound: false };
315
+ }
307
316
  const egress = await startEgressProxy({ hosts: [] });
308
317
  try {
309
318
  const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: "proxy-only", socketRoots: [...options.writable], token: egress.secret };
310
319
  const launch = fenceFor("check", {
311
- databaseFile: store.databaseFile(), worktree: options.cwd, boundary: null, binding: null, runFolders: runFolders(null), poolRoots: [],
320
+ databaseFile: store.databaseFile(), worktree: options.cwd, boundary: null, binding: null, runFolders: runFolders(null), poolRoots: [], storage,
312
321
  writes: { writes: { subpaths: [...options.writable], prefixes: [] }, readOnly: [] }, network,
313
322
  });
314
323
  return await run(file, args, { cwd: options.cwd, env: { ...options.env, ...proxyEnv(egress, "proxy-only") }, timeoutMs: options.timeoutMs, processGroup: true,
package/dist/release.js CHANGED
@@ -93,6 +93,8 @@ export const ADVISORY_PR_CHECKS = {
93
93
  "youiyoux · UI drift": ".github/workflows/youiyoux.yml",
94
94
  "journeys · app (scripted)": ".github/workflows/journeys.yml",
95
95
  "journeys · flows (scripted)": ".github/workflows/journeys.yml",
96
+ "upgrade path · scope": ".github/workflows/upgrade-path.yml",
97
+ "upgrade path": ".github/workflows/upgrade-path.yml",
96
98
  };
97
99
  /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, docs/plans/test-matrix.md):
98
100
  * the release proof (the tagged tree passed every REQUIRED_PR_CHECKS check on its pull request, read from GitHub) and
@@ -249,6 +251,8 @@ export async function runRelease(options, adapters) {
249
251
  let step = "preflight";
250
252
  let freshApproval = false;
251
253
  let supersededTask = null;
254
+ /** The commit whose check stopped the release being set aside. The stop tells a person to approve it again with --new. */
255
+ let stoppedCheck = null;
252
256
  const save = () => { journal.updatedAt = adapters.now().toISOString(); writeJournal(file, journal); };
253
257
  /** Tell the progress record about a step (v146). Never throws and never waits: the release goes on whatever it says. */
254
258
  const mark = (at, state, detail = null) => {
@@ -322,6 +326,8 @@ export async function runRelease(options, adapters) {
322
326
  say(`Kept the earlier release journal at ${aside}.`);
323
327
  freshApproval = journal.needsApproval === true;
324
328
  supersededTask = journal.task.id;
329
+ if (journal.stopped?.reason === "check-failed")
330
+ stoppedCheck = journal.candidate.sha;
325
331
  journal = null;
326
332
  }
327
333
  if (journal?.step === "done") {
@@ -336,8 +342,10 @@ export async function runRelease(options, adapters) {
336
342
  journal = await begin(options, adapters, adapters.now());
337
343
  // The earlier attempt needs a person's fresh approval (a CI failure after its deploy): this one files a task of its
338
344
  // own for the same commit, so the old approval can't carry it.
345
+ // A check that stopped this same commit is the same: its task keeps that failure, so reusing it would only stop again
346
+ // (a passing run on it, run 3333, can't erase it). A new task reruns the check and asks a person, never the plane.
339
347
  // Only an id that has never existed, numbered after the superseded one: never a task whose approval still stands.
340
- if (freshApproval) {
348
+ if (freshApproval || journal.candidate.sha === stoppedCheck) {
341
349
  const base = journal.task.id;
342
350
  const suffix = supersededTask === null ? null : supersededTask === base ? 1 : supersededTask.startsWith(`${base}-`) ? Number(supersededTask.slice(base.length + 1)) : null;
343
351
  let picked = null;