toolroll 0.9.63 → 0.9.65

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 (292) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -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 +50 -2
  16. package/dist/agent-fence.js +157 -10
  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 +36 -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/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +235 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +197 -15
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/checkout-proof.d.ts +28 -0
  55. package/dist/checkout-proof.js +62 -0
  56. package/dist/claim.d.ts +12 -0
  57. package/dist/claim.js +19 -0
  58. package/dist/cli-help.d.ts +1 -1
  59. package/dist/cli-help.js +4 -1
  60. package/dist/cli.d.ts +3 -0
  61. package/dist/cli.js +21 -2
  62. package/dist/coding-ui.js +6 -4
  63. package/dist/console-instance.d.ts +124 -0
  64. package/dist/console-instance.js +337 -0
  65. package/dist/contracts/chat-actions.d.ts +16 -0
  66. package/dist/contracts/chat-actions.js +4 -0
  67. package/dist/contracts/chat-content.d.ts +19 -0
  68. package/dist/contracts/chat-content.js +6 -0
  69. package/dist/contracts/console-api.d.ts +12 -4
  70. package/dist/contracts/console-api.js +12 -4
  71. package/dist/contracts/flow-card.d.ts +4 -0
  72. package/dist/contracts/flow-card.js +3 -0
  73. package/dist/contracts/flow.d.ts +46 -0
  74. package/dist/contracts/flow.js +11 -2
  75. package/dist/contracts/lead-tools.d.ts +92 -0
  76. package/dist/contracts/lead-tools.js +13 -0
  77. package/dist/contracts/provider.d.ts +11 -1
  78. package/dist/contracts/provider.js +1 -1
  79. package/dist/contracts/route.d.ts +76 -1
  80. package/dist/contracts/route.js +1 -1
  81. package/dist/contracts/store-json.d.ts +2 -0
  82. package/dist/control-setup.d.ts +1 -1
  83. package/dist/control-ui.js +2 -2
  84. package/dist/converse.d.ts +16 -0
  85. package/dist/converse.js +32 -4
  86. package/dist/demo-chat.js +4 -4
  87. package/dist/demo.js +2 -2
  88. package/dist/desktop-update-gate.js +6 -0
  89. package/dist/desktop-update.d.ts +1 -1
  90. package/dist/discord-chat.js +4 -1
  91. package/dist/doctor.js +1 -1
  92. package/dist/egress-bridge.d.ts +17 -0
  93. package/dist/egress-bridge.js +111 -0
  94. package/dist/egress.d.ts +119 -0
  95. package/dist/egress.js +423 -0
  96. package/dist/exec.d.ts +78 -0
  97. package/dist/exec.js +367 -9
  98. package/dist/export-ui.js +2 -2
  99. package/dist/flow-engine.js +24 -5
  100. package/dist/flow-gallery-ui.js +2 -2
  101. package/dist/flow-gallery.js +27 -8
  102. package/dist/flow-share.js +16 -5
  103. package/dist/flow-triggers.d.ts +77 -8
  104. package/dist/flow-triggers.js +517 -70
  105. package/dist/flows-cli.js +8 -2
  106. package/dist/flows-ui.d.ts +0 -2
  107. package/dist/flows-ui.js +52 -4
  108. package/dist/flows.d.ts +4 -0
  109. package/dist/flows.js +9 -1
  110. package/dist/html.d.ts +9 -0
  111. package/dist/html.js +11 -0
  112. package/dist/invoke.d.ts +17 -0
  113. package/dist/invoke.js +217 -21
  114. package/dist/keys.d.ts +18 -14
  115. package/dist/keys.js +5 -0
  116. package/dist/lead-capabilities.js +3 -2
  117. package/dist/lead-contract.js +2 -0
  118. package/dist/lead-doors.d.ts +2 -0
  119. package/dist/lead-doors.js +7 -0
  120. package/dist/lead-follow.d.ts +2 -0
  121. package/dist/lead-follow.js +58 -2
  122. package/dist/lead-progress.js +1 -0
  123. package/dist/lead-queue-runtime.d.ts +46 -0
  124. package/dist/lead-queue-runtime.js +191 -0
  125. package/dist/lead-queue.d.ts +104 -0
  126. package/dist/lead-queue.js +187 -0
  127. package/dist/lead-sweep.d.ts +67 -0
  128. package/dist/lead-sweep.js +99 -0
  129. package/dist/lead-tools.d.ts +22 -0
  130. package/dist/lead-tools.js +152 -1
  131. package/dist/lead.d.ts +13 -5
  132. package/dist/lead.js +89 -29
  133. package/dist/limits-ui.d.ts +8 -1
  134. package/dist/limits-ui.js +26 -0
  135. package/dist/live-bus.d.ts +8 -0
  136. package/dist/live-bus.js +40 -4
  137. package/dist/live-detail.d.ts +27 -0
  138. package/dist/live-detail.js +57 -0
  139. package/dist/live-notes.d.ts +20 -0
  140. package/dist/live-notes.js +47 -0
  141. package/dist/live-records.d.ts +140 -0
  142. package/dist/live-records.js +935 -0
  143. package/dist/live-terminal.d.ts +258 -0
  144. package/dist/live-terminal.js +649 -0
  145. package/dist/live.d.ts +38 -27
  146. package/dist/live.js +142 -124
  147. package/dist/mcp-oauth.d.ts +6 -3
  148. package/dist/mcp-oauth.js +8 -4
  149. package/dist/memory-pass.js +2 -2
  150. package/dist/mobile-viewport.d.ts +5 -1
  151. package/dist/mobile-viewport.js +18 -2
  152. package/dist/model-catalog.js +4 -0
  153. package/dist/monitoring-ui.js +2 -2
  154. package/dist/needs-you.d.ts +1 -1
  155. package/dist/needs-you.js +11 -4
  156. package/dist/operate.d.ts +2 -0
  157. package/dist/operate.js +275 -17
  158. package/dist/passwordless.d.ts +23 -0
  159. package/dist/passwordless.js +26 -0
  160. package/dist/peek-cli.d.ts +12 -4
  161. package/dist/peek-cli.js +19 -11
  162. package/dist/phase-routing.js +10 -3
  163. package/dist/planner-source.d.ts +3 -0
  164. package/dist/planner-source.js +14 -0
  165. package/dist/planner.d.ts +2 -1
  166. package/dist/planner.js +7 -1
  167. package/dist/policy-ui.js +2 -2
  168. package/dist/policy.d.ts +1 -20
  169. package/dist/policy.js +29 -2
  170. package/dist/project-delete-ui.d.ts +12 -0
  171. package/dist/project-delete-ui.js +41 -9
  172. package/dist/project-delete.js +2 -2
  173. package/dist/project-memory.d.ts +3 -1
  174. package/dist/project-memory.js +57 -25
  175. package/dist/project-tools.d.ts +5 -3
  176. package/dist/project-tools.js +12 -9
  177. package/dist/provider-auth.d.ts +4 -3
  178. package/dist/provider-auth.js +5 -4
  179. package/dist/provider-connection.d.ts +2 -1
  180. package/dist/provider-connection.js +5 -0
  181. package/dist/provider.d.ts +25 -2
  182. package/dist/provider.js +63 -9
  183. package/dist/pull-request-ui.js +2 -2
  184. package/dist/push.js +1 -1
  185. package/dist/quick-start.d.ts +24 -0
  186. package/dist/quick-start.js +127 -0
  187. package/dist/release.d.ts +3 -0
  188. package/dist/release.js +14 -8
  189. package/dist/request-budget-ui.js +2 -2
  190. package/dist/result-review.d.ts +1 -1
  191. package/dist/result-review.js +9 -3
  192. package/dist/retention-ui.js +2 -2
  193. package/dist/retention.js +10 -1
  194. package/dist/run-efficiency.d.ts +73 -0
  195. package/dist/run-efficiency.js +228 -0
  196. package/dist/runner.d.ts +5 -1
  197. package/dist/runner.js +22 -1
  198. package/dist/scope.d.ts +170 -5
  199. package/dist/scope.js +251 -31
  200. package/dist/scout-net.d.ts +4 -1
  201. package/dist/scout-net.js +37 -12
  202. package/dist/scout.d.ts +20 -6
  203. package/dist/scout.js +27 -3
  204. package/dist/serve.js +247 -40
  205. package/dist/server/chat.d.ts +1 -1
  206. package/dist/server/chat.js +122 -31
  207. package/dist/server/chrome.d.ts +6 -1
  208. package/dist/server/chrome.js +18 -7
  209. package/dist/server/console.css +52 -38
  210. package/dist/server/flows.js +38 -8
  211. package/dist/server/guards.d.ts +1 -0
  212. package/dist/server/guards.js +23 -9
  213. package/dist/server/http.d.ts +7 -0
  214. package/dist/server/http.js +3 -2
  215. package/dist/server/live.d.ts +34 -1
  216. package/dist/server/live.js +39 -17
  217. package/dist/server/pages-assets.js +5 -0
  218. package/dist/server/pages.js +90 -13
  219. package/dist/server/people-tokens.d.ts +3 -0
  220. package/dist/server/people-tokens.js +55 -17
  221. package/dist/server/remote-hooks.js +12 -6
  222. package/dist/server/remote.d.ts +2 -1
  223. package/dist/server/remote.js +14 -5
  224. package/dist/server/render-chat.d.ts +12 -3
  225. package/dist/server/render-chat.js +47 -16
  226. package/dist/server/render-pages.d.ts +4 -1
  227. package/dist/server/render-pages.js +5 -4
  228. package/dist/server/render-people.d.ts +3 -2
  229. package/dist/server/render-people.js +3 -2
  230. package/dist/server/render-results.d.ts +18 -2
  231. package/dist/server/render-results.js +37 -28
  232. package/dist/server/render-settings.js +6 -5
  233. package/dist/server/render-tasks.d.ts +35 -10
  234. package/dist/server/render-tasks.js +110 -27
  235. package/dist/server/request-context.d.ts +2 -0
  236. package/dist/server/request-context.js +2 -1
  237. package/dist/server/route-table.d.ts +3 -0
  238. package/dist/server/route-table.js +46 -5
  239. package/dist/server/runtime.d.ts +33 -2
  240. package/dist/server/session.d.ts +25 -1
  241. package/dist/server/session.js +18 -4
  242. package/dist/server/settings.js +121 -10
  243. package/dist/server/sweeps.d.ts +4 -0
  244. package/dist/server/sweeps.js +58 -0
  245. package/dist/server/task-views.d.ts +2 -1
  246. package/dist/server/task-views.js +96 -6
  247. package/dist/server/tasks.js +320 -74
  248. package/dist/server/terminals.d.ts +31 -0
  249. package/dist/server/terminals.js +365 -0
  250. package/dist/setup-guide.js +3 -2
  251. package/dist/slack-chat.js +4 -1
  252. package/dist/spend-ui.js +3 -3
  253. package/dist/spend.js +2 -1
  254. package/dist/steer-capabilities.d.ts +32 -0
  255. package/dist/steer-capabilities.js +60 -0
  256. package/dist/steer-controls.d.ts +14 -0
  257. package/dist/steer-controls.js +36 -0
  258. package/dist/steer-status.js +2 -2
  259. package/dist/storage-ui.js +4 -4
  260. package/dist/store-baseline.d.ts +1 -1
  261. package/dist/store-baseline.js +1 -1
  262. package/dist/store.d.ts +430 -15
  263. package/dist/store.js +1041 -52
  264. package/dist/subagent-desk.js +1 -1
  265. package/dist/summary.js +3 -0
  266. package/dist/task-source.d.ts +58 -0
  267. package/dist/task-source.js +93 -0
  268. package/dist/team-leads.js +1 -1
  269. package/dist/teams-chat.js +4 -1
  270. package/dist/telegram-lead.d.ts +3 -0
  271. package/dist/telegram-lead.js +44 -7
  272. package/dist/telegram.d.ts +11 -2
  273. package/dist/telegram.js +51 -17
  274. package/dist/terminal-catalog.d.ts +11 -0
  275. package/dist/terminal-catalog.js +129 -0
  276. package/dist/text-limits.d.ts +2 -0
  277. package/dist/text-limits.js +2 -0
  278. package/dist/tool-broker.d.ts +2 -0
  279. package/dist/tool-broker.js +1 -0
  280. package/dist/toolroll-update-ui.js +2 -2
  281. package/dist/toolroll-update.js +13 -3
  282. package/dist/tools-ui.js +2 -2
  283. package/dist/webhook-limits.d.ts +23 -0
  284. package/dist/webhook-limits.js +30 -0
  285. package/dist/webhook-readers.d.ts +56 -0
  286. package/dist/webhook-readers.js +138 -0
  287. package/dist/webhook-signing.d.ts +27 -0
  288. package/dist/webhook-signing.js +119 -0
  289. package/dist/work-index.d.ts +29 -0
  290. package/dist/work-index.js +16 -2
  291. package/dist/workspace-revision.js +3 -1
  292. package/package.json +11 -2
