toolroll 0.9.72 → 0.9.73

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 (63) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/agent-fence.d.ts +5 -0
  3. package/dist/agent-fence.js +42 -7
  4. package/dist/browser/workspace.js +28 -28
  5. package/dist/chat-actions.js +13 -8
  6. package/dist/chat-decide.d.ts +47 -1
  7. package/dist/chat-decide.js +170 -4
  8. package/dist/check-report.js +2 -1
  9. package/dist/coding-provider.js +4 -2
  10. package/dist/commit-extract.d.ts +17 -0
  11. package/dist/commit-extract.js +82 -4
  12. package/dist/contracts/console-api.d.ts +3 -3
  13. package/dist/contracts/console-api.js +4 -4
  14. package/dist/contracts/release-terms.d.ts +7 -1
  15. package/dist/contracts/release-terms.js +6 -2
  16. package/dist/exec.d.ts +1 -1
  17. package/dist/exec.js +7 -3
  18. package/dist/flow-gallery-ui.js +4 -2
  19. package/dist/flow-gallery.d.ts +3 -0
  20. package/dist/flow-gallery.js +6 -2
  21. package/dist/flow-triggers.d.ts +8 -0
  22. package/dist/flow-triggers.js +15 -0
  23. package/dist/flows-cli.js +13 -3
  24. package/dist/guides.js +10 -1
  25. package/dist/kits-ui.js +4 -2
  26. package/dist/kits.d.ts +6 -4
  27. package/dist/kits.js +9 -11
  28. package/dist/mcp-connect.d.ts +10 -0
  29. package/dist/mcp-connect.js +2 -0
  30. package/dist/project-tools.js +3 -1
  31. package/dist/protected-storage.d.ts +67 -0
  32. package/dist/protected-storage.js +398 -0
  33. package/dist/release-adapters.d.ts +8 -1
  34. package/dist/release-adapters.js +80 -14
  35. package/dist/release-authority.d.ts +6 -0
  36. package/dist/release-authority.js +9 -0
  37. package/dist/release-card.d.ts +76 -0
  38. package/dist/release-card.js +252 -0
  39. package/dist/release-carry.d.ts +3 -0
  40. package/dist/release-carry.js +23 -4
  41. package/dist/release-cli.js +36 -3
  42. package/dist/release-git.d.ts +43 -0
  43. package/dist/release-git.js +106 -0
  44. package/dist/release.d.ts +8 -2
  45. package/dist/release.js +79 -6
  46. package/dist/scope.d.ts +7 -0
  47. package/dist/scope.js +22 -5
  48. package/dist/server/flows.js +40 -10
  49. package/dist/server/render-settings.js +1 -1
  50. package/dist/server/settings.js +26 -5
  51. package/dist/setup-guide.d.ts +2 -2
  52. package/dist/store.d.ts +3 -0
  53. package/dist/store.js +6 -0
  54. package/dist/subagent-memory.d.ts +1 -0
  55. package/dist/subagent-memory.js +1 -1
  56. package/dist/subagent-tools.d.ts +22 -6
  57. package/dist/subagent-tools.js +44 -8
  58. package/dist/subagent-work.d.ts +2 -0
  59. package/dist/subagent-work.js +5 -2
  60. package/dist/subagents-ui.js +6 -4
  61. package/dist/telegram.d.ts +6 -1
  62. package/dist/telegram.js +58 -6
  63. package/package.json +1 -1
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Protected storage (docs/plans/chat-management.md §1.1): where the person's chat apps, browser profiles and password
3
+ * managers keep their sessions, denied to every launch Toolroll fences, read and write, under both fence policies and
4
+ * on every backend. A linked chat account is not proof of a person: whatever holds the Slack desktop's token, Telegram
5
+ * Desktop's `tdata` or a browser's cookies can press the bot's buttons as them. So no build, check, flow or tool server
6
+ * may read those, whatever its project's settings say.
7
+ *
8
+ * The list is outside HOME_CONFIGURATION (agent-fence.ts), which only version-1 fences read: `agentFence` adds these to
9
+ * every fence it makes, as paths no grant can reopen, and `taskBoundary` names them for both policies.
10
+ *
11
+ * Each root is denied by its name, present or not (a build can't make the folder now and swap in a link later), and by
12
+ * its real path (a root that is itself a link to a relocated folder). Inside each present root a bounded walk that
13
+ * never follows a link finds links leading outward and denies where they lead. The overrides Toolroll can know are read
14
+ * from the plane's own environment at each launch, never from a checkout: XDG_CONFIG_HOME and XDG_DATA_HOME (Linux),
15
+ * OP_CONFIG_DIR, BITWARDENCLI_APPDATA_DIR, and the profiles Firefox's (and Zen's) profiles.ini places elsewhere.
16
+ * Storage Toolroll doesn't know about (a browser started with its own --user-data-dir, a portable app) isn't covered.
17
+ *
18
+ * The chat and vault variables (SLACK_*, TELEGRAM_*, DISCORD_*, TEAMS_*, OP_*, BW_*, BITWARDENCLI_*) are kept out of
19
+ * every launch's environment (`withoutProtectedEnv`, exec.ts).
20
+ */
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";
23
+ import { homedir } from "node:os";
24
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
25
+ import { configBase, namedFolder } from "./names.js";
26
+ const AS = "Library/Application Support";
27
+ const CONTAINERS = "Library/Containers";
28
+ const GROUPS = "Library/Group Containers";
29
+ /**
30
+ * Every listed root. Checked against a real Mac (2026-10-25): existence only, never a file's contents. The 1Password
31
+ * containers and the extra Chrome, Edge and other browsers' folders below were found there and added.
32
+ */
33
+ export const PROTECTED_STORAGE = [
34
+ { app: "Slack", darwin: [`${AS}/Slack`, `${CONTAINERS}/com.tinyspeck.slackmacgap`], linux: [".config/Slack", "snap/slack", ".var/app/com.slack.Slack"] },
35
+ { app: "Telegram", darwin: [`${AS}/Telegram Desktop`, `${CONTAINERS}/org.telegram.desktop`, `${GROUPS}/6N38VWS5BX.ru.keepcoder.Telegram`, `${CONTAINERS}/ru.keepcoder.Telegram`],
36
+ linux: [".local/share/TelegramDesktop", "snap/telegram-desktop", ".var/app/org.telegram.desktop"] },
37
+ { app: "Discord", darwin: [`${AS}/discord`, `${AS}/discordptb`, `${AS}/discordcanary`],
38
+ linux: [".config/discord", ".config/discordptb", ".config/discordcanary", "snap/discord", ".var/app/com.discordapp.Discord"] },
39
+ // New Teams keeps its sign-in in Microsoft's shared sign-in cache (OneAuth) too.
40
+ { app: "Teams", darwin: [`${CONTAINERS}/com.microsoft.teams2`, `${GROUPS}/UBF8T346G9.com.microsoft.teams`, `${AS}/Microsoft/Teams`, `${GROUPS}/UBF8T346G9.com.microsoft.oneauth`],
41
+ linux: [".config/Microsoft/Microsoft Teams", ".config/teams-for-linux"] },
42
+ { app: "WhatsApp", darwin: [`${CONTAINERS}/net.whatsapp.WhatsApp`, `${GROUPS}/group.net.whatsapp.WhatsApp.shared`, `${GROUPS}/group.net.whatsapp.WhatsApp.private`, `${GROUPS}/group.net.whatsapp.family`], linux: [] },
43
+ { app: "1Password",
44
+ darwin: [`${GROUPS}/2BUA8C4S2C.com.1password`, `${GROUPS}/group.2BUA8C4S2C.com.1password`, `${GROUPS}/2BUA8C4S2C.com.agilebits`, `${CONTAINERS}/com.1password.1password`,
45
+ `${CONTAINERS}/com.1password.1password-launcher`, `${CONTAINERS}/com.1password.browser-support`, `${CONTAINERS}/2BUA8C4S2C.com.1password.browser-helper`,
46
+ `${CONTAINERS}/com.1password.1password.autofill-extension`, `${AS}/1Password`, ".config/1Password", ".config/op", ".op"],
47
+ linux: [".config/1Password", "snap/1password", ".var/app/com.onepassword.OnePassword", ".config/op", ".op"] },
48
+ { app: "Bitwarden", darwin: [`${AS}/Bitwarden`, `${CONTAINERS}/com.bitwarden.desktop`, `${GROUPS}/LTZ2PFU5D6.com.bitwarden.desktop`, `${AS}/Bitwarden CLI`],
49
+ linux: [".config/Bitwarden", "snap/bitwarden", ".var/app/com.bitwarden.desktop", ".config/Bitwarden CLI"] },
50
+ // The browser extensions of both password managers live inside the browser profiles below.
51
+ { app: "Chrome", darwin: [`${AS}/Google/Chrome`, `${AS}/Google/Chrome Beta`, `${AS}/Google/Chrome Canary`, `${AS}/Google/Chrome Dev`],
52
+ linux: [".config/google-chrome", ".config/google-chrome-beta", ".config/google-chrome-unstable", ".var/app/com.google.Chrome"] },
53
+ { app: "Chromium", darwin: [`${AS}/Chromium`], linux: [".config/chromium", "snap/chromium", ".var/app/org.chromium.Chromium"] },
54
+ { app: "Brave", darwin: [`${AS}/BraveSoftware`], linux: [".config/BraveSoftware", "snap/brave", ".var/app/com.brave.Browser"] },
55
+ { app: "Edge", darwin: [`${AS}/Microsoft Edge`, `${AS}/Microsoft Edge Beta`, `${AS}/Microsoft Edge Dev`, `${AS}/Microsoft Edge Canary`],
56
+ linux: [".config/microsoft-edge", ".config/microsoft-edge-beta", ".config/microsoft-edge-dev", ".var/app/com.microsoft.Edge"] },
57
+ { app: "Arc", darwin: [`${AS}/Arc`], linux: [] },
58
+ { app: "Firefox", darwin: [`${AS}/Firefox`], linux: [".mozilla/firefox", "snap/firefox", ".var/app/org.mozilla.firefox"] },
59
+ { app: "Zen", darwin: [`${AS}/zen`], linux: [".zen", ".var/app/app.zen_browser.zen"] },
60
+ { app: "Vivaldi", darwin: [`${AS}/Vivaldi`, `${AS}/Vivaldi Snapshot`], linux: [".config/vivaldi", ".config/vivaldi-snapshot"] },
61
+ { app: "Opera", darwin: [`${AS}/com.operasoftware.Opera`], linux: [".config/opera"] },
62
+ { app: "Orion", darwin: [`${AS}/Orion`], linux: [] },
63
+ { app: "Safari", darwin: ["Library/Safari", `${CONTAINERS}/com.apple.Safari`, `${CONTAINERS}/com.apple.Safari.WebApp`, "Library/Cookies", "Library/HTTPStorages", "Library/WebKit"], linux: [] },
64
+ ];
65
+ /** The roots whose profiles.ini may place a profile anywhere (Firefox and its forks). */
66
+ const PROFILE_INDEXES = {
67
+ darwin: [`${AS}/Firefox`, `${AS}/zen`],
68
+ linux: [".mozilla/firefox", "snap/firefox/common/.mozilla/firefox", ".var/app/org.mozilla.firefox/.mozilla/firefox", ".zen"],
69
+ };
70
+ /** The chat and vault variables no launch gets. */
71
+ export const PROTECTED_ENV_PREFIXES = ["SLACK_", "TELEGRAM_", "DISCORD_", "TEAMS_", "OP_", "BW_", "BITWARDENCLI_"];
72
+ export function isProtectedEnvName(name) {
73
+ const upper = name.toUpperCase();
74
+ return PROTECTED_ENV_PREFIXES.some(prefix => upper.startsWith(prefix));
75
+ }
76
+ /** `env` without a chat or vault variable. */
77
+ export function withoutProtectedEnv(env) {
78
+ const out = { ...env };
79
+ for (const name of Object.keys(out))
80
+ if (isProtectedEnvName(name))
81
+ delete out[name];
82
+ return out;
83
+ }
84
+ /** How far the link walk goes in each present root. */
85
+ export const PROTECTED_WALK = { depth: 4, entries: 5_000 };
86
+ const real = (path) => { try {
87
+ return realpathSync(path);
88
+ }
89
+ catch {
90
+ return resolve(path);
91
+ } };
92
+ const within = (child, parent) => { const rel = relative(parent, child); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); };
93
+ const platformOf = (platform) => platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : null;
94
+ /** A path the fence can't deny without denying the machine: `/`, the home folder or a folder above it. */
95
+ function tooWide(path, home) {
96
+ return !isAbsolute(path) || path === dirname(path) || path === home || within(home, path);
97
+ }
98
+ /** The roots by name, under the (real) home folder and the overrides the plane's environment names. */
99
+ export function protectedStorageNames(home = homedir(), env = process.env, platform = process.platform) {
100
+ const os = platformOf(platform);
101
+ if (os === null)
102
+ return [];
103
+ const base = real(home);
104
+ const names = new Set();
105
+ const add = (path) => { if (!tooWide(path, base))
106
+ names.add(path); };
107
+ const relatives = PROTECTED_STORAGE.flatMap(row => row[os]);
108
+ for (const one of relatives)
109
+ add(join(base, one));
110
+ if (os === "linux") {
111
+ const absolute = (name) => { const value = env[name]; return value !== undefined && isAbsolute(value) ? value : null; };
112
+ const config = absolute("XDG_CONFIG_HOME"), data = absolute("XDG_DATA_HOME");
113
+ for (const one of relatives) {
114
+ if (config !== null && one.startsWith(".config/"))
115
+ add(join(config, one.slice(".config/".length)));
116
+ if (data !== null && one.startsWith(".local/share/"))
117
+ add(join(data, one.slice(".local/share/".length)));
118
+ }
119
+ }
120
+ for (const name of ["OP_CONFIG_DIR", "BITWARDENCLI_APPDATA_DIR"]) {
121
+ const value = env[name];
122
+ if (value !== undefined && isAbsolute(value))
123
+ add(resolve(value));
124
+ }
125
+ for (const index of PROFILE_INDEXES[os])
126
+ for (const profile of profilesElsewhere(join(base, index)))
127
+ add(profile);
128
+ return [...names].sort();
129
+ }
130
+ /** The absolute `Path=` entries of a profiles.ini in `root`, read only as the plain file it is, bounded. */
131
+ function profilesElsewhere(root) {
132
+ const file = join(root, "profiles.ini");
133
+ let fd;
134
+ try {
135
+ fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ try {
141
+ const held = fstatSync(fd);
142
+ if (!held.isFile())
143
+ return [];
144
+ 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));
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ finally {
154
+ closeSync(fd);
155
+ }
156
+ }
157
+ const unwalkedSaid = new Set();
158
+ /**
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).
163
+ *
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.
171
+ */
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 })) {
173
+ const base = real(home);
174
+ const out = new Set();
175
+ const add = (path) => { if (!tooWide(path, base))
176
+ out.add(path); };
177
+ const present = [];
178
+ for (const name of protectedStorageNames(base, env, platform)) {
179
+ add(name);
180
+ try {
181
+ lstatSync(name);
182
+ }
183
+ catch {
184
+ continue; /* denied by name only */
185
+ }
186
+ const root = real(name);
187
+ 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 ?? [])
197
+ 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
+ }
202
+ }
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();
218
+ }
219
+ /** Where the links inside a present root lead, when outside it: a walk that never follows a link, bounded. */
220
+ function outwardLinks(name, root, home, say, list, walked) {
221
+ const found = [];
222
+ let seen = 0;
223
+ const queue = [{ path: root, depth: 0 }];
224
+ while (queue.length > 0) {
225
+ const { path, depth } = queue.shift();
226
+ let entries;
227
+ try {
228
+ entries = list(path);
229
+ }
230
+ catch (error) {
231
+ if (walked !== undefined && !ANSWERED.has(error?.code ?? ""))
232
+ walked.complete = false;
233
+ continue;
234
+ }
235
+ for (const entry of entries) {
236
+ if (++seen > PROTECTED_WALK.entries) {
237
+ if (!unwalkedSaid.has(root)) {
238
+ unwalkedSaid.add(root);
239
+ say(`toolroll: fence: ${name} holds more than ${PROTECTED_WALK.entries.toLocaleString("en-US")} entries; links past that weren't checked (not fully walked)`);
240
+ }
241
+ return found;
242
+ }
243
+ const child = join(path, entry.name);
244
+ if (entry.isSymbolicLink()) {
245
+ let spelled;
246
+ try {
247
+ spelled = resolve(dirname(child), readlinkSync(child));
248
+ }
249
+ catch {
250
+ continue;
251
+ }
252
+ if (systemMirror(child, root, spelled, home))
253
+ continue;
254
+ const target = real(spelled);
255
+ if (target === root || within(target, root))
256
+ continue;
257
+ found.push(target);
258
+ if (spelled !== target)
259
+ found.push(spelled);
260
+ }
261
+ else if (entry.isDirectory() && depth + 1 < PROTECTED_WALK.depth)
262
+ queue.push({ path: child, depth: depth + 1 });
263
+ }
264
+ }
265
+ return found;
266
+ }
267
+ /** The links macOS makes in every sandboxed app's container (`Data/X` → `~/X`), as found on a real Mac (2026-10-26). */
268
+ const CONTAINER_MIRRORS = new Set([".CFUserTextEncoding", "Desktop", "Downloads", "Movies", "Music", "Pictures",
269
+ ...["Audio", "Calendars", "ColorPickers", "ColorSync", "Colors", "Components", "Compositions", "Dictionaries", "Favorites", "Filters", "FontCollections", "Fonts",
270
+ "Input Methods", "KeyBindings", "Keyboard Layouts", "Keychains", "Mail", "PDF Services", "QuickLook", "Sounds", "Spelling", "Images/People",
271
+ "Application Support/AddressBook", "Application Support/iCloud", "Application Support/SyncServices",
272
+ "Preferences/com.apple.security.plist", "Preferences/com.apple.security_common.plist"].map(one => `Library/${one}`)]);
273
+ /**
274
+ * The system's own redirect in a sandboxed app's container, and nothing else: the root is an app container (its links
275
+ * under `Data/`) or a group container, directly in the home folder's Library, and the link is one macOS makes there
276
+ * (CONTAINER_MIRRORS, or the app's own Application Scripts folder) leading to the same place under the home folder.
277
+ * Any other outward link — in a browser profile, a password manager's folder, or an unknown name in a container — is
278
+ * where that app's data was moved, and is denied.
279
+ */
280
+ function systemMirror(link, root, target, home) {
281
+ const app = dirname(root) === real(join(home, CONTAINERS)), group = dirname(root) === real(join(home, GROUPS));
282
+ if (!app && !group)
283
+ return false;
284
+ const rel = relative(root, link).split(sep);
285
+ if (app && rel[0] !== "Data")
286
+ return false;
287
+ const under = app ? rel.slice(1) : rel;
288
+ const named = under.join("/");
289
+ const known = CONTAINER_MIRRORS.has(named) || named === `Library/Application Scripts/${root.split(sep).at(-1)}`;
290
+ return known && target === join(home, ...under);
291
+ }
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));
297
+ }
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;
329
+ }
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);
334
+ }
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";
341
+ }
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");
350
+ }
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;
363
+ }
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;
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ finally {
382
+ closeSync(fd);
383
+ }
384
+ }
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`;
391
+ try {
392
+ writeFileSync(aside, JSON.stringify({ version: 1, home, targets: [...new Set(targets)].sort() }), { mode: 0o600, flag: "wx" });
393
+ renameSync(aside, file);
394
+ }
395
+ catch {
396
+ rmSync(aside, { force: true });
397
+ }
398
+ }
@@ -7,6 +7,8 @@
7
7
  import type { GateState, Installed, ReleaseAdapters } from "./release.js";
8
8
  import { Store } from "./store.js";
9
9
  import { type BuildRunner } from "./release-carry.js";
10
+ import { type ReleaseRepo, type ReleaseTools } from "./release-git.js";
11
+ export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools, type ReleaseRepo, type ReleaseTools } from "./release-git.js";
10
12
  export type Exec = (command: string, args: readonly string[], options?: {
11
13
  cwd?: string;
12
14
  timeoutMs?: number;
@@ -41,8 +43,14 @@ export declare function stageGate(stage: string, owner: () => string | null): {
41
43
  id: string;
42
44
  phase: string;
43
45
  } | null;
46
+ /** The recorded repository for a gate checkout, from the plane's database; throws in plain words when there is none. */
47
+ export declare function recordedReleaseRepo(database: string, checkout: string): ReleaseRepo;
44
48
  export type AdapterOptions = {
45
49
  checkout: string;
50
+ /** The recorded repository and the programs, when a caller (a test) supplies them; otherwise read from the plane's
51
+ * records and found on this machine when the first Git command runs. */
52
+ releaseRepo?: ReleaseRepo;
53
+ tools?: ReleaseTools;
46
54
  /** The live database (default: this installation's). */
47
55
  database?: string;
48
56
  processes?: typeof processes;
@@ -74,4 +82,3 @@ export declare function bumpFormula(text: string, url: string, sha256: string):
74
82
  * lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
75
83
  */
76
84
  export declare function storeReleaseAdapter(open: () => Store, done: (store: Store) => void, now: () => Date, runner?: BuildRunner): NonNullable<ReleaseAdapters["release"]>;
77
- export {};
@@ -24,6 +24,9 @@ import { proveCarry } from "./release-carry.js";
24
24
  import { randomBytes, randomUUID } from "node:crypto";
25
25
  import { deploymentGate } from "./deploy-gate.js";
26
26
  import { extractCommit } from "./commit-extract.js";
27
+ import { checkoutIncludes } from "./teardown-git.js";
28
+ import { RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
29
+ export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
27
30
  /** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
28
31
  export const exec = (command, args, options = {}) => new Promise(done => {
29
32
  const child = execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
@@ -162,6 +165,48 @@ export function stageGate(stage, owner) {
162
165
  const id = textOr(journal["id"]), phase = textOr(journal["phase"]);
163
166
  return id !== null && phase !== null && BEFORE_SWAP.includes(phase) && owner() === id ? { id, phase } : null;
164
167
  }
168
+ /** The recorded repository for a gate checkout, from the plane's database; throws in plain words when there is none. */
169
+ export function recordedReleaseRepo(database, checkout) {
170
+ const real = (path) => { try {
171
+ return realpathSync(path);
172
+ }
173
+ catch {
174
+ return null;
175
+ } };
176
+ const at = real(checkout);
177
+ if (at === null)
178
+ throw new Error(`${checkout} isn't there.`);
179
+ if (!existsSync(database))
180
+ throw new Error("The plane's database isn't here, so the release's repository can't be read from its records.");
181
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
182
+ let project = null, remote = null;
183
+ try {
184
+ const store = new Store(db);
185
+ project = store.listProjects().map(one => one.path).find(path => real(path) === at) ?? null;
186
+ remote = project === null ? null : store.projectRemote(project);
187
+ }
188
+ finally {
189
+ db.close();
190
+ }
191
+ if (project === null)
192
+ throw new Error(`${checkout} isn't one of the projects Toolroll records, so it can't be released from here.`);
193
+ if (remote === null)
194
+ throw new Error(`${project} has no recorded remote. Record it with \`toolroll repos add ${project} --remote <url>\` first.`);
195
+ const parsed = releaseRemote(remote);
196
+ if (parsed === null)
197
+ throw new Error(`${project}'s recorded remote (${remote}) isn't a GitHub repository over https or ssh.`);
198
+ const named = join(at, ".git");
199
+ let gitDir;
200
+ try {
201
+ if (!lstatSync(named).isDirectory())
202
+ throw new Error("not a folder");
203
+ gitDir = realpathSync(named);
204
+ }
205
+ catch {
206
+ throw new Error(`${project}'s Git directory isn't a plain folder, so the release won't read it.`);
207
+ }
208
+ return { checkout: at, gitDir, remote, ...parsed };
209
+ }
165
210
  /** How many times, and how far apart, a merged branch's deletion is looked for after the push lost a race. */
