toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +117 -2
  16. package/dist/agent-fence.js +439 -12
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +39 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
@@ -40,13 +40,18 @@
40
40
  * rename or remove a folder of the user's above a fenced path or the temp
41
41
  * folder, which would reach the fenced path under a new name.
42
42
  */
43
+ import { INSTANCE_DIR } from "./console-instance.js";
44
+ import { ACP_AGENTS, ACP_PROVIDER_IDS } from "./acp-agents.js";
43
45
  import { spawnSync } from "node:child_process";
44
- import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
46
+ import { createHash } from "node:crypto";
47
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
45
48
  import { homedir, tmpdir } from "node:os";
46
49
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
47
50
  import { readMonitoring } from "./monitoring-settings.js";
48
51
  import { existingFolders } from "./names.js";
49
52
  import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
53
+ /** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
54
+ export const BRIDGE_DIR = "bridge-secrets";
50
55
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
51
56
  const SENSITIVE_NAME = /(^up-login\.txt$|token|secret|login|password|credential|vapid|keys?\b|\.pem$|\.key$|^backups$|^evidence$|^remote$|^sign-in\.json$|^monitoring\.json$)/i;
52
57
  function real(path) {
@@ -127,6 +132,10 @@ export function trustedWorktreeBinding(repository, worktree, poolRoots = []) {
127
132
  */
