@hwip/cli 0.0.1 → 0.1.1

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 (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1983 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +305 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +278 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5681 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,510 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startLanReceiver = exports.createConnectionLimiter = exports.LAN_BODY_IDLE_MS = exports.LAN_MAX_CONNECTIONS_PER_IP = exports.LAN_MAX_CONNECTIONS = exports.LAN_LANDED_IV = exports.LAN_MAX_IN_FLIGHT = exports.LAN_MAX_BODY_BYTES = exports.LAN_DISK_RESERVE_BYTES = exports.LAN_LARGE_CAPABILITY = exports.LAN_MAX_FILE_BYTES = void 0;
4
+ const node_http_1 = require("node:http");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
7
+ const node_stream_1 = require("node:stream");
8
+ const promises_1 = require("node:stream/promises");
9
+ const downloads_js_1 = require("./downloads.js");
10
+ const file_crypto_js_1 = require("./file-crypto.js");
11
+ const lan_auth_js_1 = require("./lan-auth.js");
12
+ const private_files_js_1 = require("./private-files.js");
13
+ /**
14
+ * 16 GiB — a long phone video in 4K fits — and the tag each piece of a file
15
+ * that size sealed in chunks adds. No plan's limit applies here: the bytes
16
+ * never reach the relay, and only a device of this account gets past the MAC.
17
+ * What bounds it is the disk (`freeBytes`). A sender sends a file over the
18
+ * relay's ceiling here only to a listener that lists `LAN_LARGE_CAPABILITY`;
19
+ * one from before it takes 1 GiB.
20
+ */
21
+ exports.LAN_MAX_FILE_BYTES = 16 * 1024 ** 3;
22
+ /** What a listener tells senders when it takes `LAN_MAX_FILE_BYTES` over LAN (`capabilities` on its device record). */
23
+ exports.LAN_LARGE_CAPABILITY = 'lan-large-v1';
24
+ /** Room left on the landing volume after a file, so a transfer never fills the disk to its last byte. */
25
+ exports.LAN_DISK_RESERVE_BYTES = 1024 ** 3;
26
+ exports.LAN_MAX_BODY_BYTES = exports.LAN_MAX_FILE_BYTES + (exports.LAN_MAX_FILE_BYTES / file_crypto_js_1.FILE_CHUNK_BYTES) * file_crypto_js_1.GCM_TAG_BYTES;
27
+ exports.LAN_MAX_IN_FLIGHT = 2;
28
+ /**
29
+ * Beside a landed file, the iv it was sealed under (base64, as the meta gave
30
+ * it). The push record that claims the file comes from the relay; this is what
31
+ * the claim holds it against. A leading dot: no landed file can have this name
32
+ * (`safeFileName` strips them).
33
+ */
34
+ exports.LAN_LANDED_IV = '.iv';
35
+ /** The sockets are the daemon's file descriptors, shared with the relay
36
+ * WebSocket: a LAN flood may exhaust local transfer (sends fall back to the
37
+ * relay), never the daemon. */
38
+ exports.LAN_MAX_CONNECTIONS = 64;
39
+ /**
40
+ * Sockets one remote address may hold. The pool above is shared by the whole
41
+ * LAN, and a host that opens 64 sockets and sends nothing holds every slot for
42
+ * the 5 s `headersTimeout` — and reopens them — which locks every real sender
43
+ * out of the LAN path (they fall back to the relay, nothing worse). A real
44
+ * sender needs a ping and at most `LAN_MAX_IN_FLIGHT` uploads, so a handful per
45
+ * address is plenty, and it takes this many remote addresses to fill the pool —
46
+ * a host with several addresses on the subnet counts as several.
47
+ *
48
+ * Not "bind only the published address": the attacker is on the same LAN and
49
+ * reaches that address as well as any other, so a narrower bind removes nothing
50
+ * from this problem — it only hides the port from other interfaces (a VPN, a
51
+ * docker bridge). It also costs: the publisher re-picks the address every
52
+ * minute as the machine changes networks, and the port is what it publishes, so
53
+ * a socket bound to one address would have to be rebound, and republished under
54
+ * a new port, on every move.
55
+ */
56
+ exports.LAN_MAX_CONNECTIONS_PER_IP = 8;
57
+ exports.LAN_BODY_IDLE_MS = 60_000;
58
+ /** Derived key sets kept; the account has a handful of devices. */
59
+ const KEY_CACHE_MAX = 64;
60
+ /** After a pre-body refusal, how long the client may keep sending before the socket goes. */
61
+ const REFUSE_GRACE_MS = 1_000;
62
+ /** One line per refused check per window: anyone on the network can ask. */
63
+ const REFUSAL_LOG_WINDOW_MS = 10 * 60_000;
64
+ const asMeta = (parsed) => {
65
+ if (typeof parsed !== 'object' || parsed === null)
66
+ return null;
67
+ const m = parsed;
68
+ const isStr = (v) => typeof v === 'string' && v.length > 0;
69
+ if (!isStr(m.fileName) || !isStr(m.iv) || !isStr(m.transferId))
70
+ return null;
71
+ if (typeof m.deviceKeyMap !== 'object' || m.deviceKeyMap === null)
72
+ return null;
73
+ const deviceKeyMap = {};
74
+ for (const [k, v] of Object.entries(m.deviceKeyMap)) {
75
+ if (typeof v !== 'string')
76
+ return null;
77
+ deviceKeyMap[k] = v;
78
+ }
79
+ const said = (0, file_crypto_js_1.chunkOf)(m.chunk);
80
+ if (!said.ok)
81
+ return null;
82
+ if (Buffer.from(m.iv, 'base64').length !== (said.chunk === undefined ? file_crypto_js_1.GCM_IV_BYTES : file_crypto_js_1.FILE_CHUNK_PREFIX_BYTES))
83
+ return null;
84
+ return {
85
+ fileName: m.fileName,
86
+ fileType: isStr(m.fileType) ? m.fileType : undefined,
87
+ fileSize: typeof m.fileSize === 'number' ? m.fileSize : undefined,
88
+ iv: m.iv,
89
+ deviceKeyMap,
90
+ transferId: m.transferId,
91
+ chunk: said.chunk,
92
+ };
93
+ };
94
+ const end = (res, status, body) => {
95
+ res.statusCode = status;
96
+ if (body) {
97
+ res.setHeader('Content-Type', 'application/json');
98
+ res.end(JSON.stringify(body));
99
+ }
100
+ else {
101
+ res.end();
102
+ }
103
+ };
104
+ /**
105
+ * Refuse before the body: answer, then discard what the client is still
106
+ * writing for a short grace; a body still arriving after it takes the
107
+ * socket down. A client mid-write whose bytes stop being read — or whose
108
+ * socket is closed under it, which is what `Connection: close` does —
109
+ * sees a reset, not the 413 it needs to fall back on. The grace bounds
110
+ * what that costs: never the whole gigabyte. A body that finishes inside
111
+ * it leaves the connection usable.
112
+ */
113
+ const refuse = (req, res, status) => {
114
+ res.statusCode = status;
115
+ // A refused peer does not get to keep its socket: left alive, one small
116
+ // request per keep-alive window holds a connection slot forever with no
117
+ // reconnect, and the per-address cap would only slow that down. Only when
118
+ // there is no body still arriving — closing under a client mid-write is the
119
+ // reset the grace below exists to avoid. A real sender's refusals (409, 413,
120
+ // 503) carry a body and keep their connection as before.
121
+ if (req.headers['content-length'] === undefined && req.headers['transfer-encoding'] === undefined) {
122
+ res.setHeader('Connection', 'close');
123
+ }
124
+ req.resume();
125
+ res.end(() => {
126
+ if (req.complete)
127
+ return;
128
+ const timer = setTimeout(() => req.socket.destroy(), REFUSE_GRACE_MS);
129
+ timer.unref();
130
+ const clear = () => clearTimeout(timer);
131
+ req.once('end', clear);
132
+ req.socket.once('close', clear);
133
+ });
134
+ };
135
+ /**
136
+ * Open-socket count per remote address. `admit` before the socket is used,
137
+ * `release` when it closes; an address with no sockets is forgotten, so the map
138
+ * is as large as the number of hosts connected right now and never larger.
139
+ */
140
+ const createConnectionLimiter = (max) => {
141
+ const open = new Map();
142
+ return {
143
+ admit: (address) => {
144
+ const count = open.get(address) ?? 0;
145
+ if (count >= max)
146
+ return false;
147
+ open.set(address, count + 1);
148
+ return true;
149
+ },
150
+ release: (address) => {
151
+ const count = open.get(address);
152
+ if (count === undefined)
153
+ return;
154
+ if (count <= 1)
155
+ open.delete(address);
156
+ else
157
+ open.set(address, count - 1);
158
+ },
159
+ tracked: () => open.size,
160
+ };
161
+ };
162
+ exports.createConnectionLimiter = createConnectionLimiter;
163
+ const startLanReceiver = (opts) => new Promise((resolve, reject) => {
164
+ const now = opts.now ?? (() => Date.now());
165
+ const maxBody = opts.maxBodyBytes ?? exports.LAN_MAX_BODY_BYTES;
166
+ const freeBytes = opts.freeBytes ?? ((dir) => {
167
+ const stats = (0, node_fs_1.statfsSync)(dir);
168
+ return stats.bavail * stats.bsize;
169
+ });
170
+ const maxInFlight = opts.maxInFlight ?? exports.LAN_MAX_IN_FLIGHT;
171
+ const idleMs = opts.idleTimeoutMs ?? exports.LAN_BODY_IDLE_MS;
172
+ const nonces = opts.nonces ?? (0, lan_auth_js_1.createNonceRegistry)();
173
+ // sender public key → derived keys. The secret is static per pair, and
174
+ // a P-256 derive per request would be the cheapest CPU an outsider
175
+ // who sniffed a device id could buy. With this cache and the device
176
+ // directory's miss brake, the pre-MAC work anyone can buy is a header
177
+ // parse and two map lookups — nothing worth rationing, so there is no
178
+ // request budget for an outsider to drain and lock real senders out.
179
+ const keyCache = new Map();
180
+ let inFlight = 0;
181
+ // Transfer ids with an upload in progress: the 409 must hold from the
182
+ // first byte, not from when the directory appears.
183
+ const active = new Set();
184
+ const keysFor = async (publicKey) => {
185
+ const hit = keyCache.get(publicKey);
186
+ if (hit)
187
+ return hit;
188
+ const keys = (0, lan_auth_js_1.deriveLanKeys)(await opts.deriveSharedSecret(publicKey));
189
+ if (keyCache.size >= KEY_CACHE_MAX)
190
+ keyCache.delete(keyCache.keys().next().value ?? '');
191
+ keyCache.set(publicKey, keys);
192
+ return keys;
193
+ };
194
+ /** Spend a nonce whose MAC held. A refusal here is a replay race or a
195
+ * sender over its quota — the peer sees the same 401, the log says which. */
196
+ const commitNonce = (sender, nonce) => {
197
+ if (nonces.commit(sender, nonce, now()))
198
+ return true;
199
+ opts.log(`local transfer: nonce refused for ${sender} (a replay, or the sender is over its quota)`);
200
+ return false;
201
+ };
202
+ /**
203
+ * The 401 stays one empty answer for the peer; the log says which check
204
+ * failed (C67) — a phone whose clock is off by minutes otherwise never
205
+ * reaches this machine over LAN and nobody can see why. Anyone can
206
+ * trigger these, and every sender id here is the peer's unproven
207
+ * claim, so the limit is per check: one line per check per
208
+ * REFUSAL_LOG_WINDOW_MS, naming the first sender and how many more
209
+ * were refused since.
210
+ */
211
+ const refusalLogged = new Map();
212
+ const logRefusal = (check, detail, sender) => {
213
+ const at = now();
214
+ const last = refusalLogged.get(check);
215
+ if (last && at - last.at < REFUSAL_LOG_WINDOW_MS) {
216
+ last.suppressed++;
217
+ return;
218
+ }
219
+ refusalLogged.set(check, { at, suppressed: 0 });
220
+ // The id is the peer's unauthenticated claim: printable, short.
221
+ const who = sender === null ? 'an unnamed sender' : sender.replace(/[^\w.-]/g, '?').slice(0, 64);
222
+ const more = last?.suppressed ? ` (${last.suppressed} more since the last line)` : '';
223
+ opts.log(`local transfer: refused ${who} — ${detail ?? check} (401)${more}`);
224
+ };
225
+ /**
226
+ * Everything an outsider can reach ends here with one answer: 401,
227
+ * empty. Resolves the sender's keys once the headers are well formed,
228
+ * fresh, unreplayed, and name a device of this account with a key we
229
+ * can derive against; the MAC itself is the caller's, because on an
230
+ * upload it binds a body that has not arrived yet.
231
+ */
232
+ const authenticate = async (req, res) => {
233
+ const fields = (0, lan_auth_js_1.parseLanAuthHeaders)(req.headers);
234
+ if (!fields) {
235
+ logRefusal('malformed auth headers', null, null);
236
+ refuse(req, res, 401);
237
+ return null;
238
+ }
239
+ const sender = fields.senderDeviceId;
240
+ if (!(0, lan_auth_js_1.isTimestampFresh)(fields.timestamp, now())) {
241
+ const skewS = Math.round((fields.timestamp - now()) / 1000);
242
+ logRefusal('clock', `its clock is ${Math.abs(skewS)} s ${skewS < 0 ? 'behind' : 'ahead of'} this machine's (limit ${lan_auth_js_1.LAN_CLOCK_SKEW_MS / 1000} s)`, sender);
243
+ refuse(req, res, 401);
244
+ return null;
245
+ }
246
+ if (nonces.seen(sender, fields.nonce, now())) {
247
+ logRefusal('a replayed request', null, sender);
248
+ refuse(req, res, 401);
249
+ return null;
250
+ }
251
+ let senderPublicKey = null;
252
+ try {
253
+ senderPublicKey = await opts.lookupSenderPublicKey(fields.senderDeviceId);
254
+ }
255
+ catch (err) {
256
+ opts.log(`local transfer: device lookup failed — ${err instanceof Error ? err.message : String(err)}`);
257
+ }
258
+ if (!senderPublicKey) {
259
+ logRefusal('not a device of this account', null, sender);
260
+ refuse(req, res, 401);
261
+ return null;
262
+ }
263
+ let keys;
264
+ try {
265
+ keys = await keysFor(senderPublicKey);
266
+ }
267
+ catch {
268
+ logRefusal('its device key does not import', null, sender);
269
+ refuse(req, res, 401); // a public key that will not import is not a sender we can trust
270
+ return null;
271
+ }
272
+ return { keys, fields, senderPublicKey };
273
+ };
274
+ const ping = async (req, res) => {
275
+ const auth = await authenticate(req, res);
276
+ if (!auth)
277
+ return;
278
+ const { keys, fields } = auth;
279
+ const ok = fields.transferId === lan_auth_js_1.LAN_PING_TRANSFER_ID
280
+ && (0, lan_auth_js_1.verifyLanMac)(keys.macKey, { ...fields, method: 'GET', path: lan_auth_js_1.LAN_PATHS.ping, bodySha256: lan_auth_js_1.EMPTY_BODY_SHA256 }, fields.mac)
281
+ && commitNonce(fields.senderDeviceId, fields.nonce);
282
+ if (!ok) {
283
+ logRefusal('the ping does not verify', null, fields.senderDeviceId);
284
+ refuse(req, res, 401);
285
+ return;
286
+ }
287
+ res.setHeader(lan_auth_js_1.LAN_HEADERS.receipt, (0, lan_auth_js_1.signLanReceipt)(keys.macKey, fields.mac));
288
+ end(res, 200, { deviceId: opts.deviceId() });
289
+ };
290
+ const upload = async (req, res) => {
291
+ const auth = await authenticate(req, res);
292
+ if (!auth)
293
+ return;
294
+ const { keys, fields, senderPublicKey } = auth;
295
+ // The sealed meta only opens under the real sender's key: this is
296
+ // where an impersonator with a genuine device id stops, at the
297
+ // same 401 as everyone else.
298
+ const opened = (0, lan_auth_js_1.openMeta)(keys.metaKey, req.headers[lan_auth_js_1.LAN_HEADERS.meta]);
299
+ if (opened === null) {
300
+ logRefusal('the upload header does not open under its key', null, fields.senderDeviceId);
301
+ refuse(req, res, 401);
302
+ return;
303
+ }
304
+ // From here the peer is a device of this account: the answers may
305
+ // say something about this machine. Still all before the body.
306
+ const meta = asMeta(opened);
307
+ if (!meta || meta.transferId !== fields.transferId) {
308
+ refuse(req, res, 400);
309
+ return;
310
+ }
311
+ if (active.has(meta.transferId)) {
312
+ refuse(req, res, 409);
313
+ return;
314
+ }
315
+ if (inFlight >= maxInFlight) {
316
+ refuse(req, res, 503);
317
+ return;
318
+ }
319
+ inFlight++;
320
+ active.add(meta.transferId);
321
+ const reserved = meta.transferId;
322
+ try {
323
+ const lengthHeader = req.headers['content-length'];
324
+ if (lengthHeader === undefined || req.headers['transfer-encoding'] !== undefined) {
325
+ refuse(req, res, 411);
326
+ return;
327
+ }
328
+ const declared = Number(lengthHeader);
329
+ if (!Number.isInteger(declared) || declared < 0) {
330
+ refuse(req, res, 400);
331
+ return;
332
+ }
333
+ if (declared > maxBody) {
334
+ refuse(req, res, 413);
335
+ return;
336
+ }
337
+ const slot = meta.deviceKeyMap[opts.deviceId()];
338
+ if (!slot) {
339
+ refuse(req, res, 400);
340
+ return;
341
+ }
342
+ const transferDir = (0, node_path_1.join)(opts.landingDir(), meta.transferId);
343
+ let rawKey;
344
+ try {
345
+ rawKey = await opts.unwrapFileKey(slot, senderPublicKey);
346
+ }
347
+ catch {
348
+ refuse(req, res, 400); // wrapped, but not for this device's key
349
+ return;
350
+ }
351
+ try {
352
+ // The parent on demand (a fresh install has no ~/.hwip/attachments/lan);
353
+ // the transfer dir itself exclusively, so a second upload is 409.
354
+ (0, private_files_js_1.mkdirHandedBack)(opts.landingDir());
355
+ // 507 before a byte is written: the file and the reserve must fit, or the sender goes by the relay.
356
+ if (freeBytes(opts.landingDir()) < declared + exports.LAN_DISK_RESERVE_BYTES) {
357
+ opts.log(`local transfer: refused ${declared}B — not enough free disk (507)`);
358
+ refuse(req, res, 507);
359
+ return;
360
+ }
361
+ (0, private_files_js_1.mkdirExclusiveHandedBack)(transferDir);
362
+ }
363
+ catch (err) {
364
+ if (err.code === 'EEXIST') {
365
+ refuse(req, res, 409);
366
+ return;
367
+ }
368
+ throw err;
369
+ }
370
+ const hasher = (0, lan_auth_js_1.createBodyHasher)();
371
+ let received = 0;
372
+ const tee = new node_stream_1.Transform({
373
+ transform(chunk, _enc, done) {
374
+ received += chunk.length;
375
+ if (received > declared) {
376
+ done(new Error('body exceeds Content-Length'));
377
+ return;
378
+ }
379
+ hasher.update(chunk);
380
+ done(null, chunk);
381
+ },
382
+ });
383
+ // `pipeline`, not `pipe`: a client that drops mid-body must error
384
+ // the tee, or the save waits for bytes that never come and the
385
+ // half-written transfer dir stays until the socket times out.
386
+ void (0, promises_1.pipeline)(req, tee).catch(() => undefined);
387
+ req.setTimeout(idleMs, () => req.destroy(new Error(`no bytes for ${idleMs} ms`)));
388
+ const dest = (0, node_path_1.join)(transferDir, (0, downloads_js_1.safeFileName)(meta.fileName));
389
+ try {
390
+ const saved = await (0, file_crypto_js_1.saveStream)(tee, () => dest, { rawKey, iv: Buffer.from(meta.iv, 'base64'), chunk: meta.chunk });
391
+ // Everything is on disk, decrypted and tag-checked; now the
392
+ // MAC that binds those bytes to this sender and this moment,
393
+ // and the nonce is spent only for a MAC that held.
394
+ const bound = { ...fields, method: 'POST', path: lan_auth_js_1.LAN_PATHS.upload, bodySha256: hasher.digestHex() };
395
+ const ok = received === declared
396
+ && (0, lan_auth_js_1.verifyLanMac)(keys.macKey, bound, fields.mac)
397
+ && commitNonce(fields.senderDeviceId, fields.nonce);
398
+ if (!ok) {
399
+ (0, node_fs_1.rmSync)(transferDir, { recursive: true, force: true });
400
+ logRefusal(received === declared ? 'the upload does not verify' : 'the upload is shorter or longer than declared', null, fields.senderDeviceId);
401
+ end(res, 401);
402
+ return;
403
+ }
404
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(transferDir, exports.LAN_LANDED_IV), meta.iv, { mode: 0o600 });
405
+ opts.log(`local transfer: received "${meta.fileName}" (${saved.bytes}B) from ${fields.senderDeviceId} as ${meta.transferId}`);
406
+ res.setHeader(lan_auth_js_1.LAN_HEADERS.receipt, (0, lan_auth_js_1.signLanReceipt)(keys.macKey, fields.mac));
407
+ end(res, 201, { transferId: meta.transferId });
408
+ }
409
+ catch (err) {
410
+ (0, node_fs_1.rmSync)(transferDir, { recursive: true, force: true });
411
+ opts.log(`local transfer: upload ${meta.transferId} failed — ${err instanceof Error ? err.message : String(err)}`);
412
+ if (!res.headersSent)
413
+ end(res, 400);
414
+ else
415
+ res.destroy();
416
+ }
417
+ }
418
+ finally {
419
+ inFlight--;
420
+ active.delete(reserved);
421
+ }
422
+ };
423
+ const route = (req, res) => {
424
+ const url = req.url ?? '';
425
+ const handler = req.method === 'GET' && url === lan_auth_js_1.LAN_PATHS.ping ? ping
426
+ : req.method === 'POST' && url === lan_auth_js_1.LAN_PATHS.upload ? upload
427
+ : null;
428
+ if (!handler) {
429
+ refuse(req, res, 404);
430
+ return;
431
+ }
432
+ handler(req, res).catch((err) => {
433
+ opts.log(`local transfer: request failed — ${err instanceof Error ? err.message : String(err)}`);
434
+ if (!res.headersSent)
435
+ refuse(req, res, 500);
436
+ else
437
+ res.destroy();
438
+ });
439
+ };
440
+ // A client that opens a socket and never sends headers must not hold
441
+ // it: Node enforces `headersTimeout` only on each connections check
442
+ // (30 s by default), so the check runs every second for the 5 s to be
443
+ // real. The body has no wall-clock budget — a 16 GiB file on Wi-Fi
444
+ // takes what it takes — only the idle timeout set per upload above.
445
+ const server = (0, node_http_1.createServer)({ connectionsCheckingInterval: 1_000 }, route);
446
+ server.headersTimeout = 5_000;
447
+ server.requestTimeout = 0;
448
+ server.maxConnections = opts.maxConnections ?? exports.LAN_MAX_CONNECTIONS;
449
+ const perIp = opts.maxConnectionsPerIp ?? exports.LAN_MAX_CONNECTIONS_PER_IP;
450
+ const limiter = (0, exports.createConnectionLimiter)(perIp);
451
+ let lastCapLogAt = -Infinity;
452
+ // Before Node's own connection handler, so a socket over its address's
453
+ // cap is destroyed before anything is read from it (Node still sets it up
454
+ // and tears it down on the close that follows). `maxConnections` is
455
+ // checked earlier still, at accept — that one is the global bound.
456
+ server.prependListener('connection', (socket) => {
457
+ // No address means the peer is already gone: nothing to count.
458
+ const address = socket.remoteAddress;
459
+ if (!address)
460
+ return;
461
+ if (!limiter.admit(address)) {
462
+ socket.destroy();
463
+ // Anyone on the network can cause this line, so one per window.
464
+ const at = now();
465
+ if (at - lastCapLogAt >= REFUSAL_LOG_WINDOW_MS) {
466
+ lastCapLogAt = at;
467
+ opts.log(`local transfer: dropped a connection from ${address} — it already holds ${perIp} open connections`);
468
+ }
469
+ return;
470
+ }
471
+ socket.once('close', () => limiter.release(address));
472
+ });
473
+ let listening = false;
474
+ // Stays attached for the server's whole life: an `error` with no
475
+ // listener is an uncaught throw, and this socket must never be the
476
+ // thing that takes the relay-serving daemon down with it.
477
+ server.on('error', (err) => {
478
+ if (!listening) {
479
+ reject(err);
480
+ return;
481
+ }
482
+ opts.log(`local transfer: server error — ${err.message}`);
483
+ opts.onError?.(err);
484
+ });
485
+ server.listen(0, '0.0.0.0', () => {
486
+ listening = true;
487
+ const address = server.address();
488
+ if (typeof address !== 'object' || address === null) {
489
+ // Cannot happen after a TCP listen; if it does, publishing
490
+ // port 0 would only be refused by the server, silently.
491
+ server.close();
492
+ reject(new Error('bound socket reported no address'));
493
+ return;
494
+ }
495
+ const port = address.port;
496
+ opts.log(`local transfer: listening on 0.0.0.0:${port}`);
497
+ let closed = null;
498
+ const stop = () => {
499
+ if (closed)
500
+ return closed;
501
+ closed = new Promise((done) => {
502
+ server.close(() => done());
503
+ server.closeAllConnections();
504
+ });
505
+ return closed;
506
+ };
507
+ resolve({ port, stop });
508
+ });
509
+ });
510
+ exports.startLanReceiver = startLanReceiver;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Local transfer (ADR-0013) — the sending half; the contract is `lan-auth.ts`.
3
+ * Nothing calls it yet outside tests (`@hwip/mcp-server` has no copy; its
4
+ * `hwip_file` always goes by relay). The caller that wires it in must send
5
+ * to a published `lan` host (a private IPv4, see `isPrivateIpv4`) without
6
+ * the proxy: see `fetchFn` and backlog C78.
7
+ *
8
+ * A file already sealed for its recipients (`encryptFileForDevices`) goes
9
+ * straight to the one target device when that device says, in the device
10
+ * list the send already fetched, that it can be reached: a registered key,
11
+ * a published `lan` endpoint, online. Everything here answers
12
+ * `{ delivered: false, reason }` rather than throwing — the caller's relay
13
+ * path is the fallback for every failure. A device that is not there costs
14
+ * the ping timeout; one that answers the ping and then fails the upload
15
+ * costs up to the upload budget.
16
+ *
17
+ * What counts as delivered is a receipt the sender can check, not a status
18
+ * code: see `signLanReceipt`. A 201 without one is treated as not delivered,
19
+ * and the file goes by relay.
20
+ */
21
+ export interface LanDeviceRecord {
22
+ deviceId: string;
23
+ publicKey?: string;
24
+ isOnline?: boolean;
25
+ lan?: {
26
+ host?: unknown;
27
+ port?: unknown;
28
+ } | null;
29
+ }
30
+ export interface LanTarget {
31
+ deviceId: string;
32
+ publicKey: string;
33
+ host: string;
34
+ port: number;
35
+ }
36
+ /**
37
+ * The device a push can go to directly, or null for the relay: a push with
38
+ * no single target (a broadcast) never qualifies, nor does a target without
39
+ * a key, an endpoint, or a live connection.
40
+ */
41
+ export declare const pickLanTarget: (devices: LanDeviceRecord[], targetDeviceId: string | undefined) => LanTarget | null;
42
+ export interface LanSealedFile {
43
+ fileName: string;
44
+ fileType?: string;
45
+ /** Plaintext size, as the push record reports it. */
46
+ fileSize: number;
47
+ iv: string;
48
+ deviceKeyMap: Record<string, string>;
49
+ ciphertext: Buffer;
50
+ }
51
+ export interface LanDeliveryInput {
52
+ target: LanTarget;
53
+ senderDeviceId: string;
54
+ /** ECDH secret between this sender's private key and `publicKey` (raw 32 bytes). */
55
+ deriveSharedSecret: (publicKey: string) => Promise<Buffer>;
56
+ file: LanSealedFile;
57
+ fetchFn?: typeof fetch;
58
+ pingTimeoutMs?: number;
59
+ /** Upload budget; defaults to `lanUploadTimeoutMs(ciphertext.length)`. */
60
+ uploadTimeoutMs?: number;
61
+ /** The caller's own cancellation (a tool call the agent abandoned). */
62
+ signal?: AbortSignal;
63
+ }
64
+ export type LanDeliveryResult = {
65
+ delivered: true;
66
+ transferId: string;
67
+ } | {
68
+ delivered: false;
69
+ reason: string;
70
+ };
71
+ /** A receiver that is there answers a ping on the same network in milliseconds. */
72
+ export declare const LAN_PING_TIMEOUT_MS = 1500;
73
+ /** 30 s, plus one second per MiB — slower than any Wi-Fi a LAN transfer is worth trying on. */
74
+ export declare const lanUploadTimeoutMs: (bytes: number) => number;
75
+ export declare const tryLanDelivery: (input: LanDeliveryInput) => Promise<LanDeliveryResult>;
76
+ //# sourceMappingURL=lan-sender.d.ts.map