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.
- package/README.md +24 -16
- package/bin/agentbox.mjs +18 -9
- package/briefs/message-rules.md +15 -4
- package/main/account-tooling.mjs +2 -1
- 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 -20
- package/main/analytics.mjs +30 -0
- package/main/approvals.mjs +14 -5
- package/main/claude-bin.mjs +45 -310
- 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 +14 -2
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -248
- package/main/codex-launch-env.mjs +31 -0
- 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 +40 -13
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/demo.mjs +17 -14
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +27 -27
- package/main/feedback.mjs +11 -6
- package/main/fresh-user.mjs +1 -1
- package/main/git-change.mjs +8 -2
- 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/instruction-settings.mjs +47 -11
- package/main/ipc.mjs +175 -22
- package/main/local-app-frames.mjs +82 -0
- package/main/machine.mjs +1 -1
- package/main/main.mjs +120 -5
- 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 +48 -5
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/refresh-agent-models.mjs +2 -1
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +8 -7
- package/main/runs-on.mjs +90 -0
- package/main/serve.mjs +124 -10
- package/main/settings.mjs +171 -11
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +25 -7
- 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/home.mjs +37 -5
- package/main/store/project.mjs +15 -19
- package/main/store.mjs +10 -4
- package/main/supervisor.mjs +1030 -447
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +491 -82
- package/main/task-terminals.mjs +49 -8
- package/main/team/index.mjs +2 -2
- package/main/team/projects.mjs +1 -1
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/mcp/core/page.mjs +1 -1
- package/mcp/core/work.mjs +34 -3
- package/mcp/tools.mjs +2 -1
- 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/after-install.mjs +14 -0
- package/scripts/approval-server.sh +31 -7
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +3 -1
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +19 -0
- package/shared/claude-commands.generated.mjs +8 -6
- package/shared/claude-models.generated.mjs +10 -8
- package/shared/codex-modes.mjs +1 -1
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/demo-world.mjs +1 -1
- package/shared/engines.mjs +20 -5
- package/shared/fresh-user-home.mjs +12 -1
- package/shared/harness-definitions.mjs +103 -0
- package/shared/instruction-defaults.json +1 -1
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/project-folder-check.mjs +3 -3
- 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 +10 -0
- package/shared/runs-on.mjs +55 -0
- package/shared/shortcuts.mjs +11 -5
- package/shared/spawn-trouble.mjs +58 -7
- 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 +28 -2
- 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-B-2Az952.css +0 -32
- 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
|
+
}
|
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
|
|
@@ -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
|
-
|
|
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,
|
|
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 {
|
package/main/runs-on.mjs
ADDED
|
@@ -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
|
|
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(
|
|
219
|
-
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) => {
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
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);
|