166
211
  export const BRANCH_GONE_LOOKS = 5;
167
212
  export const BRANCH_GONE_PAUSE_MS = 2_000;
@@ -171,9 +216,11 @@ export function releaseAdapters(options) {
171
216
  const { checkout } = options;
172
217
  const tap = options.tap ?? "ap9000/homebrew-toolroll";
173
218
  const formula = options.formula ?? "Formula/toolroll.rb";
174
- const git = (...args) => run("git", ["-C", checkout, ...args]);
175
219
  const pause = options.sleep ?? ((ms) => new Promise(done => setTimeout(done, ms)));
176
- const gh = (...args) => run("gh", args);
220
+ const tools = options.tools ?? releaseTools();
221
+ const gitEnv = releaseGitEnv();
222
+ // Never a PATH lookup or the checkout's Git config: gh by its absolute path, from a neutral folder, naming the repo.
223
+ const gh = (...args) => run(tools.gh, args, { cwd: "/", env: gitEnv });
177
224
  const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
178
225
  const toolroll = async (...args) => {
179
226
  const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
@@ -186,6 +233,29 @@ export function releaseAdapters(options) {
186
233
  };
187
234
  const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
188
235
  const database = options.database ?? databasePath(process.env, homedir());
236
+ let recorded = options.releaseRepo ?? null;
237
+ /** The recorded repository, read once (chat-management.md §0a.2). */
238
+ const releaseRepo = () => recorded ??= recordedReleaseRepo(database, checkout);
239
+ /**
240
+ * The coordinator's one way to run Git (§0a.2): on the recorded Git directory, hardened, with an allowlisted
241
+ * environment and the absolute Git. Before every command the checkout's own config is read with includes off and
242
+ * as plain files; one that includes a file, names a command or redirects a remote stops the release, naming it.
243
+ */
244
+ const git = async (...args) => {
245
+ const repo = releaseRepo();
246
+ const stop = (why) => new Error(`Toolroll won't run Git in ${repo.checkout} for a release: ${why}. Remove it from the project's Git config, then rerun.`);
247
+ if (options.releaseRepo === undefined && checkoutIncludes(repo.checkout))
248
+ throw stop("its Git config includes another file, or couldn't be read as a plain file");
249
+ const listing = await run(tools.git, [`--git-dir=${repo.gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env: gitEnv });
250
+ if (listing.code !== 0)
251
+ throw stop("its Git config couldn't be read");
252
+ const setting = releaseConfigProblem(listing.stdout);
253
+ if (setting !== null)
254
+ throw stop(`its Git config sets ${setting}, which a release doesn't run with (it could run a command, or change where Git connects or what it sends)`);
255
+ return run(tools.git, [...releaseGitArgs(repo, tools), ...args], { cwd: repo.checkout, env: gitEnv });
256
+ };
257
+ /** The remote every fetch, look and push names: the recorded URL, defined only on the command line. */
258
+ const remoteUrl = () => RELEASE_REMOTE;
189
259
  const liveOwner = () => {
190
260
  if (!existsSync(database))
191
261
  return null;
@@ -293,16 +363,12 @@ export function releaseAdapters(options) {
293
363
  sleep: options.sleep ?? sleep,
294
364
  say: options.say ?? (line => process.stdout.write(`${line}\n`)),
295
365
  repo: {
296
- async github() {
297
- const slug = githubSlug(await must(git("remote", "get-url", "origin"), "git remote get-url origin"));
298
- if (slug === null)
299
- throw new Error(`${checkout}'s origin is not a GitHub repository`);
300
- return slug;
301
- },
366
+ // The recorded owner/name, never the checkout's origin.
367
+ async github() { return releaseRepo().slug; },
302
368
  async sync(branch) {
303
- await must(git("fetch", "--quiet", "origin", "+refs/heads/main:refs/remotes/origin/main"), "git fetch origin main");
369
+ await must(git("fetch", "--quiet", "--no-tags", remoteUrl(), "+refs/heads/main:refs/remotes/origin/main"), "git fetch main");
304
370
  // A merged branch may be gone from GitHub; its commit is already here.
305
- await git("fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
371
+ await git("fetch", "--quiet", "--no-tags", remoteUrl(), `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
306
372
  const current = (await git("symbolic-ref", "--quiet", "--short", "HEAD")).stdout.trim();
307
373
  if (current === "main") {
308
374
  await must(git("merge", "--ff-only", "--quiet", "origin/main"), "git merge --ff-only origin/main");
@@ -316,17 +382,17 @@ export function releaseAdapters(options) {
316
382
  throw new Error(`${checkout}'s local main has commits origin/main doesn't; the gate diffs from it, so fix it first`);
317
383
  },
318
384
  async remoteHead(branch) {
319
- const out = await must(git("ls-remote", "origin", `refs/heads/${branch}`), "git ls-remote");
385
+ const out = await must(git("ls-remote", remoteUrl(), `refs/heads/${branch}`), "git ls-remote");
320
386
  return out.trim().split(/\s+/)[0] || null;
321
387
  },
322
388
  async deleteBranch(branch, head) {
323
389
  const ref = `refs/heads/${branch}`;
324
- const current = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
390
+ const current = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
325
391
  if (current === null)
326
392
  return;
327
393
  if (current !== head)
328
394
  throw new Error(`${branch} moved after merging; its new head was not deleted.`);
329
- const pushed = await git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`);
395
+ const pushed = await git("push", "--no-verify", `--force-with-lease=${ref}:${head}`, remoteUrl(), `:${ref}`);
330
396
  if (pushed.code === 0)
331
397
  return;
332
398
  // GitHub may delete a merged branch itself between the look and the push; gone is what was wanted. Its refs can
@@ -334,7 +400,7 @@ export function releaseAdapters(options) {
334
400
  for (let look = 0; look < BRANCH_GONE_LOOKS; look++) {
335
401
  if (look > 0)
336
402
  await pause(BRANCH_GONE_PAUSE_MS);
337
- const after = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
403
+ const after = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
338
404
  if (after === null)
339
405
  return;
340
406
  }
@@ -98,6 +98,12 @@ export declare function releaseAuthorityOfTask(store: Store, taskId: string): {
98
98
  carry: ReleaseCarry | null;
99
99
  } | null;
100
100
  /** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
101
+ /** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
102
+ * off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
103
+ export declare const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
104
+ export declare function carriesUnsandboxed(store: Store, repo: string): boolean;
105
+ /** How far a check reaches, narrowest first. */
106
+ export declare const CHECK_ACCESS_WIDTH: Readonly<Record<string, number>>;
101
107
  export declare function belongsToRelease(store: Store, taskId: string): boolean;
102
108
  export declare function releaseCarries(store: Store, authority: number): ReleaseCarry[];
103
109
  export declare function releaseCarryTo(store: Store, authority: number, toSha: string): ReleaseCarry | null;
@@ -86,6 +86,15 @@ export function releaseAuthorityOfTask(store, taskId) {
86
86
  return row === undefined ? null : { authority: readReleaseAuthority(row), carry: null };
87
87
  }
88
88
  /** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
89
+ /** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
90
+ * off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
91
+ export const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
92
+ export function carriesUnsandboxed(store, repo) {
93
+ const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, UNSANDBOXED_CARRY_ACTION);
94
+ return row !== undefined && row["outcome"] === "on";
95
+ }
96
+ /** How far a check reaches, narrowest first. */
97
+ export const CHECK_ACCESS_WIDTH = Object.freeze({ sandboxed: 0, "sign-ins": 1, unsandboxed: 2 });
89
98
  export function belongsToRelease(store, taskId) {
90
99
  return store.handle.prepare("SELECT 1 AS hit FROM release_authority WHERE anchor_task = ? UNION ALL SELECT 1 FROM release_carry WHERE task_id = ? LIMIT 1").get(taskId, taskId) !== undefined;
91
100
  }