agentbox-app 0.1.11 → 0.1.13

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 (239) hide show
  1. package/README.md +24 -16
  2. package/bin/agentbox.mjs +18 -9
  3. package/briefs/message-rules.md +15 -4
  4. package/main/account-tooling.mjs +2 -1
  5. package/main/acp.mjs +296 -0
  6. package/main/agent-homes.mjs +158 -0
  7. package/main/agent-sessions.mjs +57 -20
  8. package/main/agent-updates.mjs +9 -20
  9. package/main/analytics.mjs +30 -0
  10. package/main/approvals.mjs +14 -5
  11. package/main/claude-bin.mjs +45 -310
  12. package/main/claude-input.mjs +9 -3
  13. package/main/claude-launch-env.mjs +54 -0
  14. package/main/claude-models.mjs +39 -7
  15. package/main/claude-plan.mjs +14 -2
  16. package/main/claude-usage.mjs +82 -25
  17. package/main/codex-bin.mjs +51 -248
  18. package/main/codex-launch-env.mjs +31 -0
  19. package/main/codex-session.mjs +38 -3
  20. package/main/codex-threads.mjs +41 -8
  21. package/main/codex-watch.mjs +67 -3
  22. package/main/config.mjs +40 -13
  23. package/main/corner-tag.mjs +489 -0
  24. package/main/crash-report.mjs +26 -3
  25. package/main/demo.mjs +17 -14
  26. package/main/engine-bin.mjs +595 -0
  27. package/main/engine-setup.mjs +27 -27
  28. package/main/feedback.mjs +11 -6
  29. package/main/fresh-user.mjs +1 -1
  30. package/main/git-change.mjs +8 -2
  31. package/main/harnesses/claude-stream.mjs +208 -0
  32. package/main/harnesses/claude.mjs +84 -0
  33. package/main/harnesses/codex.mjs +65 -0
  34. package/main/harnesses/common.mjs +78 -0
  35. package/main/harnesses/instructions-file.mjs +116 -0
  36. package/main/harnesses/opencode.mjs +234 -0
  37. package/main/harnesses.mjs +65 -0
  38. package/main/importable-threads.mjs +38 -0
  39. package/main/instruction-settings.mjs +47 -11
  40. package/main/ipc.mjs +175 -22
  41. package/main/local-app-frames.mjs +82 -0
  42. package/main/machine.mjs +1 -1
  43. package/main/main.mjs +120 -5
  44. package/main/memory-gate-history.mjs +51 -12
  45. package/main/memory-gate-server.mjs +67 -11
  46. package/main/memory-gate.mjs +49 -8
  47. package/main/memory-log.mjs +124 -0
  48. package/main/notify.mjs +48 -5
  49. package/main/opencode-server.mjs +271 -0
  50. package/main/opencode.mjs +287 -0
  51. package/main/phone-link.mjs +551 -0
  52. package/main/refresh-agent-models.mjs +2 -1
  53. package/main/relay-address.mjs +28 -0
  54. package/main/relay-client.mjs +246 -0
  55. package/main/row-label.mjs +8 -7
  56. package/main/runs-on.mjs +90 -0
  57. package/main/serve.mjs +124 -10
  58. package/main/settings.mjs +171 -11
  59. package/main/shell-path.mjs +86 -0
  60. package/main/ship-queue.mjs +25 -7
  61. package/main/sign-in-files.mjs +3 -7
  62. package/main/source-updater.mjs +27 -1
  63. package/main/start-hold.mjs +73 -0
  64. package/main/store/home.mjs +37 -5
  65. package/main/store/project.mjs +15 -19
  66. package/main/store.mjs +10 -4
  67. package/main/supervisor.mjs +1030 -447
  68. package/main/task-commands.mjs +13 -17
  69. package/main/task-folders-offthread.mjs +6 -1
  70. package/main/task-folders.mjs +491 -82
  71. package/main/task-terminals.mjs +49 -8
  72. package/main/team/index.mjs +2 -2
  73. package/main/team/projects.mjs +1 -1
  74. package/main/updater.mjs +71 -9
  75. package/main/worktree-include.mjs +494 -0
  76. package/mcp/core/page.mjs +1 -1
  77. package/mcp/core/work.mjs +34 -3
  78. package/mcp/tools.mjs +2 -1
  79. package/package.json +10 -1
  80. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  81. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  82. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  83. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  84. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  85. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  86. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  87. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  88. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  89. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  90. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  91. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  92. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  93. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  94. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  95. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  96. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  97. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  98. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  99. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  100. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  101. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  102. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  103. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  104. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  105. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  106. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  107. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  108. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  109. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  110. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  111. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  112. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  113. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  114. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  115. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  116. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  117. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  118. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  119. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  120. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  121. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  122. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  123. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  124. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  125. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  126. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  127. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  128. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  129. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  130. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  131. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  132. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  133. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  134. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  135. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  136. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  137. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  138. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  139. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  140. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  141. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  142. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  143. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  144. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  145. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  146. package/renderer/dist/corner-tag.html +13 -0
  147. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  148. package/renderer/dist/emulator/data/compression/README.md +11 -0
  149. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  150. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  151. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  152. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  153. package/renderer/dist/emulator/data/cores/README.md +22 -0
  154. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  155. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  156. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  157. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  158. package/renderer/dist/emulator/data/cores/package.json +65 -0
  159. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  160. package/renderer/dist/emulator/data/emulator.css +1614 -0
  161. package/renderer/dist/emulator/data/loader.js +170 -0
  162. package/renderer/dist/emulator/data/localization/README.md +63 -0
  163. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  164. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  165. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  166. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  167. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  168. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  169. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  170. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  171. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  172. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  173. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  174. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  175. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  176. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  177. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  178. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  179. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  180. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  181. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  182. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  183. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  184. package/renderer/dist/emulator/data/src/compression.js +142 -0
  185. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  186. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  187. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  188. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  189. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  190. package/renderer/dist/emulator/data/src/storage.js +125 -0
  191. package/renderer/dist/emulator/data/version.json +5 -0
  192. package/renderer/dist/emulator/host.js +297 -0
  193. package/renderer/dist/emulator/index.html +28 -0
  194. package/renderer/dist/emulator/provenance.json +6 -0
  195. package/renderer/dist/index.html +16 -3
  196. package/scripts/after-install.mjs +14 -0
  197. package/scripts/approval-server.sh +31 -7
  198. package/scripts/before-publish.mjs +12 -10
  199. package/shared/agent-import.mjs +3 -1
  200. package/shared/agents.d.mts +1 -0
  201. package/shared/bridge-map.mjs +19 -0
  202. package/shared/claude-commands.generated.mjs +8 -6
  203. package/shared/claude-models.generated.mjs +10 -8
  204. package/shared/codex-modes.mjs +1 -1
  205. package/shared/corner-tag.d.mts +14 -0
  206. package/shared/corner-tag.mjs +141 -0
  207. package/shared/demo-world.mjs +1 -1
  208. package/shared/engines.mjs +20 -5
  209. package/shared/fresh-user-home.mjs +12 -1
  210. package/shared/harness-definitions.mjs +103 -0
  211. package/shared/instruction-defaults.json +1 -1
  212. package/shared/notify-rules.d.mts +25 -0
  213. package/shared/notify-rules.mjs +19 -0
  214. package/shared/project-folder-check.mjs +3 -3
  215. package/shared/provider-commands.mjs +10 -2
  216. package/shared/rank.d.mts +2 -0
  217. package/shared/rank.mjs +50 -0
  218. package/shared/relay-address.mjs +6 -0
  219. package/shared/relay-crypto.mjs +165 -0
  220. package/shared/runs-on.d.mts +10 -0
  221. package/shared/runs-on.mjs +55 -0
  222. package/shared/shortcuts.mjs +11 -5
  223. package/shared/spawn-trouble.mjs +58 -7
  224. package/shared/terminal-input.d.mts +1 -0
  225. package/shared/terminal-input.mjs +30 -0
  226. package/shared/terminal-keys.d.mts +1 -0
  227. package/shared/terminal-keys.mjs +16 -0
  228. package/shared/terminal-size.d.mts +4 -0
  229. package/shared/terminal-size.mjs +29 -0
  230. package/shared/usage.d.mts +3 -0
  231. package/shared/usage.mjs +7 -0
  232. package/shared/where-it-runs-from.d.mts +43 -0
  233. package/shared/where-it-runs-from.mjs +155 -0
  234. package/shared/work-items.mjs +28 -2
  235. package/shared/work-lines.mjs +1 -1
  236. package/shared/xterm-invalid-range.d.mts +2 -0
  237. package/shared/xterm-invalid-range.mjs +58 -0
  238. package/renderer/dist/assets/index-B-2Az952.css +0 -32
  239. package/renderer/dist/assets/index-D6qncgai.js +0 -1168