@@ -0,0 +1,23 @@
1
+ import { type AcpProviderId } from "./acp-agents.js";
2
+ export type AcpInspection = {
3
+ found: false;
4
+ binary: string;
5
+ } | {
6
+ found: true;
7
+ binary: string;
8
+ /** The PATH entry that matched, and where it really is. */
9
+ path: string;
10
+ realpath: string;
11
+ /** A native executable (Mach-O/ELF), or a script with a shebang line. */
12
+ kind: "native" | "script" | "unknown";
13
+ /** For a script: the nearest package.json's name and version, read, never run. */
14
+ package: {
15
+ name: string;
16
+ version: string;
17
+ } | null;
18
+ size: number;
19
+ };
20
+ /** Find an agent's binary on PATH, and describe it without running it. */
21
+ export declare function inspectAcpAgent(provider: AcpProviderId, env?: Readonly<Record<string, string | undefined>>): AcpInspection;
22
+ /** The words a screen says for an inspection: "installed at …, version …" or "not installed". */
23
+ export declare function inspectionWords(inspection: AcpInspection): string;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * What can be known about an ACP agent's binary without running it
3
+ * (docs/plans/acp-adapter.md § Identity): nothing outside the one spawning
4
+ * gateway executes an ACP agent — not readiness, not the providers screen,
5
+ * not a version probe. So readiness and the screens look, and never run:
6
+ * PATH lookup, realpath, lstat, and for a script launcher a bounded read of
7
+ * the nearest package.json for its name and version.
8
+ *
9
+ * None of this is evidence an agent may run. That is its conformance check
10
+ * (attestation), which this only helps describe.
11
+ */
12
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
13
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
14
+ import { ACP_AGENTS } from "./acp-agents.js";
15
+ /** The most bytes read from a launcher (its first line) or a package.json. */
16
+ const READ_CAP = 64 * 1024;
17
+ /** How many folders up from the launcher a package.json is looked for. */
18
+ const PACKAGE_DEPTH = 6;
19
+ /** Read at most `cap` bytes from the start of a regular file, without following a final symlink. */
20
+ function readHead(file, cap) {
21
+ let fd = null;
22
+ try {
23
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | (constants.O_NONBLOCK ?? 0));
24
+ if (!fstatSync(fd).isFile())
25
+ return null;
26
+ const buffer = Buffer.alloc(cap);
27
+ const read = readSync(fd, buffer, 0, cap, 0);
28
+ return buffer.subarray(0, read);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ finally {
34
+ if (fd !== null)
35
+ closeSync(fd);
36
+ }
37
+ }
38
+ function kindOf(head) {
39
+ if (head === null || head.length < 4)
40
+ return "unknown";
41
+ if (head[0] === 0x23 && head[1] === 0x21)
42
+ return "script"; // "#!"
43
+ const magic = head.readUInt32BE(0);
44
+ // ELF, Mach-O (both byte orders, 32/64-bit) and fat binaries.
45
+ if (magic === 0x7f454c46 || [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca].includes(magic))
46
+ return "native";
47
+ return "unknown";
48
+ }
49
+ /** The nearest package.json above a script, by bounded read; its name and plain version, or null. */
50
+ function packageOf(realpath) {
51
+ let folder = dirname(realpath);
52
+ for (let depth = 0; depth < PACKAGE_DEPTH; depth += 1) {
53
+ const head = readHead(join(folder, "package.json"), READ_CAP);
54
+ if (head !== null) {
55
+ try {
56
+ const parsed = JSON.parse(head.toString("utf8"));
57
+ const name = parsed["name"];
58
+ const version = parsed["version"];
59
+ if (typeof name === "string" && typeof version === "string" && name.length <= 214 && /^\d+\.\d+\.\d+$/.test(version))
60
+ return { name, version };
61
+ }
62
+ catch {
63
+ // A package.json past the cap or not JSON says nothing.
64
+ }
65
+ return null;
66
+ }
67
+ const up = dirname(folder);
68
+ if (up === folder)
69
+ break;
70
+ folder = up;
71
+ }
72
+ return null;
73
+ }
74
+ /** Find an agent's binary on PATH, and describe it without running it. */
75
+ export function inspectAcpAgent(provider, env = process.env) {
76
+ const binary = ACP_AGENTS[provider].binary;
77
+ for (const folder of (env["PATH"] ?? "").split(delimiter)) {
78
+ if (folder === "" || !isAbsolute(folder))
79
+ continue;
80
+ const path = join(folder, binary);
81
+ try {
82
+ lstatSync(path);
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ let realpath;
88
+ try {
89
+ realpath = realpathSync(path);
90
+ const stat = statSync(realpath);
91
+ if (!stat.isFile() || (stat.mode & 0o111) === 0)
92
+ continue;
93
+ const head = readHead(realpath, 4096);
94
+ const kind = kindOf(head);
95
+ return { found: true, binary, path, realpath, kind, package: kind === "script" ? packageOf(realpath) : null, size: stat.size };
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ }
101
+ return { found: false, binary };
102
+ }
103
+ /** The words a screen says for an inspection: "installed at …, version …" or "not installed". */
104
+ export function inspectionWords(inspection) {
105
+ if (!inspection.found)
106
+ return `\`${inspection.binary}\` is not installed on this computer's PATH`;
107
+ const version = inspection.package === null ? "" : ` (${inspection.package.name} ${inspection.package.version})`;
108
+ return `installed at ${inspection.realpath}${version}`;
109
+ }
@@ -20,8 +20,6 @@ export declare const QUESTIONS_PER_ATTEMPT = 10;
20
20
  export declare const QUESTION_PAGE_EVERY_MS: number;
21
21
  /** The board's one note on a run whose agent asked past the caps (set once). */
22
22
  export declare const QUESTIONS_OVERFLOW_NOTE = "The agent asked more questions than the board takes; see its transcript.";
23
- /** Notification kinds held while the person watches the run, and re-checked when the watch lapses. */
24
- export declare const WATCHED_KINDS: ReadonlySet<string>;
25
23
  export declare const AGENT_BOARD_SCHEMA = "\nCREATE TABLE IF NOT EXISTS agent_turn (\n id INTEGER PRIMARY KEY,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n attempt_key TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('busy','idle','crashed')),\n at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS agent_turn_run ON agent_turn (run, id);\nCREATE TABLE IF NOT EXISTS agent_now (\n task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id) ON DELETE CASCADE,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n attempt_key TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('busy','idle','crashed')),\n state_at TEXT NOT NULL,\n transcript_bytes INTEGER NOT NULL DEFAULT 0,\n transcript_at TEXT NOT NULL,\n stuck_at TEXT,\n said TEXT CHECK (said IN ('working','blocked','done')),\n said_note TEXT,\n said_at TEXT,\n status_offset INTEGER NOT NULL DEFAULT 0,\n diff_files INTEGER,\n diff_at TEXT\n);\nCREATE TABLE IF NOT EXISTS agent_question (\n id INTEGER PRIMARY KEY,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n attempt_key TEXT NOT NULL,\n key TEXT NOT NULL,\n question TEXT NOT NULL,\n asked_at TEXT NOT NULL,\n closed_at TEXT,\n closed_how TEXT CHECK (closed_how IN ('answered','ended')),\n answer_note INTEGER REFERENCES task_steer(id),\n answered_by TEXT,\n UNIQUE (run, key)\n);\nCREATE INDEX IF NOT EXISTS agent_question_open ON agent_question (task_ref) WHERE closed_at IS NULL;\nCREATE TABLE IF NOT EXISTS agent_seen (\n account TEXT NOT NULL,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n seen_at TEXT NOT NULL,\n PRIMARY KEY (account, run)\n);\nCREATE TABLE IF NOT EXISTS agent_view (\n view TEXT PRIMARY KEY,\n account TEXT NOT NULL,\n run INTEGER NOT NULL,\n expires_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS agent_view_run ON agent_view (run, account);\nCREATE INDEX IF NOT EXISTS agent_board_open ON run (task_ref, id) WHERE role = 'builder' AND outcome IS NULL;\nCREATE INDEX IF NOT EXISTS agent_board_recent ON run (finished_at, task_ref, id) WHERE role = 'builder' AND outcome IS NOT NULL;\nCREATE INDEX IF NOT EXISTS agent_board_repair ON run (parent_run, id) WHERE role = 'repair';\n";
26
24
  /** Added to agent_now after the table first shipped (store.ts addColumn): the oldest open tool call's kind and start. */
27
25
  export declare const AGENT_NOW_COLUMNS: readonly [string, string][];
@@ -194,15 +192,30 @@ export declare function releaseAgentView(store: Store, view: string, at: Date):
194
192
  export declare function watchingAgentUntil(store: Store, account: string, run: number, at: Date): string | null;
195
193
  /** Someone has this exact run open right now. */
196
194
  export declare function watchingAgent(store: Store, account: string, run: number, at: Date): boolean;
195
+ export declare const neverHeld: (kind: string) => boolean;
197
196
  /**
198
- * A watched ping, decided when its delivery is claimed (every chat, phone and browser asks here, after the
199
- * permanent rules in store.pingAllowed): held until the watch lapses while this person has the run open; then
200
- * skipped when they have already seen the finished run (an answered question is resolved, so never claimed);
201
- * otherwise sent. Anything that is not a watched kind about a run is sent.
197
+ * A notification's subject (cockpit §3, R11): the run it is about — its own, or for a ping about a task the task's
198
+ * live build — so any ping, not only the agent's own kinds, can be held while its person watches that agent. Null:
199
+ * about nothing a person watches. Its version is the notification's own identity (its dedupe key), so a newer fact
200
+ * about the same subject is a new ping.
201
+ */
202
+ export declare function attentionSubjectOf(store: Store, row: {
203
+ kind: string;
204
+ run: number | null;
205
+ taskRef?: number | null;
206
+ }): {
207
+ run: number;
208
+ } | null;
209
+ /**
210
+ * A watched ping (cockpit §3, R11), decided when its delivery is claimed AND again at every channel's final send
211
+ * fence, after the permanent rules in store.pingAllowed: held until the watch lapses while this person has its
212
+ * subject's agent open; then skipped when it was news about a finished run they have since seen; otherwise sent.
213
+ * A ping about nothing anyone watches is sent. (A delivery already handed to the network can't be recalled.)
202
214
  */
203
215
  export declare function watchedPing(store: Store, row: {
204
216
  kind: string;
205
217
  run: number | null;
218
+ taskRef?: number | null;
206
219
  }, account: string, now: Date): {
207
220
  hold: string;
208
221
  } | "skip" | "send";
@@ -46,8 +46,6 @@ export const QUESTIONS_PER_ATTEMPT = 10;
46
46
  export const QUESTION_PAGE_EVERY_MS = 5 * 60_000;
47
47
  /** The board's one note on a run whose agent asked past the caps (set once). */
48
48
  export const QUESTIONS_OVERFLOW_NOTE = "The agent asked more questions than the board takes; see its transcript.";
49
- /** Notification kinds held while the person watches the run, and re-checked when the watch lapses. */
50
- export const WATCHED_KINDS = new Set(["agent-question", "run-finished", "build-failed"]);
51
49
  export const AGENT_BOARD_SCHEMA = `
52
50
  CREATE TABLE IF NOT EXISTS agent_turn (
53
51
  id INTEGER PRIMARY KEY,
@@ -435,19 +433,42 @@ export function watchingAgentUntil(store, account, run, at) {
435
433
  export function watchingAgent(store, account, run, at) {
436
434
  return watchingAgentUntil(store, account, run, at) !== null;
437
435
  }
436
+ /** Kinds no watch ever holds: a security alert and the evening digest reach people as they are (store.pingAllowed).
437
+ * Nothing held may delay them either: a channel's ordering never keeps one behind a held ping (neverHeld). */
438
+ const NEVER_HELD = new Set(["secret-detected", "security-release", "evening-digest"]);
439
+ export const neverHeld = (kind) => NEVER_HELD.has(kind);
440
+ /** Kinds that are news only until the finished run is seen: a ping about one already opened is not sent at all. */
441
+ const SEEN_SETTLES = new Set(["run-finished", "build-failed"]);
438
442
  /**
439
- * A watched ping, decided when its delivery is claimed (every chat, phone and browser asks here, after the
440
- * permanent rules in store.pingAllowed): held until the watch lapses while this person has the run open; then
441
- * skipped when they have already seen the finished run (an answered question is resolved, so never claimed);
442
- * otherwise sent. Anything that is not a watched kind about a run is sent.
443
+ * A notification's subject (cockpit §3, R11): the run it is about — its own, or for a ping about a task the task's
444
+ * live build — so any ping, not only the agent's own kinds, can be held while its person watches that agent. Null:
445
+ * about nothing a person watches. Its version is the notification's own identity (its dedupe key), so a newer fact
446
+ * about the same subject is a new ping.
447
+ */
448
+ export function attentionSubjectOf(store, row) {
449
+ if (NEVER_HELD.has(row.kind))
450
+ return null;
451
+ if (row.run !== null)
452
+ return { run: row.run };
453
+ if (row.taskRef === undefined || row.taskRef === null)
454
+ return null;
455
+ const live = store.handle.prepare("SELECT id FROM run WHERE task_ref = ? AND role = 'builder' AND outcome IS NULL ORDER BY id DESC LIMIT 1").get(row.taskRef);
456
+ return live === undefined ? null : { run: Number(live["id"]) };
457
+ }
458
+ /**
459
+ * A watched ping (cockpit §3, R11), decided when its delivery is claimed AND again at every channel's final send
460
+ * fence, after the permanent rules in store.pingAllowed: held until the watch lapses while this person has its
461
+ * subject's agent open; then skipped when it was news about a finished run they have since seen; otherwise sent.
462
+ * A ping about nothing anyone watches is sent. (A delivery already handed to the network can't be recalled.)
443
463
  */
444
464
  export function watchedPing(store, row, account, now) {
445
- if (row.run === null || !WATCHED_KINDS.has(row.kind))
465
+ const subject = attentionSubjectOf(store, row);
466
+ if (subject === null)
446
467
  return "send";
447
- const until = watchingAgentUntil(store, account, row.run, now);
468
+ const until = watchingAgentUntil(store, account, subject.run, now);
448
469
  if (until !== null)
449
470
  return { hold: until };
450
- if (row.kind === "agent-question")
471
+ if (!SEEN_SETTLES.has(row.kind) || row.run === null)
451
472
  return "send";
452
473
  const seen = store.handle.prepare("SELECT 1 AS hit FROM agent_seen WHERE account = ? AND run = ?").get(account, boardRunOf(store, row.run));
453
474
  return seen === undefined ? "send" : "skip";
@@ -1,3 +1,6 @@
1
+ import type { LoopbackMode } from "./egress.js";
2
+ /** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
3
+ export declare const BRIDGE_DIR = "bridge-secrets";
1
4
  /** How a launch was fenced, as recorded on the run and said on the task. */
2
5
  export type FenceMethod = "codex-profile" | "macos-sandbox" | "linux-bubblewrap" | "claude-rules" | "none";
3
6
  /** Only the plane's recorded repository may supply an own-Git exemption. */
@@ -55,8 +58,9 @@ export type RunFolders = {
55
58
  root: string;
56
59
  own: string | null;
57
60
  };
58
- /** The prefixes of every launch's private folders: its tools' config and a Codex build's own home. */
59
- export declare const RUN_FOLDER_PREFIXES: readonly ["so-tools-", "so-codex-"];
61
+ /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
62
+ * network bridge (egress-bridge.ts). */
63
+ export declare const RUN_FOLDER_PREFIXES: readonly ["so-tools-", "so-codex-", "so-net-"];
60
64
  export declare function runFolders(own: string | null, root?: string): RunFolders;
61
65
  /**
62
66
  * What a fence gives back beside its denied paths. Each is narrow and none
@@ -75,7 +79,40 @@ export type FenceGrants = {
75
79
  lookup: string[];
76
80
  runFolders?: RunFolders;
77
81
  pool?: CheckoutPool;
82
+ network?: NetworkFence;
78
83
  };
84
+ /**
85
+ * A launch's network confinement (egress.ts, docs/plans/agent-baseline-hardening.md arc 1). `open`: the network as it
86
+ * always was. `allowlist`: nothing leaves the machine but through the run's own proxy (`proxyPort`); the run's tool
87
+ * broker (`brokerPorts`) stays reachable; on macOS this Mac's other local services are reachable only under
88
+ * `loopback: "any"` (all of them: Seatbelt can't refuse one local port once it admits the rest); unix sockets only
89
+ * under `socketRoots` (the run's own temp folder and checkout). On Linux the agent gets a network of its own and
90
+ * reaches the proxy and broker through `bridge` (a folder of unix sockets the plane serves, and the forwarder that
91
+ * carries them onto the namespace's own loopback).
92
+ */
93
+ export type NetworkFence = {
94
+ mode: "open";
95
+ } | {
96
+ mode: "allowlist";
97
+ proxyPort: number;
98
+ brokerPorts: number[];
99
+ loopback: LoopbackMode;
100
+ socketRoots: string[];
101
+ bridge?: {
102
+ folder: string;
103
+ forwarder: string;
104
+ node: string;
105
+ };
106
+ /** Linux: the run's own folders a private /tmp gives back (its checkout, its launch folder), when they are there. */
107
+ keep?: string[];
108
+ };
109
+ /**
110
+ * Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
111
+ * Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
112
+ * (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
113
+ * confined; the run's proxy resolves names for it.
114
+ */
115
+ export declare const DELEGATION_SERVICES: readonly string[];
79
116
  export declare const NO_GRANTS: FenceGrants;
80
117
  /**
81
118
  * The plane's checkout pools, denied whole: every project's worktrees, and any made there after launch. `agentFence`
@@ -187,6 +224,14 @@ export declare function ownedAncestors(paths: readonly string[], roots?: readonl
187
224
  * checkout given back right after, so every later deny still holds inside it.
188
225
  */
189
226
  export declare function macosFenceProfile(fence: readonly string[], grants?: FenceGrants): string;
227
+ /**
228
+ * The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
229
+ * allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
230
+ * allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
231
+ * or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
232
+ * connection.
233
+ */
234
+ export declare function macosNetworkRules(network: NetworkFence | undefined): string[];
190
235
  export declare const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
191
236
  /**
192
237
  * Whether this machine can run an agent inside the macOS fence (checked once). Not from inside another
@@ -213,3 +258,6 @@ export declare function linuxFenced(file: string, args: readonly string[], fence
213
258
  file: string;
214
259
  args: string[];
215
260
  };
261
+ /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
262
+ * engines), wherever they exist here. */
263
+ export declare function linuxHostSockets(uid?: number | undefined, home?: string, tmp?: string): string[];
@@ -40,13 +40,17 @@
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 { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
45
47
  import { homedir, tmpdir } from "node:os";
46
48
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
47
49
  import { readMonitoring } from "./monitoring-settings.js";
48
50
  import { existingFolders } from "./names.js";
49
51
  import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
52
+ /** Beside the database: where Linux builds' network bridges are made (its name reads as secret wherever it is). */
53
+ export const BRIDGE_DIR = "bridge-secrets";
50
54
  /** Names that are secrets wherever the database lives (used when its folder is shared with other things). */
51
55
  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
56
  function real(path) {
@@ -127,6 +131,10 @@ export function trustedWorktreeBinding(repository, worktree, poolRoots = []) {
127
131
  */
128
132
  export function agentFence(options) {
129
133
  const fence = new Set();
134
+ /** The console's run folder: kept by its own pathname through canonicalization (and its target's too). */
135
+ let runFolder = null;
136
+ /** The bridges' folder, likewise. */
137
+ let bridgeFolder = null;
130
138
  const home = real(options.home ?? homedir());
131
139
  const worktree = options.worktree === null ? null : real(options.worktree);
132
140
  if (options.databaseFile !== null && options.databaseFile !== "" && options.databaseFile !== ":memory:") {
@@ -135,6 +143,45 @@ export function agentFence(options) {
135
143
  for (const suffix of ["", "-wal", "-shm", "-journal"])
136
144
  if (existsSync(database + suffix))
137
145
  fence.add(database + suffix);
146
+ // The console's run folder (its discovery sockets, console-instance.ts) beside the database, wherever that is: no
147
+ // build may add, rename or remove anything in it while a console listens there. It is made (owner-only) before any
148
+ // 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
149
+ // real folder to cover (a Linux bind needs one).
150
+ // Whatever is at that path is fenced, always — a folder, a file, a link (the path itself, never only where a link
151
+ // points), a folder holding anything at all — so no build can replace it; when nothing is there it is made first.
152
+ // If it can't be made, the path is fenced anyway: a sandbox that can't cover a missing path refuses to launch
153
+ // rather than run unfenced. The name is Toolroll's own; nothing else belongs there.
154
+ // The folder Linux builds' network bridges are made in (egress-bridge.ts): made before any build starts and fenced
155
+ // from every build, so no build — however long it runs — can reach a bridge made for a later one.
156
+ const bridges = join(state, BRIDGE_DIR);
157
+ try {
158
+ lstatSync(bridges);
159
+ }
160
+ catch {
161
+ try {
162
+ mkdirSync(bridges, { mode: 0o700 });
163
+ }
164
+ catch { /* fenced below either way */ }
165
+ }
166
+ // Fenced by its own name as well as where it leads: a link planted there is itself out of reach (and the bridge
167
+ // refuses a root that isn't a private folder: egress-bridge.ts).
168
+ bridgeFolder = bridges;
169
+ fence.add(bridges);
170
+ runFolder = join(state, INSTANCE_DIR);
171
+ const present = () => { try {
172
+ lstatSync(runFolder);
173
+ return true;
174
+ }
175
+ catch {
176
+ return false;
177
+ } };
178
+ if (!present()) {
179
+ try {
180
+ mkdirSync(runFolder, { mode: 0o700 });
181
+ }
182
+ catch { /* fenced below either way */ }
183
+ }
184
+ fence.add(runFolder);
138
185
  // Only a folder that is Toolroll's own (~/.config/toolroll or an older name,
139
186
  // the desktop app's "Toolroll") is fenced whole: a database kept
140
187
  // in a shared folder (a projects folder, a test's temp folder) must not
@@ -179,12 +226,12 @@ export function agentFence(options) {
179
226
  fence.add(path);
180
227
  for (const path of options.boundary?.protectedShell ?? [])
181
228
  fence.add(real(path));
182
- const protectedPaths = new Set([...fence].map(real));
229
+ const protectedPaths = new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]));
183
230
  for (const path of options.boundary?.shell ?? [])
184
231
  fence.add(path);
185
232
  const binding = options.binding;
186
233
  const needed = binding === undefined || binding === null ? [] : [binding.gitdir, real(join(binding.main, ".git"))];
187
- return [...new Set([...fence].map(real))].filter(path => {
234
+ return [...new Set([...fence].flatMap(path => path === runFolder || path === bridgeFolder ? [path, real(path)] : [real(path)]))].filter(path => {
188
235
  if (protectedPaths.has(path) || !needed.some(target => target === path || inside(target, path)))
189
236
  return true;
190
237
  console.warn(`Dropped build fence entry covering this checkout's own Git data: ${path}`);
@@ -197,10 +244,18 @@ export function agentFence(options) {
197
244
  * cloud logins or another agent's sign-in, or rewrite their own Claude or
198
245
  * Codex setup for the next interactive session.
199
246
  */
200
- const HOME_CONFIGURATION = [".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json")];
247
+ const HOME_CONFIGURATION = [
248
+ ".claude", ".claude.json", ".codex", ".gemini", ".ssh", ".aws", ".gnupg", ".netrc", ".git-credentials", ".kube", join(".config", "gh"), join(".docker", "config.json"),
249
+ // Every ACP agent's own configuration and sign-in (acp-agents.ts): fenced from every build but that agent's own.
250
+ ...ACP_PROVIDER_IDS.flatMap(id => ACP_AGENTS[id].home),
251
+ ];
201
252
  /** What a provider's own CLI reads to sign in and keep its sessions. The OS fence around Claude and Gemini holds
202
253
  * 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: [] };
254
+ const PROVIDER_HOME = {
255
+ claude: [".claude", ".claude.json"], gemini: [".gemini"], codex: [], openrouter: [],
256
+ // Each ACP agent keeps its own configuration and sign-in where its row says (acp-agents.ts).
257
+ ...Object.fromEntries(ACP_PROVIDER_IDS.map(id => [id, ACP_AGENTS[id].home])),
258
+ };
204
259
  /** What Claude's own file tools refuse inside its home: the CLI reads these itself, its model never needs to. */
205
260
  const CLAUDE_FILE_TOOL_HOME = [".claude.json", join(".claude", ".credentials.json"), join(".claude", "settings.json"), join(".claude", "settings.local.json"), ".npmrc"];
206
261
  /**
@@ -311,8 +366,9 @@ export function taskBoundary(options) {
311
366
  const fileTools = options.provider === "claude" ? present(CLAUDE_FILE_TOOL_HOME).filter(path => !shell.has(path)) : [];
312
367
  return { protectedShell, shell: [...shell].sort(), fileTools: fileTools.sort(), readOnly: [...readOnly].sort(), lookup: [...lookup].sort() };
313
368
  }
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-"];
369
+ /** The prefixes of every launch's private folders: its tools' config, a Codex build's own home, and a Linux build's
370
+ * network bridge (egress-bridge.ts). */
371
+ export const RUN_FOLDER_PREFIXES = ["so-tools-", "so-codex-", "so-net-"];
316
372
  export function runFolders(own, root = tmpdir()) {
317
373
  return { root: real(root), own: own === null ? null : real(own) };
318
374
  }
@@ -327,6 +383,17 @@ function otherRunFolders(folders) {
327
383
  }
328
384
  return entries.filter(entry => RUN_FOLDER_PREFIXES.some(prefix => entry.startsWith(prefix))).map(entry => join(folders.root, entry)).filter(path => path !== folders.own);
329
385
  }
386
+ /**
387
+ * Services a sandboxed process could ask to act outside the sandbox for it (macOS): open a URL in a browser (Launch
388
+ * Services), script another application (Apple Events), fetch in the background (nsurlsessiond), look a name up
389
+ * (the DNS service: a lookup is itself a message out), or read and write the clipboard. Denied when the network is
390
+ * confined; the run's proxy resolves names for it.
391
+ */
392
+ export const DELEGATION_SERVICES = [
393
+ "com.apple.coreservices.launchservicesd", "com.apple.coreservices.quarantine-resolver", "com.apple.lsd.mapdb", "com.apple.lsd.modifydb", "com.apple.lsd.open",
394
+ "com.apple.coreservices.appleevents", "com.apple.nsurlsessiond", "com.apple.nsurlsessiond.NSURLSessionProxyService",
395
+ "com.apple.dnssd.service", "com.apple.mDNSResponder", "com.apple.pasteboard.1",
396
+ ];
330
397
  export const NO_GRANTS = { readOnly: [], readable: [], lookup: [] };
331
398
  /**
332
399
  * The pool deny for one launch. `roots` come from the plane's own records (the pool beside the database, and the one
@@ -471,14 +538,22 @@ export function codexFenceArgv(fence, fullAccess, grants = NO_GRANTS) {
471
538
  for (const path of held.write)
472
539
  access.set(path, "write");
473
540
  }
474
- for (const path of deny)
541
+ // Denies are keyed where they resolve; the console's run folder is also denied by its own pathname, so a link there
542
+ // stays unreplaceable, not only its target.
543
+ for (const path of deny) {
475
544
  access.set(real(path), "deny");
545
+ if (basename(path) === INSTANCE_DIR)
546
+ access.set(path, "deny");
547
+ }
476
548
  const filesystem = [...access].map(([path, mode]) => `${toml(path)}=${toml(mode)}`);
477
549
  return [
478
550
  "-c", 'default_permissions="standing-orders"',
479
551
  "-c", 'permissions.standing-orders.extends=":workspace"',
480
552
  "-c", `permissions.standing-orders.filesystem={${filesystem.join(",")}}`,
481
- ...(fullAccess ? ["-c", "permissions.standing-orders.network.enabled=true", "-c", 'approval_policy="never"'] : []),
553
+ // Codex's command sandbox can't route through a proxy: its commands reach the network only when the run's sealed
554
+ // network is open (and Full access asked for it), and are offline otherwise.
555
+ ...(fullAccess && (grants.network === undefined || grants.network.mode === "open") ? ["-c", "permissions.standing-orders.network.enabled=true"] : []),
556
+ ...(fullAccess ? ["-c", 'approval_policy="never"'] : []),
482
557
  ];
483
558
  }
484
559
  /**
@@ -644,8 +719,27 @@ export function macosFenceProfile(fence, grants = NO_GRANTS) {
644
719
  "(deny mach-priv-task-port)",
645
720
  "(deny mach-task-name)",
646
721
  ...(above.length === 0 ? [] : [`(deny file-write* ${paths("literal", above)})`]),
722
+ ...macosNetworkRules(grants.network),
647
723
  ].join("\n");
648
724
  }
725
+ /**
726
+ * The network half of the macOS fence. Every outbound connection is denied, then loopback is given back as the launch
727
+ * allows: every local port under `loopback: "any"`, else the run's proxy and broker alone. (Seatbelt admits a port an
728
+ * allow names whatever a deny after it says, so a local port can't be refused once all are admitted: the choice is all
729
+ * or none, plus the run's own.) Filters name the remote end only: a `local ip` filter would admit any outbound
730
+ * connection.
731
+ */
732
+ export function macosNetworkRules(network) {
733
+ if (network === undefined || network.mode === "open")
734
+ return [];
735
+ const local = (port) => `(remote ip "localhost:${port}")`;
736
+ return [
737
+ "(deny network-outbound)",
738
+ `(allow network-outbound ${network.loopback === "any" ? local("*") : [network.proxyPort, ...network.brokerPorts].map(local).join(" ")})`,
739
+ ...(network.socketRoots.length === 0 ? [] : [`(allow network-outbound ${network.socketRoots.map(root => `(remote unix-socket (subpath ${seatbelt(root)}))`).join(" ")})`]),
740
+ `(deny mach-lookup ${DELEGATION_SERVICES.map(name => `(global-name ${seatbelt(name)})`).join(" ")})`,
741
+ ];
742
+ }
649
743
  export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
650
744
  /** 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
745
  const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
@@ -719,6 +813,9 @@ function linuxGrants(fence, grants) {
719
813
  * folder, or /dev/null for a file), so what is made there after launch is masked too; then only the explicit grants
720
814
  * bound back read-only (bubblewrap binds from the original root, so a grant inside a mask still reaches its files). */
721
815
  export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
816
+ const network = grants.network?.mode === "allowlist" ? grants.network : null;
817
+ if (network !== null && network.bridge === undefined)
818
+ throw new Error("A confined network on Linux needs its bridge");
722
819
  const mask = (path) => {
723
820
  let folder = true;
724
821
  try {
@@ -732,5 +829,55 @@ export function linuxFenced(file, args, fence, grants = NO_GRANTS) {
732
829
  const pool = grants.pool?.deny ?? [];
733
830
  const own = grants.pool?.own != null && existsSync(grants.pool.own) ? ["--bind", grants.pool.own, grants.pool.own] : [];
734
831
  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] };
832
+ // A confined network: a network and a process view of its own, the host's sockets out of reach (a socket in a
833
+ // mounted folder is not network, so they are masked), and the run's proxy and broker carried in by the forwarder.
834
+ const isolated = network === null ? [] : [
835
+ "--unshare-net", "--unshare-pid", "--proc", "/proc",
836
+ ...linuxHostSockets().flatMap(mask),
837
+ "--ro-bind", network.bridge.folder, network.bridge.folder,
838
+ ];
839
+ // A confined build gets a /tmp of its own (other programs' sockets, other runs' folders and anything planted there
840
+ // all out of sight at once), with only its own folders there given back; every mask and grant below still applies.
841
+ const privateTmp = network === null ? [] : ["--tmpfs", "/tmp", ...(network.keep ?? []).filter(path => inside(path, "/tmp") && existsSync(path)).flatMap(path => ["--bind", path, path])];
842
+ const command = network === null ? [file, ...args]
843
+ : [network.bridge.node, network.bridge.forwarder, network.bridge.folder, ...[network.proxyPort, ...network.brokerPorts].map(String), "--", file, ...args];
844
+ return { file: "bwrap", args: ["--dev-bind", "/", "/", ...privateTmp, ...pool.flatMap(mask), ...own, ...fence.flatMap(mask), ...kept, ...isolated, "--die-with-parent", "--", ...command] };
845
+ }
846
+ /** The host's own sockets a process in a mounted filesystem could still talk to (D-Bus, the display, container
847
+ * engines), wherever they exist here. */
848
+ export function linuxHostSockets(uid = process.getuid?.(), home = homedir(), tmp = "/tmp") {
849
+ // The system's own, root-owned: resolved (/var/run is a link to /run) so bubblewrap mounts over the real path, once.
850
+ const system = [
851
+ ...(uid === undefined ? [] : [`/run/user/${uid}`]), "/run/dbus", "/var/run/dbus", "/var/run/docker.sock", "/run/docker.sock", "/run/containerd", "/run/podman",
852
+ "/var/run/podman", "/run/systemd/private", "/run/snapd.socket", "/run/screen",
853
+ ];
854
+ const masks = new Set();
855
+ for (const path of system) {
856
+ try {
857
+ masks.add(realpathSync(path));
858
+ }
859
+ catch { /* not here */ }
860
+ }
861
+ // The user's own, in places a build could plant names: taken only as the real folders or sockets they are, never
862
+ // through a link (a planted link would turn a mask onto any folder at all). Container engines
863
+ // run per user (Docker Desktop and the docker CLI's folder whole, so a socket made there later is covered too;
864
+ // Colima, OrbStack, Lima, Rancher Desktop, rootless containers); a terminal multiplexer takes commands from its
865
+ // socket; an SSH agent signs as you; editors keep IPC sockets in /tmp (hidden by the build's private /tmp).
866
+ // Masked whoever owns it (a folder `sudo docker` made is root's): only a link is never followed, since a link turns
867
+ // a mask onto its target. A link there is a residual until builds can't write the home folder (arc 2).
868
+ const owned = (path) => {
869
+ try {
870
+ const held = lstatSync(path);
871
+ return !held.isSymbolicLink() && (held.isDirectory() || held.isSocket());
872
+ }
873
+ catch {
874
+ return false;
875
+ }
876
+ };
877
+ for (const path of [".docker", ".colima", ".orbstack", ".lima", ".rd", ".local/share/containers"].map(name => join(home, name)))
878
+ if (owned(path))
879
+ masks.add(path);
880
+ // /tmp's sockets (the display's, an SSH agent's, a multiplexer's, editors') go with the build's private /tmp.
881
+ void tmp;
882
+ return [...masks].sort();
736
883
  }
@@ -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
+ };