128
133
  export function agentFence(options) {
129
134
  const fence = new Set();
135
+ /** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
136
+ let runFolder = null;
137
+ /** The bridges' folder, likewise. */
138
+ let bridgeFolder = null;
130
139
  const home = real(options.home ?? homedir());
131
140
  const worktree = options.worktree === null ? null : real(options.worktree);
132
141
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
@@ -135,6 +144,45 @@ export function agentFence(options) {
135
144
  for (const suffix of ["", "-wal", "-shm", "-journal"])
136
145
  if (existsSync(database + suffix))
137
146
  fence.add(database + suffix);
147
+ // The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
148
+ // build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
149
+ // build starts if it isn't there yet, so a build can never be the one to create it — and the fence always has a
150
+ // real folder to cover (a Linux bind needs one).
151
+ // Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
152
+ // points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
153
+ // If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
154
+ // rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
155
+ // The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
156
+ // from every build, so no build — however long it runs — can reach a bridge made for a later one.
157
+ const bridges = join(state, BRIDGE_DIR);
158
+ try {
159
+ lstatSync(bridges);
160
+ }
161
+ catch {
162
+ try {
163
+ mkdirSync(bridges, { mode: 0o700 });
164
+ }
165
+ catch { /* fenced below either way */ }
166
+ }
167
+ // Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
168
+ // refuses a root that isn't a private folder: egress-bridge.ts).
169
+ bridgeFolder = bridges;
170
+ fence.add(bridges);
171
+ runFolder = join(state, INSTANCE_DIR);
172
+ const present = () => { try {
173
+ lstatSync(runFolder);
174
+ return true;
175
+ }
176
+ catch {
177
+ return false;
178
+ } };
179
+ if (!present()) {
180
+ try {
181
+ mkdirSync(runFolder, { mode: 0o700 });
182
+ }
183
+ catch { /* fenced below either way */ }
184
+ }
185
+ fence.add(runFolder);
138
186
  // Only a folder that is Toolroll's own (~/.config/toolroll or an older name,
139
187
  // the desktop app's "Toolroll") is fenced whole: a database kept
140
188
  // in a shared folder (a projects folder, a test's temp folder) must not
@@ -179,12 +227,12 @@ export function agentFence(options) {
179
227
  fence.add(path);
180
228
  for (const path of options.boundary?.protectedShell ?? [])
181
229
  fence.add(real(path));
182
- const protectedPaths = new Set([...fence].map(real));
230
+ const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
183
231
  for (const path of options.boundary?.shell ?? [])
184
232
  fence.add(path);
185
233
  const binding = options.binding;
186
234
  const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
187
- return [...new Set([...fence].map(real))].filter(path => {
235
+ return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
188
236
  if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
189
237
  return true;
190
238
  console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
@@ -197,10 +245,18 @@ export function agentFence(options) {
197
245
  * cloud logins or another agent's sign-in, or rewrite their own Claude or
198
246
  * Codex setup for the next interactive session.
199
247
  */
200
- const HOME_CONFIGURATION = [".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json")];
248
+ const HOME_CONFIGURATION = [
249
+ ".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json"),
250
+ // Every ACP agent's own configuration and sign-in (acp-agents.ts): fenced from every build but that agent's own.
251
+ ...ACP_PROVIDER_IDS.flatMap(id => ACP_AGENTS[id].home),
252
+ ];
201
253
  /** What a provider's own CLI reads to sign in and keep its sessions. The OS fence around Claude and Gemini holds
202
254
  * their whole process, so those stay reachable there; Codex fences only its agent's commands, so it keeps none. */
203
- const PROVIDER_HOME = { claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [] };
255
+ const PROVIDER_HOME = {
256
+ claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [],
257
+ // Each ACP agent keeps its own configuration and sign-in where its row says (acp-agents.ts).
258
+ ...Object.fromEntries(ACP_PROVIDER_IDS.map(id => [id, ACP_AGENTS[id].home])),
259
+ };
204
260
  /** What Claude's own file tools refuse inside its home: the CLI reads these itself, its model never needs to. */
205
261
  const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.json"), join(".claude", "settings.json"), join(".claude", "settings.local.json"), ".npmrc"];
206
262
  /**
@@ -311,8 +367,9 @@ export function taskBoundary(options) {
311
367
  const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
312
368
  return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
313
369
  }
314
- /** The prefixes of every launch's private folders: its tools' config and a Codex build's own home. */
315
- export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-"];
370
+ /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
371
+ * network bridge (egress-bridge.ts). */
372
+ export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-", "so-cmd-"];
316
373
  export function runFolders(own, root = tmpdir()) {
317
374
  return { root: real(root), own: own === null ? null : real(own) };
318
375
  }
@@ -327,6 +384,172 @@ function otherRunFolders(folders) {
327
384
  }
328
385
  return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
329
386
  }
387
+ export { isWritesMode, WRITES_DEFAULT } from "./writes-mode.js";
388
+ /** Claude's transcript folder for a working folder: its path with every character but a letter or digit made `-`
389
+ * (Claude Code's own naming; a name over 200 characters gets a suffix, so it is granted by prefix). */
390
+ export function claudeProjectFolder(home, cwd) {
391
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
392
+ return slug.length > 200 ? { path: join(home, ".claude", "projects", slug.slice(0, 200)), byPrefix: true } : { path: join(home, ".claude", "projects", slug), byPrefix: false };
393
+ }
394
+ /**
395
+ * A folder a write grant may name: made when absent, then only as the real folder it is — never a link, nor reached
396
+ * through one (its real path is its parent's real path and its own name). A build can rewrite what it may write, so a
397
+ * folder it was given could otherwise come back as a link to anywhere and be granted, followed, on its next launch.
398
+ * Null when it isn't such a folder: nothing is granted, and the write it was for fails.
399
+ */
400
+ export function grantFolder(path, create = true) {
401
+ if (create) {
402
+ try {
403
+ mkdirSync(path, { recursive: true, mode: 0o700 });
404
+ }
405
+ catch { /* checked below */ }
406
+ }
407
+ try {
408
+ const held = lstatSync(path);
409
+ if (held.isSymbolicLink() || !held.isDirectory())
410
+ return null;
411
+ // Nor through a link of this user's anywhere above it (a system alias, /var → /private/var, is root's and fine).
412
+ const uid = process.getuid?.();
413
+ for (let folder = dirname(resolve(path)); folder !== dirname(folder); folder = dirname(folder)) {
414
+ const above = lstatSync(folder);
415
+ if (above.isSymbolicLink() && (uid === undefined || above.uid === uid))
416
+ return null;
417
+ }
418
+ const at = realpathSync(path);
419
+ return at === join(realpathSync(dirname(path)), basename(path)) ? at : null;
420
+ }
421
+ catch {
422
+ return null;
423
+ }
424
+ }
425
+ /**
426
+ * The data folders an ACP agent writes besides a ~/.local/share|state root, per agent, only where known: Goose's
427
+ * sessions and state; Qwen Code (a Gemini CLI fork) its history and temp, as Gemini measured. An agent not listed
428
+ * (Cursor, Cline: their settings sit among their data) writes nothing of its own under a workspace build until its
429
+ * layout is measured; a project using one chooses `anywhere`.
430
+ */
431
+ const ACP_DATA = {
432
+ goose: [".local/share/goose", ".local/state/goose"],
433
+ qwen: [".qwen/tmp", ".qwen/history"],
434
+ };
435
+ /** The suffix Claude Code gives a transcript folder whose name it shortened: `-` and a hash, nothing else. */
436
+ const CLAUDE_SHORTENED = "-[a-z0-9]+(/|$)";
437
+ /** A file and the temporary names it is replaced through (`name.tmp-1`, `name.json.12345`), never a folder below it. */
438
+ const ATOMIC_NAMES = "([.~][^/]*)?$";
439
+ /** The parts of a repository's shared Git data a commit in a worktree writes: objects, refs and their logs (and the
440
+ * packed refs). Its config, hooks, info, other worktrees' data and submodules' are never among them. */
441
+ function commitGitData(common) {
442
+ const made = (path) => { try {
443
+ mkdirSync(path, { recursive: true });
444
+ }
445
+ catch { /* granted as named */ } return real(path); };
446
+ return {
447
+ subpaths: ["objects", "refs", "logs"].map(name => grantFolder(join(common, name))).filter((path) => path !== null),
448
+ prefixes: [{ path: join(common, "packed-refs"), suffix: ATOMIC_NAMES }],
449
+ // Alternates there would let objects come from anywhere.
450
+ readOnly: [made(join(common, "objects", "info"))],
451
+ };
452
+ }
453
+ /**
454
+ * What a `workspace` build writes and what it loses (arc 2): its checkout, its checkout's Git data (less the shared
455
+ * repository's config, hooks and info, which Git runs or reads as commands), its own launch folder (its temp is there),
456
+ * its project's cache folder, the terminal devices, and only the provider state its CLI must write (measured: Claude's
457
+ * transcript folder for this checkout; Gemini's history, temp and project list; an ACP agent's own folders). The
458
+ * folders named here are made first, so the provider never has to make them where it can't. `deny` is what this
459
+ * provider must not even read (Claude's live sessions' keys).
460
+ */
461
+ export function writeAllowlist(options) {
462
+ const home = real(options.home);
463
+ const worktree = real(options.worktree);
464
+ const subpaths = new Set([worktree]);
465
+ const prefixes = new Map();
466
+ const readOnly = new Set();
467
+ const deny = new Set();
468
+ const made = (path) => { try {
469
+ mkdirSync(path, { recursive: true, mode: 0o700 });
470
+ }
471
+ catch { /* granted as named either way */ } return real(path); };
472
+ const grant = (path, create = true) => { const at = grantFolder(path, create); if (at !== null)
473
+ subpaths.add(at); };
474
+ if (options.binding !== null) {
475
+ const common = real(join(options.binding.main, ".git"));
476
+ const gitdir = real(options.binding.gitdir);
477
+ grant(gitdir, false);
478
+ // The shared Git data a commit writes, and nothing Git reads as commands or settings: the shared config (filters,
479
+ // hooks path, fsmonitor), hooks, info (attributes, excludes), other worktrees' data and submodules' stay as they are.
480
+ const data = commitGitData(common);
481
+ for (const path of data.subpaths)
482
+ subpaths.add(path);
483
+ for (const one of data.prefixes)
484
+ prefixes.set(`${one.path}\0${one.suffix}`, one);
485
+ for (const path of data.readOnly)
486
+ readOnly.add(path);
487
+ // This worktree's own settings file, present (empty) so it is held read-only everywhere, a bind included.
488
+ const own = join(gitdir, "config.worktree");
489
+ try {
490
+ if (!existsSync(own))
491
+ writeFileSync(own, "", { mode: 0o644, flag: "wx" });
492
+ }
493
+ catch { /* held read-only by name */ }
494
+ readOnly.add(own);
495
+ }
496
+ if (options.launchFolder !== null)
497
+ grant(options.launchFolder, false);
498
+ if (options.cache !== null)
499
+ grant(options.cache);
500
+ if (options.provider === "claude") {
501
+ const folder = claudeProjectFolder(home, worktree);
502
+ if (folder.byPrefix) {
503
+ made(dirname(folder.path));
504
+ prefixes.set(folder.path, { path: folder.path, suffix: CLAUDE_SHORTENED });
505
+ }
506
+ else
507
+ grant(folder.path);
508
+ deny.add(join(home, ".claude", "sessions"));
509
+ }
510
+ else if (options.provider === "gemini") {
511
+ for (const name of ["history", "tmp"])
512
+ grant(join(home, ".gemini", name));
513
+ prefixes.set("gemini", { path: join(home, ".gemini", "projects.json"), suffix: ATOMIC_NAMES });
514
+ }
515
+ else if (Object.hasOwn(ACP_AGENTS, options.provider)) {
516
+ // An ACP agent's configuration stays read-only whole (its MCP servers, hooks, plugins, agents and commands could
517
+ // otherwise be planted for the operator's next session of it); only its known data folders are written (ACP_DATA).
518
+ for (const name of ACP_AGENTS[options.provider].home)
519
+ if (/^\.local\/(share|state)\//.test(name))
520
+ grant(join(home, name));
521
+ for (const name of ACP_DATA[options.provider] ?? [])
522
+ grant(join(home, name));
523
+ }
524
+ return { writes: { subpaths: [...subpaths].sort(), prefixes: [...prefixes.values()].sort((a, b) => a.path.localeCompare(b.path)) }, readOnly: [...readOnly].sort(), deny: [...deny].sort() };
525
+ }
526
+ /** Where a project's builds keep their package caches: one folder per project (one trust domain), never used by the
527
+ * plane's own tools, below the user's cache folder. */
528
+ export function buildCacheFolder(repo, home = homedir(), platform = process.platform, env = process.env) {
529
+ const root = platform === "darwin" ? join(home, "Library", "Caches") : env["XDG_CACHE_HOME"] !== undefined && isAbsolute(env["XDG_CACHE_HOME"]) ? env["XDG_CACHE_HOME"] : join(home, ".cache");
530
+ return join(root, "toolroll-builds", createHash("sha256").update(real(repo)).digest("hex").slice(0, 16));
531
+ }
532
+ /** The environment that points a build's package managers at its project's cache folder, not the user's own. */
533
+ export function buildCacheEnv(folder) {
534
+ const at = (name) => join(folder, name);
535
+ return {
536
+ npm_config_cache: at("npm"), npm_config_store_dir: at("pnpm"), YARN_CACHE_FOLDER: at("yarn"), BUN_INSTALL_CACHE_DIR: at("bun"),
537
+ COREPACK_HOME: at("corepack"), npm_config_devdir: at("node-gyp"), PIP_CACHE_DIR: at("pip"), UV_CACHE_DIR: at("uv"),
538
+ GOCACHE: at("go-build"), GOMODCACHE: at("go-mod"), CARGO_HOME: at("cargo"), GRADLE_USER_HOME: at("gradle"),
539
+ DENO_DIR: at("deno"), XDG_CACHE_HOME: at("xdg"),
540
+ };
541
+ }
542
+ /**
543
+ * Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
544
+ * Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
545
+ * (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
546
+ * confined; the run's proxy resolves names for it.
547
+ */
548
+ export const DELEGATION_SERVICES = [
549
+ "com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
550
+ "com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
551
+ "com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
552
+ ];
330
553
  export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
331
554
  /**
332
555
  * The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
@@ -457,7 +680,14 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
457
680
  // Codex lets the more specific path win: no read grant inside a pool, but the build's own checkout.
458
681
  const pooled = (path) => pool !== undefined && pool.deny.some(one => path === one || inside(path, one))
459
682
  && !(pool.own !== null && (path === pool.own || inside(path, pool.own)));
460
- if (fullAccess)
683
+ // Full access writes anywhere its fence allows, unless the build writes only its workspace (arc 2): then exactly its
684
+ // allowlist, in either mode, beside the workspace Codex's own profile gives.
685
+ // Codex's own workspace profile also writes the shared temp folders; a workspace build starts from its read-only
686
+ // profile instead, with exactly the allowlist (its checkout among it) written back.
687
+ if (grants.writes !== undefined)
688
+ for (const path of grants.writes.subpaths)
689
+ access.set(real(path), "write");
690
+ else if (fullAccess)
461
691
  access.set("/", "write");
462
692
  for (const path of readOnly)
463
693
  if (!pooled(real(path)))
@@ -471,14 +701,22 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
471
701
  for (const path of held.write)
472
702
  access.set(path, "write");
473
703
  }
474
- for (const path of deny)
704
+ // Denies are keyed where they resolve; the console's run folder is also denied by its own pathname, so a link there
705
+ // stays unreplaceable, not only its target.
706
+ for (const path of deny) {
475
707
  access.set(real(path), "deny");
708
+ if (basename(path) === INSTANCE_DIR)
709
+ access.set(path, "deny");
710
+ }
476
711
  const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
477
712
  return [
478
713
  "-c", 'default_permissions="standing-orders"',
479
- "-c", 'permissions.standing-orders.extends=":workspace"',
714
+ "-c", `permissions.standing-orders.extends=${grants.writes === undefined ? '":workspace"' : '":read-only"'}`,
480
715
  "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
481
- ...(fullAccess ? ["-c", "permissions.standing-orders.network.enabled=true", "-c", 'approval_policy="never"'] : []),
716
+ // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
717
+ // network is open (and Full access asked for it), and are offline otherwise.
718
+ ...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
719
+ ...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
482
720
  ];
483
721
  }
484
722
  /**
@@ -625,6 +863,14 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
625
863
  return [
626
864
  "(version 1)",
627
865
  "(allow default)",
866
+ // A workspace build writes only its allowlist (and the terminal devices); every deny and read-only rule after this
867
+ // still holds inside it.
868
+ ...(grants.writes === undefined ? [] : [
869
+ "(deny file-write*)",
870
+ `(allow file-write* (subpath "/dev")${grants.writes.subpaths.length === 0 ? "" : ` ${paths("subpath", grants.writes.subpaths)}`}${grants.writes.prefixes.map(one => ` (regex #"^${seatbeltRegex(one.path)}${one.suffix}")`).join("")})`,
871
+ // A granted folder itself can't be renamed, removed or replaced (by a link to anywhere, say): only what is in it.
872
+ ...(grants.writes.subpaths.length === 0 ? [] : [`(deny file-write* ${paths("literal", grants.writes.subpaths)})`]),
873
+ ]),
628
874
  ...(pool === undefined || pool.deny.length === 0 ? [] : [`(deny file-read* file-write* ${paths("subpath", pool.deny)})`]),
629
875
  ...(pool?.own == null ? [] : [
630
876
  `(allow file-read* file-write* ${paths("subpath", [pool.own])})`,
@@ -644,9 +890,38 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
644
890
  "(deny mach-priv-task-port)",
645
891
  "(deny mach-task-name)",
646
892
  ...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
893
+ ...macosNetworkRules(grants.network),
894
+ // A workspace build reaches no unix socket but its own (a container engine's, an agent's, a database's would act
895
+ // outside its limits for it), whatever its network: a confined network has its own rule for this already. Its open
896
+ // network still names hosts: the system's DNS service answers on a unix socket, and without it nothing resolves.
897
+ ...(grants.writes === undefined || grants.network?.mode === "allowlist" ? [] : [
898
+ "(deny network-outbound (remote unix-socket))",
899
+ `(allow network-outbound (remote unix-socket (path-literal ${seatbelt(MACOS_DNS_SOCKET)})))`,
900
+ ...(grants.writes.subpaths.length === 0 ? [] : [`(allow network-outbound ${grants.writes.subpaths.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
901
+ ]),
647
902
  ].join("\n");
648
903
  }
904
+ /**
905
+ * The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
906
+ * allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
907
+ * allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
908
+ * or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
909
+ * connection.
910
+ */
911
+ export function macosNetworkRules(network) {
912
+ if (network === undefined || network.mode === "open")
913
+ return [];
914
+ const local = (port) => `(remote ip "localhost:${port}")`;
915
+ return [
916
+ "(deny network-outbound)",
917
+ `(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
918
+ ...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
919
+ `(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
920
+ ];
921
+ }
649
922
  export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
923
+ /** Where macOS's resolver (mDNSResponder) answers: every name lookup on a Mac is a message to this socket. */
924
+ export const MACOS_DNS_SOCKET = "/private/var/run/mDNSResponder";
650
925
  /** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
651
926
  const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
652
927
  let seatbeltWorks = null;
@@ -719,6 +994,9 @@ function linuxGrants(fence, grants) {
719
994
  * folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
720
995
  * bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
721
996
  export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
997
+ const network = grants.network?.mode === "allowlist" ? grants.network : null;
998
+ if (network !== null && network.bridge === undefined)
999
+ throw new Error("A confined network on Linux needs its bridge");
722
1000
  const mask = (path) => {
723
1001
  let folder = true;
724
1002
  try {
@@ -732,5 +1010,154 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
732
1010
  const pool = grants.pool?.deny ?? [];
733
1011
  const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
734
1012
  const kept = linuxGrants([...fence, ...pool], grants).flatMap(path => ["--ro-bind", path, path]);
735
- return { file: "bwrap", args: ["--dev-bind", "/", "/", ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, "--die-with-parent", "--", file, ...args] };
1013
+ // A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
1014
+ // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
1015
+ const isolated = network === null ? [] : [
1016
+ "--unshare-net", "--unshare-pid", "--proc", "/proc",
1017
+ ...linuxHostSockets(undefined, undefined, undefined, grants.writes !== undefined).flatMap(mask),
1018
+ "--ro-bind", network.bridge.folder, network.bridge.folder,
1019
+ ];
1020
+ // A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
1021
+ // all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
1022
+ // A workspace build (arc 2) sees the whole filesystem read-only, a /dev of its own and a /tmp of its own, with only
1023
+ // its allowlist bound back writable; its own checkout's Git data is in that list.
1024
+ const writes = grants.writes;
1025
+ const root = writes === undefined ? ["--dev-bind", "/", "/"] : ["--ro-bind", "/", "/", "--dev", "/dev"];
1026
+ const writable = writes === undefined ? [] : [...writes.subpaths, ...writes.prefixes.map(one => one.path)].filter(path => existsSync(path)).flatMap(path => ["--bind", path, path]);
1027
+ // What a private /tmp gives back is read-only for a workspace build (its writable part is bound again after): the
1028
+ // checkout's whole shared Git data among it must not come back writable.
1029
+ // A confined network brings a /tmp of its own (arc 1); a workspace build with an open network sees the shared /tmp
1030
+ // read-only like the rest of the root, its own folders there bound back writable.
1031
+ const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network?.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => [writes === undefined ? "--bind" : "--ro-bind", path, path])];
1032
+ // A workspace build never reaches the host's own services either (a container engine would write anywhere for it),
1033
+ // whatever its network: confined networks mask them below already.
1034
+ const services = writes === undefined || network !== null ? [] : linuxHostSockets(undefined, undefined, undefined, true).flatMap(mask);
1035
+ const command = network === null ? [file, ...args]
1036
+ : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
1037
+ return { file: "bwrap", args: [...root, ...privateTmp, ...writable, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...services, ...isolated, "--die-with-parent", "--", ...command] };
1038
+ }
1039
+ /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
1040
+ * engines), wherever they exist here. */
1041
+ export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp", followLinks = false, env = process.env) {
1042
+ // The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
1043
+ const system = [
1044
+ ...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
1045
+ "/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
1046
+ ];
1047
+ const masks = new Set();
1048
+ for (const path of system) {
1049
+ try {
1050
+ masks.add(realpathSync(path));
1051
+ }
1052
+ catch { /* not here */ }
1053
+ }
1054
+ // The user's own, in places a build could plant names: taken only as the real folders or sockets they are, never
1055
+ // through a link (a planted link would turn a mask onto any folder at all). Container engines
1056
+ // run per user (Docker Desktop and the docker CLI's folder whole, so a socket made there later is covered too;
1057
+ // Colima, OrbStack, Lima, Rancher Desktop, rootless containers); a terminal multiplexer takes commands from its
1058
+ // socket; an SSH agent signs as you; editors keep IPC sockets in /tmp (hidden by the build's private /tmp).
1059
+ // Masked whoever owns it (a folder `sudo docker` made is root's): only a link is never followed, since a link turns
1060
+ // a mask onto its target. A link there is a residual until builds can't write the home folder (arc 2).
1061
+ const owned = (path) => {
1062
+ try {
1063
+ const held = lstatSync(path);
1064
+ return !held.isSymbolicLink() && (held.isDirectory() || held.isSocket());
1065
+ }
1066
+ catch {
1067
+ return false;
1068
+ }
1069
+ };
1070
+ for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name))) {
1071
+ if (owned(path))
1072
+ masks.add(path);
1073
+ // A workspace build can't plant a link in the home folder, so a link there is the operator's own: its target is
1074
+ // masked too (an engine moved elsewhere).
1075
+ else if (followLinks) {
1076
+ try {
1077
+ if (lstatSync(path).isSymbolicLink())
1078
+ masks.add(realpathSync(path));
1079
+ }
1080
+ catch { /* dangling */ }
1081
+ }
1082
+ }
1083
+ // An engine Toolroll's own environment points at, wherever it is (never the build's to choose): always masked. So is
1084
+ // the SSH agent it names (it signs as you), which a shared /tmp would otherwise leave in reach.
1085
+ for (const name of ["DOCKER_HOST", "CONTAINER_HOST"]) {
1086
+ const socket = /^unix:\/\/(\/.+)$/.exec(env[name] ?? "")?.[1];
1087
+ if (socket !== undefined) {
1088
+ try {
1089
+ masks.add(realpathSync(socket));
1090
+ }
1091
+ catch { /* not there */ }
1092
+ }
1093
+ }
1094
+ const agent = env["SSH_AUTH_SOCK"];
1095
+ if (agent !== undefined && isAbsolute(agent)) {
1096
+ try {
1097
+ const socket = realpathSync(agent);
1098
+ masks.add(socket);
1099
+ // The folder an agent makes for its socket (ssh-XXXX in /tmp) too, so a socket it makes next is out of reach.
1100
+ if (inside(socket, real(tmp)) && dirname(socket) !== real(tmp) && !lstatSync(dirname(socket)).isSymbolicLink())
1101
+ masks.add(dirname(socket));
1102
+ }
1103
+ catch { /* not there */ }
1104
+ }
1105
+ // A build that shares /tmp (a workspace build with an open network) reaches none of the sockets there either: every
1106
+ // socket two levels down (a multiplexer's, an agent's, a display's, an editor's), and the folder that holds it, taken
1107
+ // as it is (never through a link). Toolroll's own launch folders are fenced by name already.
1108
+ if (followLinks) {
1109
+ const socketIn = (folder, depth) => {
1110
+ let found = false;
1111
+ let entries = [];
1112
+ try {
1113
+ entries = readdirSync(folder);
1114
+ }
1115
+ catch {
1116
+ return false;
1117
+ }
1118
+ for (const entry of entries) {
1119
+ const path = join(folder, entry);
1120
+ if (RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix)))
1121
+ continue;
1122
+ let held;
1123
+ try {
1124
+ held = lstatSync(path);
1125
+ }
1126
+ catch {
1127
+ continue;
1128
+ }
1129
+ if (held.isSocket()) {
1130
+ masks.add(path);
1131
+ found = true;
1132
+ }
1133
+ else if (held.isDirectory() && !held.isSymbolicLink() && depth > 0 && socketIn(path, depth - 1)) {
1134
+ masks.add(path);
1135
+ found = true;
1136
+ }
1137
+ }
1138
+ return found;
1139
+ };
1140
+ socketIn(tmp, 1);
1141
+ }
1142
+ return [...masks].sort();
1143
+ }
1144
+ /**
1145
+ * What each agent's fence gives it, by permission mode: the one table README's "What an agent can reach" shows
1146
+ * (fenceSummaryMarkdown; a test holds them equal).
1147
+ */
1148
+ export const FENCE_SUMMARY = [
1149
+ {
1150
+ agent: "**Codex / OpenRouter** (any OS)",
1151
+ auto: "Codex's sandbox with the fence: writes only the checkout, its Git data and the project's caches (anywhere outside the fence, under a project set to write anywhere); no network",
1152
+ full: "the same writes, with the fence; its commands reach the network only when the project's network is open",
1153
+ },
1154
+ {
1155
+ agent: "**Claude, Gemini, ACP agents**",
1156
+ auto: "macOS: inside a Seatbelt sandbox that denies the fence and writes only the checkout, its Git data, the project's caches, the run's temp folder and the agent's own session files. Linux: the same in bubblewrap, with a /tmp of its own. Neither (Windows, or Linux without bubblewrap): the build doesn't start, unless an instance operator allows unconfined agents",
1157
+ full: "same",
1158
+ },
1159
+ ];
1160
+ /** FENCE_SUMMARY as README's markdown table. */
1161
+ export function fenceSummaryMarkdown() {
1162
+ return ["| | Auto | Full access |", "|---|---|---|", ...FENCE_SUMMARY.map(row => `| ${row.agent} | ${row.auto} | ${row.full} |`)].join("\n");
736
1163
  }