@@ -0,0 +1,246 @@
1
+ // THE MAC'S END OF THE RELAY (relay/worker.mjs is the middle, relay/phone/ the
2
+ // far end). With phones let in from "Anywhere", the Mac holds one socket open
3
+ // out to the relay, so nothing has to reach in to it: no port opened, no
4
+ // router touched, nothing installed on the phone.
5
+ //
6
+ // Each request that arrives is opened with the key only the Mac and the phone
7
+ // have (shared/relay-crypto.mjs), checked for a replay, and then asked of this
8
+ // Mac's own phone server on 127.0.0.1, exactly as a phone on the Wi-Fi would
9
+ // ask it. So the token, the files and every channel are guarded by the same
10
+ // code either way. The answer goes back sealed, a frame at a time, so the
11
+ // live event stream and a large picture both work.
12
+ import {
13
+ relayIds, relayKeys, open, seal, unpackRequest, FRAME, frameAad, TO_MAC, FROM_MAC, ID_BYTES, tag,
14
+ } from '../shared/relay-crypto.mjs';
15
+
16
+ /** How far a phone's clock may be from this Mac's before a request is stale. */
17
+ const SKEW_MS = 5 * 60_000;
18
+ /** One sealed frame's most. Cloudflare takes up to 1MB per socket message. */
19
+ const CHUNK = 256 * 1024;
20
+ /** The most one request may carry, as the relay allows (relay/worker.mjs). */
21
+ const MAX_REQUEST = 32 * 1024 * 1024;
22
+ /** What a phone's request may carry on to the phone server. */
23
+ const PASS = ['accept', 'content-type', 'range', 'user-agent', 'if-none-match', 'x-agentbox-token', 'x-agentbox-device'];
24
+ /**
25
+ * How often a slow answer says "still working" (FRAME.WAIT), well inside the
26
+ * 30 s the room gives a Mac to start answering (relay/room.mjs).
27
+ */
28
+ const WAIT_MS = 10_000;
29
+ /** What the phone does not want back: the relay's stream is its own framing. */
30
+ const DROP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'content-length', 'content-encoding']);
31
+
32
+ const enc = new TextEncoder();
33
+ const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
34
+
35
+ export function createRelayClient({
36
+ relayUrl, key, port, WebSocketImpl = globalThis.WebSocket, fetchImpl = (...a) => globalThis.fetch(...a),
37
+ log = console, onChange = () => {}, waitMs = WAIT_MS,
38
+ }) {
39
+ let ws = null;
40
+ let want = false;
41
+ let connected = false;
42
+ let error = null;
43
+ let retry = 1000;
44
+ let timer = null;
45
+ let gen = 0;
46
+ let forKey = null;
47
+ let ids = null;
48
+ let keys = null;
49
+ /** Requests answered lately, by nonce, so a recorded one sent again is refused. */
50
+ const answered = new Map();
51
+ /** Answers being sent, so a phone that gives up stops them. */
52
+ const live = new Map();
53
+ /** Pieces of requests too big for one message, until their last piece. */
54
+ const parts = new Map();
55
+
56
+ async function ready() {
57
+ const k = key();
58
+ if (k === forKey) return;
59
+ ids = await relayIds(k);
60
+ keys = await relayKeys(k);
61
+ forKey = k;
62
+ answered.clear();
63
+ }
64
+
65
+ function fresh(head) {
66
+ const now = Date.now();
67
+ for (const [n, at] of answered) if (now - at > 2 * SKEW_MS) answered.delete(n);
68
+ if (typeof head?.nonce !== 'string' || head.nonce.length < 16) return false;
69
+ if (!Number.isFinite(head.ts) || Math.abs(now - head.ts) > SKEW_MS) return false;
70
+ if (answered.has(head.nonce)) return false;
71
+ answered.set(head.nonce, now);
72
+ return true;
73
+ }
74
+
75
+ const send = (sock, bytes) => { try { sock.send(bytes); } catch { /* the socket went; the phone hears from the relay */ } };
76
+
77
+ async function answer(sock, id, sealed) {
78
+ const k = keys;
79
+ let head;
80
+ let body;
81
+ try {
82
+ ({ head, body } = unpackRequest(await open(k.up, sealed, 'req')));
83
+ } catch {
84
+ send(sock, tag(FROM_MAC.REFUSED, id));
85
+ return;
86
+ }
87
+ if (!fresh(head) || typeof head.path !== 'string' || !head.path.startsWith('/')) {
88
+ send(sock, tag(FROM_MAC.REFUSED, id));
89
+ return;
90
+ }
91
+ const ctl = new AbortController();
92
+ const idh = hex(id);
93
+ live.set(idh, ctl);
94
+ let seq = 0;
95
+ // One at a time, in order: each frame's number is sealed into it, and a
96
+ // "still working" frame from the timer must not overtake the head.
97
+ let queue = Promise.resolve();
98
+ const frame = (type, payload) => (queue = queue.then(async () => {
99
+ const plain = new Uint8Array(1 + payload.length);
100
+ plain[0] = type;
101
+ plain.set(payload, 1);
102
+ send(sock, tag(FROM_MAC.FRAME, id, await seal(k.down, plain, frameAad(head.nonce, seq++))));
103
+ }));
104
+ // Until the answer starts, a phone that asked for it hears "still working".
105
+ let waiting = head.wait ? setInterval(() => { frame(FRAME.WAIT, new Uint8Array(0)).catch(() => {}); }, waitMs) : null;
106
+ const answering = () => { if (waiting) clearInterval(waiting); waiting = null; };
107
+ let sentHead = false;
108
+ try {
109
+ const headers = { 'x-agentbox-relay': '1', 'x-forwarded-for': 'relay' };
110
+ for (const name of PASS) if (head.headers?.[name]) headers[name] = String(head.headers[name]);
111
+ const method = String(head.method || 'GET').toUpperCase();
112
+ const res = await fetchImpl(`http://127.0.0.1:${port()}${head.path}`, {
113
+ method, headers, body: method === 'GET' || method === 'HEAD' ? undefined : body, signal: ctl.signal,
114
+ });
115
+ answering();
116
+ const out = {};
117
+ res.headers.forEach((v, name) => { if (!DROP.has(name)) out[name] = v; });
118
+ sentHead = true;
119
+ await frame(FRAME.HEAD, enc.encode(JSON.stringify({ status: res.status, headers: out })));
120
+ if (res.body) {
121
+ const reader = res.body.getReader();
122
+ for (;;) {
123
+ const { value, done } = await reader.read();
124
+ if (done) break;
125
+ for (let i = 0; i < value.length; i += CHUNK) await frame(FRAME.DATA, value.subarray(i, i + CHUNK));
126
+ }
127
+ }
128
+ await frame(FRAME.END, new Uint8Array(0));
129
+ send(sock, tag(FROM_MAC.DONE, id));
130
+ } catch (err) {
131
+ answering();
132
+ if (ctl.signal.aborted) return;
133
+ log.warn?.('[relay] answering failed:', err?.message ?? err);
134
+ try {
135
+ await queue.catch(() => {});
136
+ if (!sentHead) {
137
+ await frame(FRAME.HEAD, enc.encode(JSON.stringify({ status: 502, headers: { 'content-type': 'application/json' } })));
138
+ await frame(FRAME.DATA, enc.encode(JSON.stringify({ error: 'Your computer could not answer that.' })));
139
+ }
140
+ await frame(FRAME.END, new Uint8Array(0));
141
+ } catch { /* nothing more to say */ }
142
+ send(sock, tag(FROM_MAC.DONE, id));
143
+ } finally {
144
+ answering();
145
+ live.delete(idh);
146
+ }
147
+ }
148
+
149
+ async function onMessage(sock, data) {
150
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data)
151
+ : data instanceof Uint8Array ? data
152
+ : new Uint8Array(await data.arrayBuffer());
153
+ if (bytes.length < 1 + ID_BYTES) return;
154
+ const id = bytes.slice(1, 1 + ID_BYTES);
155
+ const idh = hex(id);
156
+ if (bytes[0] === TO_MAC.CANCEL) {
157
+ live.get(idh)?.abort();
158
+ live.delete(idh);
159
+ parts.delete(idh);
160
+ } else if (bytes[0] === TO_MAC.PART) {
161
+ // A big request comes in pieces (relay/room.mjs); keep them until the
162
+ // last, within a limit, so a stream of pieces cannot fill memory.
163
+ const held = parts.get(idh) ?? { chunks: [], size: 0 };
164
+ held.chunks.push(bytes.slice(1 + ID_BYTES));
165
+ held.size += bytes.length - 1 - ID_BYTES;
166
+ if (held.size > MAX_REQUEST) { parts.delete(idh); send(sock, tag(FROM_MAC.REFUSED, id)); return; }
167
+ parts.set(idh, held);
168
+ } else if (bytes[0] === TO_MAC.REQUEST) {
169
+ const held = parts.get(idh);
170
+ parts.delete(idh);
171
+ const last = bytes.slice(1 + ID_BYTES);
172
+ if (!held) { await answer(sock, id, last); return; }
173
+ const whole = new Uint8Array(held.size + last.length);
174
+ let at = 0;
175
+ for (const c of held.chunks) { whole.set(c, at); at += c.length; }
176
+ whole.set(last, at);
177
+ await answer(sock, id, whole);
178
+ }
179
+ }
180
+
181
+ function schedule() {
182
+ if (!want || timer) return;
183
+ timer = setTimeout(() => { timer = null; void connect(); }, retry);
184
+ retry = Math.min(retry * 2, 30_000);
185
+ }
186
+
187
+ async function connect() {
188
+ const mine = ++gen;
189
+ try { await ready(); } catch (err) { error = String(err?.message ?? err); onChange(); schedule(); return; }
190
+ if (!want || mine !== gen) return;
191
+ const url = `${relayUrl.replace(/^http/, 'ws').replace(/\/+$/, '')}/mac/${ids.room}?proof=${encodeURIComponent(ids.proof)}`;
192
+ let sock;
193
+ try { sock = new WebSocketImpl(url); } catch (err) { error = `Could not reach the relay: ${err?.message ?? err}`; onChange(); schedule(); return; }
194
+ ws = sock;
195
+ sock.binaryType = 'arraybuffer';
196
+ sock.onopen = () => {
197
+ if (ws !== sock) return;
198
+ connected = true;
199
+ error = null;
200
+ retry = 1000;
201
+ onChange();
202
+ };
203
+ sock.onmessage = (event) => { if (ws === sock) void onMessage(sock, event.data); };
204
+ sock.onerror = () => { if (ws === sock && !connected) error = 'Could not reach the relay.'; };
205
+ sock.onclose = (event) => {
206
+ if (ws !== sock) return;
207
+ ws = null;
208
+ const was = connected;
209
+ connected = false;
210
+ for (const c of live.values()) c.abort();
211
+ live.clear();
212
+ if (event?.code === 4001) error = 'The relay turned this computer away.';
213
+ else if (was) error = null;
214
+ onChange();
215
+ schedule();
216
+ };
217
+ }
218
+
219
+ return {
220
+ start() {
221
+ if (want) return;
222
+ want = true;
223
+ retry = 1000;
224
+ void connect();
225
+ },
226
+ stop() {
227
+ want = false;
228
+ gen += 1;
229
+ if (timer) { clearTimeout(timer); timer = null; }
230
+ const sock = ws;
231
+ ws = null;
232
+ connected = false;
233
+ for (const c of live.values()) c.abort();
234
+ live.clear();
235
+ try { sock?.close(1000, 'Phones from anywhere turned off.'); } catch { /* already */ }
236
+ onChange();
237
+ },
238
+ /** The key was reset: the room is a new one. */
239
+ restart() {
240
+ if (!want) return;
241
+ this.stop();
242
+ this.start();
243
+ },
244
+ state: () => ({ connected, error, connecting: want && !connected && !error }),
245
+ };
246
+ }
@@ -1,3 +1,4 @@
1
+ import { harnessFor } from './harnesses.mjs';
1
2
  // THE NAME ON THE ROW, WRITTEN RATHER THAN TAKEN FROM THE FIRST SENTENCE.
