agentbox-app 0.1.12 → 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.
- package/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +57 -20
- package/main/agent-updates.mjs +9 -22
- package/main/analytics.mjs +30 -0
- package/main/claude-bin.mjs +45 -311
- package/main/claude-input.mjs +9 -3
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +16 -16
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -249
- package/main/codex-session.mjs +38 -3
- package/main/codex-threads.mjs +41 -8
- package/main/codex-watch.mjs +67 -3
- package/main/config.mjs +26 -4
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +4 -15
- package/main/feedback.mjs +11 -6
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +84 -0
- package/main/harnesses/codex.mjs +65 -0
- package/main/harnesses/common.mjs +78 -0
- package/main/harnesses/instructions-file.mjs +116 -0
- package/main/harnesses/opencode.mjs +234 -0
- package/main/harnesses.mjs +65 -0
- package/main/importable-threads.mjs +38 -0
- package/main/ipc.mjs +165 -31
- package/main/local-app-frames.mjs +82 -0
- package/main/main.mjs +100 -4
- package/main/memory-gate-history.mjs +51 -12
- package/main/memory-gate-server.mjs +67 -11
- package/main/memory-gate.mjs +49 -8
- package/main/memory-log.mjs +124 -0
- package/main/notify.mjs +47 -4
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +6 -6
- package/main/runs-on.mjs +32 -35
- package/main/serve.mjs +113 -9
- package/main/settings.mjs +148 -10
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +24 -6
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +27 -1
- package/main/start-hold.mjs +73 -0
- package/main/store.mjs +1 -1
- package/main/supervisor.mjs +832 -428
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +490 -98
- package/main/task-terminals.mjs +37 -7
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/package.json +10 -1
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
- package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
- package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
- package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
- package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
- package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
- package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
- package/renderer/dist/corner-tag.html +13 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +297 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/index.html +16 -3
- package/scripts/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +2 -0
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +18 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/engines.mjs +20 -5
- package/shared/harness-definitions.mjs +103 -0
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +2 -0
- package/shared/rank.mjs +50 -0
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/runs-on.d.mts +0 -10
- package/shared/runs-on.mjs +25 -49
- package/shared/shortcuts.mjs +10 -4
- package/shared/spawn-trouble.mjs +57 -6
- package/shared/terminal-input.d.mts +1 -0
- package/shared/terminal-input.mjs +30 -0
- package/shared/terminal-keys.d.mts +1 -0
- package/shared/terminal-keys.mjs +16 -0
- package/shared/terminal-size.d.mts +4 -0
- package/shared/terminal-size.mjs +29 -0
- package/shared/usage.d.mts +3 -0
- package/shared/usage.mjs +7 -0
- package/shared/where-it-runs-from.d.mts +43 -0
- package/shared/where-it-runs-from.mjs +155 -0
- package/shared/work-items.mjs +25 -1
- package/shared/work-lines.mjs +1 -1
- package/shared/xterm-invalid-range.d.mts +2 -0
- package/shared/xterm-invalid-range.mjs +58 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
- package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
|
@@ -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
|
+
}
|
package/main/row-label.mjs
CHANGED
|
@@ -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
|
|
@@ -188,10 +189,7 @@ export function codexNameArgs(item) {
|
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
function codexArgs(prompt) {
|
|
191
|
-
return
|
|
192
|
-
'exec', '--skip-git-repo-check', '--ephemeral', '--ignore-user-config', '--ignore-rules',
|
|
193
|
-
'-s', 'read-only', '-c', 'model_reasoning_effort=low', prompt,
|
|
194
|
-
];
|
|
192
|
+
return harnessFor('codex').smallModelArgs(prompt);
|
|
195
193
|
}
|
|
196
194
|
|
|
197
195
|
/**
|
|
@@ -218,11 +216,13 @@ export function nameRow(item, opts = {}) {
|
|
|
218
216
|
*/
|
|
219
217
|
export function askSmallModel(prompt, clean, { claudeBin, codexBin = null, engine = 'claude', model = NAME_MODEL, timeoutMs = NAME_TIMEOUT_MS, env } = {}) {
|
|
220
218
|
return new Promise((resolve) => {
|
|
221
|
-
|
|
219
|
+
let harness;
|
|
220
|
+
try { harness = harnessFor(engine); } catch { return resolve(''); }
|
|
221
|
+
const bin = harness.binary({ claudeBin, codexBin });
|
|
222
222
|
if (!bin) return resolve('');
|
|
223
223
|
let child;
|
|
224
224
|
try {
|
|
225
|
-
child = spawn(bin,
|
|
225
|
+
child = spawn(bin, harness.smallModelArgs(prompt, model), {
|
|
226
226
|
// Somewhere that is nobody's project. A naming call must not pick up a
|
|
227
227
|
// CLAUDE.md, a settings file or a hook from whatever folder it lands in.
|
|
228
228
|
cwd: '/tmp',
|
package/main/runs-on.mjs
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
//
|
|
1
|
+
// WHETHER THIS MAC IS ALREADY SIGNED INTO A CODING AGENT, AND WHICH ONE.
|
|
2
2
|
//
|
|
3
|
-
// w-e217e577e5, 2026-10-07
|
|
4
|
-
//
|
|
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.
|
|
5
7
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// to
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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).
|
|
13
16
|
//
|
|
14
|
-
// NULL IS AN ORDINARY ANSWER AND IT MEANS
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// is read by somebody deciding whose money this is spending.
|
|
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.
|
|
19
21
|
//
|
|
20
22
|
// IT IS CACHED ON THE LOGIN FILES' OWN TIMES, NOT ON A CLOCK. The snapshot is
|
|
21
23
|
// built every ten seconds and `~/.claude.json` is 148 KB on the machine this was
|
|
@@ -30,11 +32,11 @@
|
|
|
30
32
|
import fs from 'node:fs';
|
|
31
33
|
import os from 'node:os';
|
|
32
34
|
import path from 'node:path';
|
|
33
|
-
import { planFile
|
|
35
|
+
import { planFile } from './claude-plan.mjs';
|
|
34
36
|
import { codexAccount, codexAuthFile } from './codex-account.mjs';
|
|
35
37
|
import { signInFiles, signInStamp } from './sign-in-files.mjs';
|
|
36
38
|
|
|
37
|
-
/** keyed by engine and folder, valued by the stamp it was read at. */
|
|
39
|
+
/** keyed by engine, home and folder, valued by the stamp it was read at. */
|
|
38
40
|
const remembered = new Map();
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -43,12 +45,11 @@ const remembered = new Map();
|
|
|
43
45
|
* @param {object} facts
|
|
44
46
|
* @param {string} facts.engine Which coding agent the workspace runs on, which
|
|
45
47
|
* the supervisor has already decided (`Supervisor#engineFacts`). Never worked
|
|
46
|
-
* out here: the capability gate lives there, and a
|
|
48
|
+
* out here: the capability gate lives there, and a line that guessed could
|
|
47
49
|
* name the subscription the fleet is not spending.
|
|
48
50
|
* @param {string} [facts.claudeProfile] The Claude login, `default` or a folder.
|
|
49
51
|
* @param {string} [facts.codexHome] The CODEX_HOME this workspace runs on.
|
|
50
|
-
* @returns {{ engine: string
|
|
51
|
-
* tool itself reports, never a label of ours.
|
|
52
|
+
* @returns {{ engine: string }|null}
|
|
52
53
|
*/
|
|
53
54
|
export function runsOn({ engine = null, home = os.homedir(), claudeProfile = 'default', codexHome = null } = {}) {
|
|
54
55
|
if (engine !== 'claude' && engine !== 'codex') return null;
|
|
@@ -63,28 +64,24 @@ export function runsOn({ engine = null, home = os.homedir(), claudeProfile = 'de
|
|
|
63
64
|
const stamp = signInStamp(signInFiles({ engine, folder, home }));
|
|
64
65
|
const seen = remembered.get(key);
|
|
65
66
|
if (seen && seen.stamp === stamp) return seen.value;
|
|
66
|
-
const value = stamp
|
|
67
|
+
const value = stamp && signedIn(engine, { home, claudeProfile, folder }) ? { engine } : null;
|
|
67
68
|
remembered.set(key, { stamp, value });
|
|
68
69
|
return value;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// different screens: the first draws nothing, the second draws the name.
|
|
79
|
-
let account = null;
|
|
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.
|
|
80
79
|
try {
|
|
81
|
-
account = JSON.parse(fs.readFileSync(planFile(claudeProfile, home), 'utf8'))?.oauthAccount
|
|
80
|
+
const account = JSON.parse(fs.readFileSync(planFile(claudeProfile, home), 'utf8'))?.oauthAccount;
|
|
81
|
+
return !!account && typeof account === 'object';
|
|
82
82
|
} catch {
|
|
83
|
-
return
|
|
83
|
+
return false;
|
|
84
84
|
}
|
|
85
|
-
if (!account || typeof account !== 'object') return null;
|
|
86
|
-
const plan = planOfAccount(account);
|
|
87
|
-
return { engine: 'claude', plan: plan.known ? plan.label : null };
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
/** Forgets every cached reading. For tests, and for a sign-out that lands. */
|
package/main/serve.mjs
CHANGED
|
@@ -29,6 +29,8 @@ 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';
|
|
@@ -136,11 +138,14 @@ function readVersion() {
|
|
|
136
138
|
* same scrubbed path as the desktop's, which writes nothing until crash
|
|
137
139
|
* reports are set up for this process.
|
|
138
140
|
*/
|
|
139
|
-
function answerWhatOnlyTheWindowAnswered(ipcMain) {
|
|
141
|
+
export function answerWhatOnlyTheWindowAnswered(ipcMain) {
|
|
140
142
|
ipcMain.handle('zero:badge', () => null);
|
|
143
|
+
ipcMain.handle('zero:corner-tag', () => null);
|
|
141
144
|
ipcMain.handle('zero:notify', () => null);
|
|
142
145
|
ipcMain.handle('zero:boot-info', () => ({ reloaded: false, builtAt: null, recovered: null }));
|
|
143
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);
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
/**
|
|
@@ -192,6 +197,13 @@ export async function bootHeadless({ dataDir = repoRoot, appDir = repoRoot, user
|
|
|
192
197
|
stateFile: teamStateFile(config.storeRoot),
|
|
193
198
|
onChange: () => ipc?.push?.(),
|
|
194
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());
|
|
195
207
|
ipc = registerIpc({ store, supervisor, config, window, host, team });
|
|
196
208
|
answerWhatOnlyTheWindowAnswered(host.ipcMain);
|
|
197
209
|
return { config, store, supervisor, window, host, ipc, team, channels: host.ipcMain.handlers };
|
|
@@ -200,18 +212,71 @@ export async function bootHeadless({ dataDir = repoRoot, appDir = repoRoot, user
|
|
|
200
212
|
/** A token in the url, because the port alone is not a door key. */
|
|
201
213
|
export const newToken = () => crypto.randomBytes(24).toString('base64url');
|
|
202
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
|
+
|
|
203
223
|
const MIME = {
|
|
204
224
|
'.html': 'text/html; charset=utf-8',
|
|
225
|
+
'.htm': 'text/html; charset=utf-8',
|
|
205
226
|
'.js': 'text/javascript; charset=utf-8',
|
|
227
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
206
228
|
'.css': 'text/css; charset=utf-8',
|
|
207
229
|
'.json': 'application/json; charset=utf-8',
|
|
208
230
|
'.svg': 'image/svg+xml',
|
|
209
231
|
'.png': 'image/png',
|
|
232
|
+
'.jpg': 'image/jpeg',
|
|
233
|
+
'.jpeg': 'image/jpeg',
|
|
234
|
+
'.gif': 'image/gif',
|
|
210
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',
|
|
211
242
|
'.woff2': 'font/woff2',
|
|
243
|
+
'.woff': 'font/woff',
|
|
244
|
+
'.ttf': 'font/ttf',
|
|
212
245
|
'.map': 'application/json; charset=utf-8',
|
|
213
246
|
};
|
|
214
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
|
+
|
|
215
280
|
/**
|
|
216
281
|
* Serve one built renderer and one channel endpoint.
|
|
217
282
|
*
|
|
@@ -222,10 +287,27 @@ const MIME = {
|
|
|
222
287
|
* `listeners` is the Set off `broadcastingWindow`, which is where the pushes
|
|
223
288
|
* going the other way are waiting for somewhere to go.
|
|
224
289
|
*/
|
|
225
|
-
export function createServer(
|
|
226
|
-
return http.createServer(
|
|
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) => {
|
|
227
301
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
228
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
|
+
|
|
229
311
|
// The token rides in a header on api calls and in the query on the event
|
|
230
312
|
// stream, because a browser cannot put a header on either a navigation or
|
|
231
313
|
// an EventSource. It guards the two doors that reach the store. The built
|
|
@@ -234,14 +316,21 @@ export function createServer({ channels, token, listeners = new Set(), dist = pa
|
|
|
234
316
|
// NOR DOES THE PAGE ITSELF ASK: the tab takes the token out of its address
|
|
235
317
|
// bar on load, so a reload asks for `/` bare, and refusing that drew
|
|
236
318
|
// "Wrong or missing token" on every reload (tests/a-browser-tab-survives-a-reload).
|
|
237
|
-
const guarded = url.pathname === '/events' || url.pathname.startsWith('/api/');
|
|
319
|
+
const guarded = url.pathname === '/events' || url.pathname.startsWith('/api/') || url.pathname === '/file-key';
|
|
238
320
|
const given = req.headers['x-agentbox-token'] || url.searchParams.get('token');
|
|
239
|
-
if (guarded && given
|
|
321
|
+
if (guarded && !sameKey(given, typeof token === 'function' ? token() : token)) {
|
|
240
322
|
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
241
323
|
res.end('Wrong or missing token. Use the url the terminal printed.');
|
|
242
324
|
return;
|
|
243
325
|
}
|
|
244
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
|
+
|
|
245
334
|
// The phone line left open. Seven channels talk the other way, main to
|
|
246
335
|
// screen, and every one of them is one-way, so this is an event stream
|
|
247
336
|
// rather than a websocket: the same job with no dependency to add.
|
|
@@ -316,7 +405,19 @@ export function createServer({ channels, token, listeners = new Set(), dist = pa
|
|
|
316
405
|
res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
|
|
317
406
|
res.end(buf);
|
|
318
407
|
});
|
|
319
|
-
}
|
|
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));
|
|
320
421
|
}
|
|
321
422
|
|
|
322
423
|
function readBody(req) {
|
|
@@ -324,9 +425,12 @@ function readBody(req) {
|
|
|
324
425
|
let body = '';
|
|
325
426
|
req.on('data', (c) => {
|
|
326
427
|
body += c;
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
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'));
|
|
330
434
|
});
|
|
331
435
|
req.on('end', () => resolve(body));
|
|
332
436
|
req.on('error', reject);
|