@@ -18,7 +18,7 @@ import { closeSync, constants, fstatSync, openSync, readSync, statSync, unlinkSy
18
18
  import { join } from "node:path";
19
19
  import { ingestStatus, markStuck, noteDiff, noteTranscript, NOTE_MAX, pageQuestions, QUESTION_KEY, QUESTION_MAX, QUESTIONS_OPEN_MAX, QUESTIONS_PER_ATTEMPT } from "./agent-board.js";
20
20
  import { readVerifiedArtifact } from "./evidence.js";
21
- import { liveLogPath, sanitizeTranscriptLine } from "./live.js";
21
+ import { legacyLiveLogPath, liveLogPath, sanitizeTranscriptLine } from "./live.js";
22
22
  import { observeWorktree, parseBaseTreeSnapshot, PEEK_LIMITS } from "./peek.js";
23
23
  /** The whole file may grow this far; past it, nothing more is read. */
24
24
  export const STATUS_FILE_CAP = 256 * 1024;
@@ -168,7 +168,14 @@ export async function changedFileCount(worktree, base) {
168
168
  */
169
169
  export function pulseAgentBoard(store, state, now) {
170
170
  try {
171
- const bytes = statSync(liveLogPath(state.root, state.run)).size;
171
+ // The records file every run writes now, else a legacy text file.
172
+ let bytes;
173
+ try {
174
+ bytes = statSync(liveLogPath(state.root, state.run)).size;
175
+ }
176
+ catch {
177
+ bytes = statSync(legacyLiveLogPath(state.root, state.run)).size;
178
+ }
172
179
  noteTranscript(store, state.run, state.attemptKey, bytes, now);
173
180
  }
174
181
  catch { /* no live log: the activity row still counts */ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The agent states' words, tones and counts (docs/plans/agents-cockpit.md §1), with nothing the browser can't load:
3
+ * agent-state.ts derives the states; the console's pages and the sidebar say them with these.
4
+ */
5
+ /** The display states, strongest first: a roll-up takes the first one any member has. */
6
+ export declare const DISPLAY_STATES: readonly ["needs-you", "failed", "stuck", "paused", "working", "waiting", "done", "idle"];
7
+ export type DisplayState = (typeof DISPLAY_STATES)[number];
8
+ export declare const STATE_WORDS: Record<DisplayState, string>;
9
+ /** For someone who may not act on it: the obligation is still there, and still counts. */
10
+ export declare const NEEDS_SOMEONE = "Needs someone";
11
+ /** Each state's tone, for the console's one palette (attention, problem, live, ready, done, muted). */
12
+ export declare const STATE_TONE: Record<DisplayState, "attention" | "problem" | "live" | "ready" | "done" | "muted">;
13
+ export type StateCounts = Record<DisplayState, number>;
14
+ export declare const NO_COUNTS: StateCounts;
15
+ /** The strongest state among counts, and the counts themselves. Nothing counted is idle. */
16
+ export declare function rollUp(counts: StateCounts): {
17
+ state: DisplayState;
18
+ counts: StateCounts;
19
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The agent states' words, tones and counts (docs/plans/agents-cockpit.md §1), with nothing the browser can't load:
3
+ * agent-state.ts derives the states; the console's pages and the sidebar say them with these.
4
+ */
5
+ /** The display states, strongest first: a roll-up takes the first one any member has. */
6
+ export const DISPLAY_STATES = ["needs-you", "failed", "stuck", "paused", "working", "waiting", "done", "idle"];
7
+ export const STATE_WORDS = {
8
+ "needs-you": "Needs you", failed: "Failed", stuck: "Stuck", paused: "Paused", working: "Working", waiting: "Waiting", done: "Done", idle: "Idle",
9
+ };
10
+ /** For someone who may not act on it: the obligation is still there, and still counts. */
11
+ export const NEEDS_SOMEONE = "Needs someone";
12
+ /** Each state's tone, for the console's one palette (attention, problem, live, ready, done, muted). */
13
+ export const STATE_TONE = {
14
+ "needs-you": "attention", failed: "problem", stuck: "problem", paused: "muted", working: "live", waiting: "muted", done: "ready", idle: "muted",
15
+ };
16
+ export const NO_COUNTS = Object.freeze({ "needs-you": 0, failed: 0, stuck: 0, paused: 0, working: 0, waiting: 0, done: 0, idle: 0 });
17
+ /** The strongest state among counts, and the counts themselves. Nothing counted is idle. */
18
+ export function rollUp(counts) {
19
+ return { state: DISPLAY_STATES.find(state => state !== "idle" && counts[state] > 0) ?? "idle", counts };
20
+ }