2
3
  //
3
4
  // A dictated message keeps its first line as its title, verbatim and forever
@@ -35,6 +36,7 @@
35
36
  // words the moment they open the row (shared/work-items.mjs).
36
37
 
37
38
  import { spawn } from 'node:child_process';
39
+ import { codexLaunchEnv } from './codex-launch-env.mjs';
38
40
 
39
41
  /**
40
42
  * The model this runs on. Small and fast on purpose: this is a naming call, it
@@ -187,10 +189,7 @@ export function codexNameArgs(item) {
187
189
  }
188
190
 
189
191
  function codexArgs(prompt) {
190
- return [
191
- 'exec', '--skip-git-repo-check', '--ephemeral', '--ignore-user-config', '--ignore-rules',
192
- '-s', 'read-only', '-c', 'model_reasoning_effort=low', prompt,
193
- ];
192
+ return harnessFor('codex').smallModelArgs(prompt);
194
193
  }
195
194
 
196
195
  /**
@@ -217,15 +216,17 @@ export function nameRow(item, opts = {}) {
217
216
  */
218
217
  export function askSmallModel(prompt, clean, { claudeBin, codexBin = null, engine = 'claude', model = NAME_MODEL, timeoutMs = NAME_TIMEOUT_MS, env } = {}) {
219
218
  return new Promise((resolve) => {
220
- const bin = engine === 'codex' ? codexBin : claudeBin;
219
+ let harness;
220
+ try { harness = harnessFor(engine); } catch { return resolve(''); }
221
+ const bin = harness.binary({ claudeBin, codexBin });
221
222
  if (!bin) return resolve('');
222
223
  let child;
223
224
  try {
224
- child = spawn(bin, engine === 'codex' ? codexArgs(prompt) : ['-p', prompt, '--model', model], {
225
+ child = spawn(bin, harness.smallModelArgs(prompt, model), {
225
226
  // Somewhere that is nobody's project. A naming call must not pick up a
226
227
  // CLAUDE.md, a settings file or a hook from whatever folder it lands in.
227
228
  cwd: '/tmp',
228
- ...(env ? { env } : {}),
229
+ ...(engine === 'codex' ? { env: codexLaunchEnv(bin, env) } : env ? { env } : {}),
229
230
  stdio: ['ignore', 'pipe', 'pipe'],
230
231
  });
231
232
  } catch {
@@ -0,0 +1,90 @@
1
+ // WHETHER THIS MAC IS ALREADY SIGNED INTO A CODING AGENT, AND WHICH ONE.
2
+ //
3
+ // w-e217e577e5, 2026-10-07. A new user ran an agent and, in her words, was
4
+ // "like, 'Oh my God, where are the tokens coming from? I didn't even connect my
5
+ // account.'" The app finds whichever coding agent is already signed in on the
6
+ // Mac and spends that subscription without ever saying so.
7
+ //
8
+ // WHICH ENGINE, AND NOTHING ELSE. This read the plan tier too for a few hours,
9
+ // so a row in the sidebar's foot could say "Claude · Max 20x"; that row came
10
+ // out the same day and the tier came out with it ("very critically, I want to
11
+ // get rid of that Claude Max 20x plan"). The question the walk answers is "did
12
+ // it connect to something of mine", and the engine is the whole of that answer.
13
+ // What somebody is paying for is on each agent's own page in Settings, which is
14
+ // where you go when you actually want it (main/settings.mjs reads
15
+ // main/claude-plan.mjs and main/codex-account.mjs for exactly that).
16
+ //
17
+ // NULL IS AN ORDINARY ANSWER AND IT MEANS SAY NOTHING. A Mac mid-setup, a config
18
+ // file being rewritten under us, a login that is not there: all null, and the
19
+ // walk then says nothing at all rather than guessing. That Mac is also the one
20
+ // that gets the plan question instead, which says all of this out loud already.
21
+ //
22
+ // IT IS CACHED ON THE LOGIN FILES' OWN TIMES, NOT ON A CLOCK. The snapshot is
23
+ // built every ten seconds and `~/.claude.json` is 148 KB on the machine this was
24
+ // written on, so parsing it per tick is work nobody asked for; it is also a file
25
+ // that genuinely changes, since Claude Code refetches its own profile as it
26
+ // runs. `signInFiles`/`signInStamp` (main/sign-in-files.mjs) already name the
27
+ // files a login writes and read their modification times and nothing else, so
28
+ // the cache key is the newest of those times. A moved time re-reads. A cache
29
+ // that aged out on a timer instead would be either stale after a sign-in or
30
+ // re-parsing for nothing, and the file itself knows which.
31
+
32
+ import fs from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import { planFile } from './claude-plan.mjs';
36
+ import { codexAccount, codexAuthFile } from './codex-account.mjs';
37
+ import { signInFiles, signInStamp } from './sign-in-files.mjs';
38
+
39
+ /** keyed by engine, home and folder, valued by the stamp it was read at. */
40
+ const remembered = new Map();
41
+
42
+ /**
43
+ * WHAT THE FLEET IS RUNNING ON, or null because nothing readable is signed in.
44
+ *
45
+ * @param {object} facts
46
+ * @param {string} facts.engine Which coding agent the workspace runs on, which
47
+ * the supervisor has already decided (`Supervisor#engineFacts`). Never worked
48
+ * out here: the capability gate lives there, and a line that guessed could
49
+ * name the subscription the fleet is not spending.
50
+ * @param {string} [facts.claudeProfile] The Claude login, `default` or a folder.
51
+ * @param {string} [facts.codexHome] The CODEX_HOME this workspace runs on.
52
+ * @returns {{ engine: string }|null}
53
+ */
54
+ export function runsOn({ engine = null, home = os.homedir(), claudeProfile = 'default', codexHome = null } = {}) {
55
+ if (engine !== 'claude' && engine !== 'codex') return null;
56
+ const folder = engine === 'codex'
57
+ ? (codexHome || path.join(home, '.codex'))
58
+ : (!claudeProfile || claudeProfile === 'default' ? null : claudeProfile);
59
+ // The home is in the key as well as the folder. A default Claude login has no
60
+ // folder of its own, so without it two homes share one entry, which is exactly
61
+ // what a test with a throwaway home does and what a packaged app would do if
62
+ // HOME ever moved under it.
63
+ const key = `${engine}:${home}:${folder ?? 'default'}`;
64
+ const stamp = signInStamp(signInFiles({ engine, folder, home }));
65
+ const seen = remembered.get(key);
66
+ if (seen && seen.stamp === stamp) return seen.value;
67
+ const value = stamp && signedIn(engine, { home, claudeProfile, folder }) ? { engine } : null;
68
+ remembered.set(key, { stamp, value });
69
+ return value;
70
+ }
71
+
72
+ /** Whether there is a login in there at all. Never throws, never guesses. */
73
+ function signedIn(engine, { home, claudeProfile, folder }) {
74
+ if (engine === 'codex') return !!codexAccount(null, { file: codexAuthFile(folder) });
75
+ // `oauthAccount` is the object Claude Code writes when somebody signs in, and
76
+ // its absence is the honest "nobody is". A file with no account in it is an
77
+ // ordinary thing: `~/.claude.json` also holds project history, so it exists on
78
+ // plenty of Macs nobody has logged in on.
79
+ try {
80
+ const account = JSON.parse(fs.readFileSync(planFile(claudeProfile, home), 'utf8'))?.oauthAccount;
81
+ return !!account && typeof account === 'object';
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** Forgets every cached reading. For tests, and for a sign-out that lands. */
88
+ export function forgetRunsOn() {
89
+ remembered.clear();
90
+ }
package/main/serve.mjs CHANGED
@@ -29,9 +29,12 @@ import crypto from 'node:crypto';
29
29
  import { fileURLToPath } from 'node:url';
30
30
 
31
31
  import { spawn } from 'node:child_process';
32
+ import { Readable } from 'node:stream';
33
+ import { mediaResponse, mediaType } from './img-scheme.mjs';
32
34
  import { loadConfig } from './config.mjs';
33
35
  import { Store } from './store.mjs';
34
36
  import { Supervisor } from './supervisor.mjs';
37
+ import { carryHerBriefsAcross, joinMessageRules, setAsideShippedMessageRules, unifyAgentInstructions } from './instruction-settings.mjs';
35
38
  import { registerIpc } from './ipc.mjs';
36
39
  import { reportFromRenderer } from './crash-report.mjs';
37
40
  import * as workItemsDisk from './store/work-items.mjs';
@@ -135,11 +138,14 @@ function readVersion() {
135
138
  * same scrubbed path as the desktop's, which writes nothing until crash
136
139
  * reports are set up for this process.
137
140
  */
138
- function answerWhatOnlyTheWindowAnswered(ipcMain) {
141
+ export function answerWhatOnlyTheWindowAnswered(ipcMain) {
139
142
  ipcMain.handle('zero:badge', () => null);
143
+ ipcMain.handle('zero:corner-tag', () => null);
140
144
  ipcMain.handle('zero:notify', () => null);
141
145
  ipcMain.handle('zero:boot-info', () => ({ reloaded: false, builtAt: null, recovered: null }));
142
146
  ipcMain.handle('zero:crash', (_e, payload) => { reportFromRenderer(payload ?? {}); return null; });
147
+ // The phone door belongs to the desktop app (main/phone-link.mjs); a tab is told there is none.
148
+ ipcMain.handle('zero:phone-link', () => null);
143
149
  }
144
150
 
145
151
  /**
@@ -152,11 +158,17 @@ function answerWhatOnlyTheWindowAnswered(ipcMain) {
152
158
  */
153
159
  export async function bootHeadless({ dataDir = repoRoot, appDir = repoRoot, userDir = dataDir } = {}) {
154
160
  const config = loadConfig(dataDir);
161
+ // The browser editor uses the same user files as the desktop. Finish the
162
+ // older migrations first so only user content enters the combined file.
163
+ carryHerBriefsAcross(appDir, userDir);
164
+ joinMessageRules(appDir, userDir);
165
+ setAsideShippedMessageRules(appDir, userDir);
166
+ unifyAgentInstructions(appDir, userDir);
155
167
  // THE APP'S OWN HOME IS THE STORE ROOT, BEFORE ANYTHING OPENS THE STORE, as
156
168
  // main/main.mjs does for the desktop. The ledgers live under the home the
157
169
  // store reads from this variable, and every worker is handed `storeRoot` as
158
170
  // that same variable. Without this line the two disagreed: the copy wrote its
159
- // rows wherever an inherited variable pointed (or `~/.agentbox`), each worker
171
+ // rows wherever an inherited variable pointed (or a dot-folder in $HOME), each worker
160
172
  // looked under `storeRoot`, and every store call answered "no work item".
161
173
  // tests/an-agent-in-a-browser-copy-can-reach-its-own-work-item.test.mjs.
162
174
  // Rows already written under the old home come along, or they would vanish.
@@ -185,6 +197,13 @@ export async function bootHeadless({ dataDir = repoRoot, appDir = repoRoot, user
185
197
  stateFile: teamStateFile(config.storeRoot),
186
198
  onChange: () => ipc?.push?.(),
187
199
  }) : null;
200
+ // AND IT STOPS ITS OWN WORKERS ON THE WAY OUT, as main/main.mjs does for the
201
+ // desktop. Control-C reaches the whole process group, so without this each
202
+ // worker died of the signal itself and was written down as a run that failed
203
+ // on its own: a Claude task stopped that way read "it will keep failing the
204
+ // same way until this is fixed" and rested 15 minutes after the copy came
205
+ // back (tests/control-c-on-the-browser-copy-does-not-fail-its-agents).
206
+ host.app.on('before-quit', () => supervisor.killAll());
188
207
  ipc = registerIpc({ store, supervisor, config, window, host, team });
189
208
  answerWhatOnlyTheWindowAnswered(host.ipcMain);
190
209
  return { config, store, supervisor, window, host, ipc, team, channels: host.ipcMain.handlers };
@@ -193,18 +212,71 @@ export async function bootHeadless({ dataDir = repoRoot, appDir = repoRoot, user
193
212
  /** A token in the url, because the port alone is not a door key. */
194
213
  export const newToken = () => crypto.randomBytes(24).toString('base64url');
195
214
 
215
+ /**
216
+ * THE KEY THAT ONLY READS FILES. A design page runs its own scripts and can
217
+ * read its own address, so the address a phone loads it from must not carry
218
+ * the phone's key, which starts agents and approves their requests. This is
219
+ * made from that key, changes when it is reset, and opens nothing but /f/.
220
+ */
221
+ export const fileKey = (token) => crypto.createHmac('sha256', String(token ?? '')).update('agentbox-files').digest('base64url').slice(0, 32);
222
+
196
223
  const MIME = {
197
224
  '.html': 'text/html; charset=utf-8',
225
+ '.htm': 'text/html; charset=utf-8',
198
226
  '.js': 'text/javascript; charset=utf-8',
227
+ '.mjs': 'text/javascript; charset=utf-8',
199
228
  '.css': 'text/css; charset=utf-8',
200
229
  '.json': 'application/json; charset=utf-8',
201
230
  '.svg': 'image/svg+xml',
202
231
  '.png': 'image/png',
232
+ '.jpg': 'image/jpeg',
233
+ '.jpeg': 'image/jpeg',
234
+ '.gif': 'image/gif',
203
235
  '.webp': 'image/webp',
236
+ '.avif': 'image/avif',
237
+ '.bmp': 'image/bmp',
238
+ '.ico': 'image/x-icon',
239
+ '.pdf': 'application/pdf',
240
+ '.txt': 'text/plain; charset=utf-8',
241
+ '.md': 'text/plain; charset=utf-8',
204
242
  '.woff2': 'font/woff2',
243
+ '.woff': 'font/woff',
244
+ '.ttf': 'font/ttf',
205
245
  '.map': 'application/json; charset=utf-8',
206
246
  };
207
247
 
248
+ /**
249
+ * One file off this computer, for the phone: /f/<file key>/<doc|img>/<path>.
250
+ * `files.allows(kind, file)` is the same rule the desktop's two schemes use
251
+ * (main/main.mjs passes it in), checked after the path is made whole, so a
252
+ * `..` cannot climb out of what it allows. Never from memory, for the reason
253
+ * the desktop's schemes give: a picture an agent redraws keeps its name.
254
+ */
255
+ async function answerFile(req, res, url, { token, files }) {
256
+ const [, , key, kind, ...rest] = url.pathname.split('/');
257
+ if (kind !== 'doc' && kind !== 'img') { res.writeHead(404).end('No such kind of file.'); return; }
258
+ if (!sameKey(key, fileKey(typeof token === 'function' ? token() : token))) { res.writeHead(403).end('Wrong key.'); return; }
259
+ let file;
260
+ try { file = path.normalize('/' + rest.map(decodeURIComponent).join('/')); } catch { res.writeHead(400).end('Bad path.'); return; }
261
+ if (!path.isAbsolute(file) || !files.allows(kind, file)) { res.writeHead(403).end('Not available.'); return; }
262
+ let stat;
263
+ try { stat = await fs.promises.stat(file); } catch { res.writeHead(404).end('Not there.'); return; }
264
+ if (!stat.isFile()) { res.writeHead(404).end('Not there.'); return; }
265
+ const media = mediaType(file);
266
+ if (media) {
267
+ const out = await mediaResponse(file, req.headers.range);
268
+ res.writeHead(out.status, { ...Object.fromEntries(out.headers), 'cache-control': 'no-store' });
269
+ if (out.body) Readable.fromWeb(out.body).pipe(res); else res.end();
270
+ return;
271
+ }
272
+ res.writeHead(200, {
273
+ 'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
274
+ 'content-length': String(stat.size),
275
+ 'cache-control': 'no-store',
276
+ });
277
+ fs.createReadStream(file).pipe(res);
278
+ }
279
+
208
280
  /**
209
281
  * Serve one built renderer and one channel endpoint.
210
282
  *
@@ -215,23 +287,50 @@ const MIME = {
215
287
  * `listeners` is the Set off `broadcastingWindow`, which is where the pushes
216
288
  * going the other way are waiting for somewhere to go.
217
289
  */
218
- export function createServer({ channels, token, listeners = new Set(), dist = path.join(repoRoot, 'renderer', 'dist') }) {
219
- return http.createServer(async (req, res) => {
290
+ export function createServer(options) {
291
+ return http.createServer(createHandler(options));
292
+ }
293
+
294
+ /**
295
+ * The same door as a bare request handler, so main/phone-link.mjs can put its
296
+ * own server in front of it. `token` may be a function, read on every request,
297
+ * so a key that is reset stops working at once.
298
+ */
299
+ export function createHandler({ channels, token, listeners = new Set(), dist = path.join(repoRoot, 'renderer', 'dist'), files = null }) {
300
+ return async (req, res) => {
220
301
  const url = new URL(req.url, 'http://127.0.0.1');
221
302
 
303
+ // A file for the phone, behind the file key rather than the phone's own.
304
+ // Only a server that was handed `files` has any to give.
305
+ if (url.pathname.startsWith('/f/')) {
306
+ if (!files) { res.writeHead(404).end('No files here.'); return; }
307
+ await answerFile(req, res, url, { token, files });
308
+ return;
309
+ }
310
+
222
311
  // The token rides in a header on api calls and in the query on the event
223
312
  // stream, because a browser cannot put a header on either a navigation or
224
313
  // an EventSource. It guards the two doors that reach the store. The built
225
314
  // screen is the same public files for everybody, and its own script and
226
315
  // style requests carry no token, so asking for one there drew a blank tab.
227
- const guarded = url.pathname === '/events' || url.pathname.startsWith('/api/');
316
+ // NOR DOES THE PAGE ITSELF ASK: the tab takes the token out of its address
317
+ // bar on load, so a reload asks for `/` bare, and refusing that drew
318
+ // "Wrong or missing token" on every reload (tests/a-browser-tab-survives-a-reload).
319
+ const guarded = url.pathname === '/events' || url.pathname.startsWith('/api/') || url.pathname === '/file-key';
228
320
  const given = req.headers['x-agentbox-token'] || url.searchParams.get('token');
229
- if (guarded && given !== token) {
321
+ if (guarded && !sameKey(given, typeof token === 'function' ? token() : token)) {
230
322
  res.writeHead(403, { 'content-type': 'text/plain' });
231
323
  res.end('Wrong or missing token. Use the url the terminal printed.');
232
324
  return;
233
325
  }
234
326
 
327
+ // The phone asks for its file key once, with its own key.
328
+ if (url.pathname === '/file-key') {
329
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
330
+ res.end(JSON.stringify({ key: fileKey(typeof token === 'function' ? token() : token) }));
331
+ return;
332
+ }
333
+
235
334
  // The phone line left open. Seven channels talk the other way, main to
236
335
  // screen, and every one of them is one-way, so this is an event stream
237
336
  // rather than a websocket: the same job with no dependency to add.
@@ -306,7 +405,19 @@ export function createServer({ channels, token, listeners = new Set(), dist = pa
306
405
  res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
307
406
  res.end(buf);
308
407
  });
309
- });
408
+ };
409
+ }
410
+
411
+ /**
412
+ * The key a request carries against the real one, in the same time whatever
413
+ * it gets right, so nobody can learn the key a character at a time by timing
414
+ * the refusals. Both sides are hashed first because a length that differs is
415
+ * itself a thing `timingSafeEqual` would refuse at once.
416
+ */
417
+ export function sameKey(given, key) {
418
+ if (typeof given !== 'string' || typeof key !== 'string' || !given || !key) return false;
419
+ const digest = (s) => crypto.createHash('sha256').update(s).digest();
420
+ return crypto.timingSafeEqual(digest(given), digest(key));
310
421
  }
311
422
 
312
423
  function readBody(req) {
@@ -314,9 +425,12 @@ function readBody(req) {
314
425
  let body = '';
315
426
  req.on('data', (c) => {
316
427
  body += c;
317
- // A screen never posts anything large; the uploads go through their own
318
- // channels. This is here so a stuck client cannot eat the process.
319
- if (body.length > 8 * 1024 * 1024) reject(new Error('Body too large'));
428
+ // This is here so a stuck client cannot eat the process. A PHONE'S
429
+ // UPLOADS DO COME THROUGH HERE, though: it has no file paths, so a
430
+ // picture is posted whole, as base64, to zero:stage-attachment. 8 MB of
431
+ // that was a 6 MB photo; an iPhone's is often more (2026-10-12). The
432
+ // relay passes up to 32 MB (relay/worker.mjs).
433
+ if (body.length > 32 * 1024 * 1024) reject(new Error('Body too large'));
320
434
  });
321
435
  req.on('end', () => resolve(body));
322
436
  req.on('error', reject);