miki-moni 0.3.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/README.zh-CN.md +275 -0
  4. package/README.zh-TW.md +275 -0
  5. package/bin/miki.mjs +49 -0
  6. package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
  7. package/dist/web/assets/index--89DkyV1.css +1 -0
  8. package/dist/web/assets/index-CyPlxvOn.js +64 -0
  9. package/dist/web/index.html +20 -0
  10. package/dist/web/pair-info.html +138 -0
  11. package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
  12. package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
  13. package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
  14. package/dist/web-phone/index.html +20 -0
  15. package/hooks/miki-emit.ps1 +56 -0
  16. package/package.json +89 -0
  17. package/shared/i18n.ts +915 -0
  18. package/src/cli/i18n-cli.ts +149 -0
  19. package/src/cli/miki.ts +168 -0
  20. package/src/cli/pair.ts +534 -0
  21. package/src/cli/prompt.ts +6 -0
  22. package/src/cli/pushable-iter.ts +45 -0
  23. package/src/cli/setup-self-host.ts +292 -0
  24. package/src/cli/setup-wizard.ts +130 -0
  25. package/src/cli/wrap.ts +742 -0
  26. package/src/config.ts +121 -0
  27. package/src/crypto.ts +66 -0
  28. package/src/data-dir.ts +31 -0
  29. package/src/ext-registry.ts +47 -0
  30. package/src/hook-handler.ts +86 -0
  31. package/src/index.ts +279 -0
  32. package/src/install-hooks.ts +107 -0
  33. package/src/notifier.ts +21 -0
  34. package/src/pairing.ts +100 -0
  35. package/src/protocol-ext.ts +46 -0
  36. package/src/relay-client.ts +468 -0
  37. package/src/relay-protocol.ts +57 -0
  38. package/src/server.ts +1134 -0
  39. package/src/session-resolver.ts +437 -0
  40. package/src/session-store.ts +131 -0
  41. package/src/types.ts +33 -0
  42. package/src/vscode-bridge.ts +407 -0
  43. package/src/wrap-process.ts +183 -0
  44. package/tools/tray.ps1 +286 -0
  45. package/worker/package.json +24 -0
  46. package/worker/src/daemon-relay.ts +348 -0
  47. package/worker/src/env.ts +11 -0
  48. package/worker/src/handshake.ts +63 -0
  49. package/worker/src/index.ts +81 -0
  50. package/worker/src/pairing-code.ts +39 -0
  51. package/worker/src/pairing-coordinator.ts +145 -0
  52. package/worker/wrangler-selfhost.toml +36 -0
  53. package/worker/wrangler.toml +29 -0
@@ -0,0 +1,468 @@
1
+ import WebSocket from "ws";
2
+ import { fromBase64, toBase64, sign as signMsg, deriveSharedSecret } from "./crypto.js";
3
+ import { encodeEnvelope, decodeEnvelope, type Envelope, type Plaintext } from "./relay-protocol.js";
4
+ import { addPairedPeer, saveConfig, type Config, type PairedPeer } from "./config.js";
5
+ import type { SessionStore } from "./session-store.js";
6
+ import type { VscodeBridge } from "./vscode-bridge.js";
7
+ import type { Session } from "./types.js";
8
+ import type { Notifier } from "./notifier.js";
9
+ import { computePeerId } from "./pairing.js";
10
+ import { createHash } from "node:crypto";
11
+ import { readFileSync } from "node:fs";
12
+ import { PORT_FILE, CONFIG_FILE } from "./data-dir.js";
13
+
14
+ const RECONNECT_INITIAL_MS = 1000;
15
+ const RECONNECT_MAX_MS = 60_000;
16
+ const NONCE_FRESHNESS_MS = 60_000;
17
+ const DEFAULT_LOCAL_PORT = 8765;
18
+ const HTTP_PROXY_TIMEOUT_MS = 30_000;
19
+
20
+ export interface RelayClientDeps {
21
+ config: Config;
22
+ store: SessionStore;
23
+ bridge: VscodeBridge;
24
+ /** Optional notifier — fires when a new phone completes pairing so the user
25
+ * sees "+1 device" instead of silent permanent-QR pairs. */
26
+ notifier?: Notifier;
27
+ /** Where to persist newly-paired peers. Defaults to data-dir CONFIG_FILE.
28
+ * Override for tests. */
29
+ configPath?: string;
30
+ /** Override the localhost port the daemon's HTTP server is on (default 8765).
31
+ * Plumbed for tests; production reads from MIKI_LOCAL_PORT env if set. */
32
+ localHttpPort?: number;
33
+ }
34
+
35
+ interface PeerSecrets {
36
+ peer: PairedPeer;
37
+ sharedSecret: Uint8Array;
38
+ recentNonces: Map<string, number>; // nonce → seen-at ms
39
+ }
40
+
41
+ function buildChallengeMessage(nonce: Uint8Array, issued_at_ms: number): Uint8Array {
42
+ const out = new Uint8Array(nonce.length + 8);
43
+ out.set(nonce, 0);
44
+ new DataView(out.buffer, nonce.length, 8).setBigUint64(0, BigInt(issued_at_ms), false);
45
+ return out;
46
+ }
47
+
48
+ export class RelayClient {
49
+ private ws: WebSocket | null = null;
50
+ private stopRequested = false;
51
+ private reconnectMs = RECONNECT_INITIAL_MS;
52
+ private storeListener: ((s: Session) => void) | null = null;
53
+ private peers: PeerSecrets[] = [];
54
+ private ready = false;
55
+
56
+ constructor(private deps: RelayClientDeps) {
57
+ this.peers = deps.config.paired_peers.map((p) => ({
58
+ peer: p,
59
+ sharedSecret: fromBase64(p.shared_secret),
60
+ recentNonces: new Map(),
61
+ }));
62
+ }
63
+
64
+ async start(): Promise<void> {
65
+ if (!this.deps.config.remote) {
66
+ throw new Error("RelayClient cannot start without config.remote.worker_url");
67
+ }
68
+ this.stopRequested = false;
69
+ this.connect();
70
+ }
71
+
72
+ async stop(): Promise<void> {
73
+ this.stopRequested = true;
74
+ if (this.storeListener) {
75
+ this.deps.store.off("session_changed", this.storeListener);
76
+ this.storeListener = null;
77
+ }
78
+ if (this.ws) {
79
+ this.ws.close();
80
+ this.ws = null;
81
+ }
82
+ }
83
+
84
+ private daemonIdHex(): string {
85
+ const pub = fromBase64(this.deps.config.device.signing_pubkey);
86
+ return createHash("sha256").update(pub).digest("hex").slice(0, 32);
87
+ }
88
+
89
+ private connect(): void {
90
+ const remote = this.deps.config.remote!;
91
+ // Use worker_url AS-IS (test passes ws://localhost:N, prod passes wss://relay.f1telemetrystationpro.org)
92
+ // Append /v1/daemon if not already in the URL.
93
+ const baseUrl = remote.worker_url.replace(/\/$/, "");
94
+ const url = baseUrl.includes("/v1/") ? baseUrl : `${baseUrl}/v1/daemon`;
95
+ const headers: Record<string, string> = {
96
+ "X-Daemon-Pubkey": this.deps.config.device.signing_pubkey,
97
+ "X-Daemon-Enc-Pubkey": this.deps.config.device.pubkey, // X25519, for phone ECDH
98
+ "X-Daemon-Id": this.daemonIdHex(),
99
+ };
100
+ const ws = new WebSocket(url, { headers });
101
+ this.ws = ws;
102
+ this.ready = false;
103
+
104
+ ws.on("open", () => { /* wait for challenge from server */ });
105
+ ws.on("message", (raw) => this.handleMessage(raw.toString()));
106
+ ws.on("close", () => this.handleClose());
107
+ ws.on("error", () => { /* swallow; close handler reconnects */ });
108
+ }
109
+
110
+ private handleMessage(text: string): void {
111
+ let msg: any;
112
+ try { msg = JSON.parse(text); } catch { return; }
113
+
114
+ if (!this.ready) {
115
+ if (msg.type === "challenge") {
116
+ const nonce = fromBase64(msg.nonce);
117
+ const sigMsg = buildChallengeMessage(nonce, msg.issued_at_ms);
118
+ const priv = fromBase64(this.deps.config.device.signing_privkey);
119
+ const sig = signMsg(sigMsg, priv);
120
+ this.ws!.send(JSON.stringify({ type: "challenge_response", sig: toBase64(sig) }));
121
+ return;
122
+ }
123
+ if (msg.type === "ready") {
124
+ this.ready = true;
125
+ this.reconnectMs = RECONNECT_INITIAL_MS;
126
+ this.storeListener = (session: Session) => this.broadcastEvent(session);
127
+ this.deps.store.on("session_changed", this.storeListener);
128
+ // Re-register the persistent pair token (if configured). This keeps
129
+ // the QR alive across daemon restarts and ensures the relay
130
+ // coordinator has the entry after its own state resets.
131
+ const persistToken = this.deps.config.remote?.pair_token;
132
+ if (persistToken && this.ws) {
133
+ this.ws.send(JSON.stringify({
134
+ type: "register_pairing",
135
+ token: persistToken,
136
+ persistent: true,
137
+ }));
138
+ }
139
+ return;
140
+ }
141
+ // Drop any other messages received before ready
142
+ return;
143
+ }
144
+
145
+ // Post-ready: envelope routing
146
+ if (msg.type === "envelope") {
147
+ this.handleEnvelope(msg as Envelope);
148
+ return;
149
+ }
150
+
151
+ // Phone scanned the persistent QR — handle pairing here so the daemon
152
+ // doesn't need a separate `pnpm pair --new` running.
153
+ if (msg.type === "pair_offer") {
154
+ void this.handlePairOffer(msg);
155
+ return;
156
+ }
157
+
158
+ // Phone (or daemon-side CLI) revoked a paired phone — relay forwards.
159
+ // Mirror in local config so the next restart's peers list is clean.
160
+ if (msg.type === "phone_revoked") {
161
+ void this.handlePhoneRevoked(msg);
162
+ return;
163
+ }
164
+
165
+ // Legacy: envelopes sent without a wrapper type field (current relay sends plain Envelope objects)
166
+ if (typeof msg.ts === "number" && typeof msg.nonce === "string") {
167
+ this.handleEnvelope(msg as Envelope);
168
+ }
169
+ }
170
+
171
+ // ── Permanent-QR pairing ────────────────────────────────────────────────────
172
+ // When a phone scans the persistent QR, the worker forwards its pair_offer
173
+ // here. We derive the shared secret, persist the new peer, and ack — same
174
+ // logic the one-shot `pnpm pair --new` CLI used to do.
175
+
176
+ private daemonIdHex_cached: string | null = null;
177
+ private daemonId(): string {
178
+ if (!this.daemonIdHex_cached) this.daemonIdHex_cached = this.daemonIdHex();
179
+ return this.daemonIdHex_cached;
180
+ }
181
+
182
+ private async handlePairOffer(msg: any): Promise<void> {
183
+ const phonePubB64 = typeof msg.phone_pubkey === "string" ? msg.phone_pubkey : "";
184
+ if (!phonePubB64) return;
185
+ const phoneSignPubB64 = typeof msg.phone_sign_pubkey === "string" ? msg.phone_sign_pubkey : undefined;
186
+
187
+ // Idempotency: if we've already paired this phone (same X25519 pubkey),
188
+ // just re-ack — phone may have lost local state and is re-pairing.
189
+ const peer_id = computePeerId(phonePubB64);
190
+ const existing = this.deps.config.paired_peers.find((p) => p.peer_id === peer_id);
191
+ if (existing) {
192
+ if (this.ws) {
193
+ this.ws.send(JSON.stringify({ type: "pair_ack", daemon_id: this.daemonId() }));
194
+ }
195
+ return;
196
+ }
197
+
198
+ const phonePub = fromBase64(phonePubB64);
199
+ const encPriv = fromBase64(this.deps.config.device.privkey);
200
+ const sharedSecret = deriveSharedSecret(encPriv, phonePub);
201
+
202
+ const peer: PairedPeer = {
203
+ peer_id,
204
+ peer_name: typeof msg.phone_name === "string" ? msg.phone_name : "phone",
205
+ peer_pubkey: phonePubB64,
206
+ peer_sign_pubkey: phoneSignPubB64,
207
+ shared_secret: toBase64(sharedSecret),
208
+ paired_at: Date.now(),
209
+ last_seen_at: null,
210
+ };
211
+
212
+ // Mutate in-memory config + persist to disk.
213
+ this.deps.config = addPairedPeer(this.deps.config, peer);
214
+ try {
215
+ await saveConfig(this.deps.configPath ?? CONFIG_FILE, this.deps.config);
216
+ } catch { /* swallow — pair_ack still goes out, next restart will rebuild */ }
217
+
218
+ // Hot-add to peers so envelopes from this phone start decrypting immediately.
219
+ this.peers.push({ peer, sharedSecret, recentNonces: new Map() });
220
+
221
+ if (this.ws) {
222
+ this.ws.send(JSON.stringify({ type: "pair_ack", daemon_id: this.daemonId() }));
223
+ }
224
+
225
+ void this.deps.notifier?.notify({
226
+ project: "miki-moni",
227
+ message: `New device paired: ${peer.peer_name} (${peer_id.slice(0, 8)}…)`,
228
+ });
229
+ }
230
+
231
+ private async handlePhoneRevoked(msg: any): Promise<void> {
232
+ const signPk = typeof msg.phone_pubkey_b64 === "string" ? msg.phone_pubkey_b64 : "";
233
+ if (!signPk) return;
234
+ const before = this.deps.config.paired_peers.length;
235
+ this.deps.config = {
236
+ ...this.deps.config,
237
+ paired_peers: this.deps.config.paired_peers.filter((p) => p.peer_sign_pubkey !== signPk),
238
+ };
239
+ if (this.deps.config.paired_peers.length === before) return;
240
+ this.peers = this.peers.filter((p) => p.peer.peer_sign_pubkey !== signPk);
241
+ try {
242
+ await saveConfig(this.deps.configPath ?? CONFIG_FILE, this.deps.config);
243
+ } catch { /* swallow */ }
244
+ void this.deps.notifier?.notify({
245
+ project: "miki-moni",
246
+ message: `Device unpaired (${signPk.slice(0, 8)}…)`,
247
+ });
248
+ }
249
+
250
+ private handleClose(): void {
251
+ if (this.storeListener) {
252
+ this.deps.store.off("session_changed", this.storeListener);
253
+ this.storeListener = null;
254
+ }
255
+ this.ws = null;
256
+ this.ready = false;
257
+ if (this.stopRequested) return;
258
+ const wait = this.reconnectMs;
259
+ this.reconnectMs = Math.min(this.reconnectMs * 2, RECONNECT_MAX_MS);
260
+ setTimeout(() => this.connect(), wait);
261
+ }
262
+
263
+ private handleEnvelope(env: Envelope): void {
264
+ // Freshness check
265
+ if (typeof env.ts !== "number" || Math.abs(Date.now() - env.ts) > NONCE_FRESHNESS_MS) return;
266
+
267
+ // Try each peer's secret until one decrypts. (Phone could send before identifying themselves.)
268
+ for (const p of this.peers) {
269
+ // Replay check
270
+ if (p.recentNonces.has(env.nonce)) continue;
271
+ const pt = decodeEnvelope(env, p.sharedSecret);
272
+ if (!pt) continue;
273
+ // Accept
274
+ p.recentNonces.set(env.nonce, Date.now());
275
+ this.pruneNonces(p);
276
+ void this.dispatchPlaintext(pt, p);
277
+ return;
278
+ }
279
+ // No peer could decrypt — drop silently
280
+ }
281
+
282
+ private pruneNonces(p: PeerSecrets): void {
283
+ const cutoff = Date.now() - NONCE_FRESHNESS_MS;
284
+ for (const [n, t] of p.recentNonces) {
285
+ if (t < cutoff) p.recentNonces.delete(n);
286
+ }
287
+ }
288
+
289
+ private resolveCmdSession(pt: { session_uuid?: string; cwd?: string }) {
290
+ if (pt.session_uuid) return this.deps.store.get(pt.session_uuid);
291
+ if (pt.cwd) return this.deps.store.getByCwd(pt.cwd)[0];
292
+ return undefined;
293
+ }
294
+
295
+ private async dispatchPlaintext(pt: Plaintext, p: PeerSecrets): Promise<void> {
296
+ switch (pt.kind) {
297
+ case "cmd_focus": {
298
+ const session = this.resolveCmdSession(pt);
299
+ if (session) await this.deps.bridge.focus(session.session_uuid);
300
+ return;
301
+ }
302
+ case "cmd_send": {
303
+ const session = this.resolveCmdSession(pt);
304
+ if (session) await this.deps.bridge.send(session.session_uuid, pt.prompt);
305
+ return;
306
+ }
307
+ case "request_snapshot": {
308
+ const snapshot = { kind: "state_snapshot" as const, sessions: this.deps.store.list() };
309
+ this.sendToPeer(p, snapshot);
310
+ return;
311
+ }
312
+ case "ping": {
313
+ this.sendToPeer(p, { kind: "pong", echo: pt.echo });
314
+ return;
315
+ }
316
+ case "http_proxy": {
317
+ await this.handleHttpProxy(pt, p);
318
+ return;
319
+ }
320
+ case "ws_proxy_open": {
321
+ await this.handleWsProxyOpen(pt, p);
322
+ return;
323
+ }
324
+ case "ws_proxy_send": {
325
+ this.handleWsProxySend(pt, p);
326
+ return;
327
+ }
328
+ case "ws_proxy_close": {
329
+ this.handleWsProxyClose(pt, p);
330
+ return;
331
+ }
332
+ default:
333
+ return; // ignore other kinds for now
334
+ }
335
+ }
336
+
337
+ // ── Remote RPC tunnel ──────────────────────────────────────────────────────
338
+ // server.ts is unchanged — these handlers re-issue calls against the same
339
+ // localhost endpoints the local dashboard uses, then ship the response back
340
+ // through the encrypted relay channel.
341
+
342
+ private localPort(): number {
343
+ if (this.deps.localHttpPort) return this.deps.localHttpPort;
344
+ const env = Number(process.env.MIKI_LOCAL_PORT);
345
+ if (Number.isFinite(env) && env > 0) return env;
346
+ // Daemon writes the chosen port (8765 + n if taken) into ~/.miki-moni/port
347
+ // at startup. Read it so the tunnel proxy hits whatever port the local
348
+ // server is actually on, without needing env var configuration.
349
+ try {
350
+ const p = Number(readFileSync(PORT_FILE, "utf8").trim());
351
+ if (Number.isFinite(p) && p > 0) return p;
352
+ } catch { /* file may not exist yet during early startup */ }
353
+ return DEFAULT_LOCAL_PORT;
354
+ }
355
+
356
+ private async handleHttpProxy(
357
+ pt: Extract<Plaintext, { kind: "http_proxy" }>,
358
+ p: PeerSecrets,
359
+ ): Promise<void> {
360
+ const url = `http://127.0.0.1:${this.localPort()}${pt.path}`;
361
+ const ctrl = new AbortController();
362
+ const timeout = setTimeout(() => ctrl.abort(), HTTP_PROXY_TIMEOUT_MS);
363
+ try {
364
+ const res = await fetch(url, {
365
+ method: pt.method,
366
+ headers: pt.headers ?? undefined,
367
+ body: pt.body ?? undefined,
368
+ signal: ctrl.signal,
369
+ });
370
+ const text = await res.text();
371
+ const headers: Record<string, string> = {};
372
+ res.headers.forEach((v, k) => { headers[k] = v; });
373
+ this.sendToPeer(p, {
374
+ kind: "http_proxy_response",
375
+ request_id: pt.request_id,
376
+ status: res.status,
377
+ headers,
378
+ body: text,
379
+ });
380
+ } catch (err) {
381
+ this.sendToPeer(p, {
382
+ kind: "http_proxy_response",
383
+ request_id: pt.request_id,
384
+ status: 502,
385
+ headers: { "content-type": "text/plain" },
386
+ body: `tunnel_error: ${(err as Error).message}`,
387
+ });
388
+ } finally {
389
+ clearTimeout(timeout);
390
+ }
391
+ }
392
+
393
+ /** tunnel_ws_id -> local WebSocket connection, scoped per peer. */
394
+ private tunnelWsByPeer = new Map<string, Map<string, WebSocket>>();
395
+
396
+ private peerTunnels(p: PeerSecrets): Map<string, WebSocket> {
397
+ let m = this.tunnelWsByPeer.get(p.peer.peer_id);
398
+ if (!m) { m = new Map(); this.tunnelWsByPeer.set(p.peer.peer_id, m); }
399
+ return m;
400
+ }
401
+
402
+ private async handleWsProxyOpen(
403
+ pt: Extract<Plaintext, { kind: "ws_proxy_open" }>,
404
+ p: PeerSecrets,
405
+ ): Promise<void> {
406
+ const url = `ws://127.0.0.1:${this.localPort()}${pt.path}`;
407
+ const localWs = new WebSocket(url);
408
+ this.peerTunnels(p).set(pt.tunnel_ws_id, localWs);
409
+ localWs.on("open", () => {
410
+ this.sendToPeer(p, { kind: "ws_proxy_opened", tunnel_ws_id: pt.tunnel_ws_id });
411
+ });
412
+ localWs.on("message", (raw) => {
413
+ this.sendToPeer(p, {
414
+ kind: "ws_proxy_msg",
415
+ tunnel_ws_id: pt.tunnel_ws_id,
416
+ data: raw.toString(),
417
+ });
418
+ });
419
+ localWs.on("close", (code, reason) => {
420
+ this.peerTunnels(p).delete(pt.tunnel_ws_id);
421
+ this.sendToPeer(p, {
422
+ kind: "ws_proxy_close",
423
+ tunnel_ws_id: pt.tunnel_ws_id,
424
+ code, reason: reason?.toString() ?? "",
425
+ });
426
+ });
427
+ localWs.on("error", (err) => {
428
+ this.peerTunnels(p).delete(pt.tunnel_ws_id);
429
+ this.sendToPeer(p, {
430
+ kind: "ws_proxy_close",
431
+ tunnel_ws_id: pt.tunnel_ws_id,
432
+ code: 1011,
433
+ reason: `tunnel_error: ${(err as Error).message}`,
434
+ });
435
+ });
436
+ }
437
+
438
+ private handleWsProxySend(
439
+ pt: Extract<Plaintext, { kind: "ws_proxy_send" }>,
440
+ p: PeerSecrets,
441
+ ): void {
442
+ const local = this.peerTunnels(p).get(pt.tunnel_ws_id);
443
+ if (!local || local.readyState !== WebSocket.OPEN) return;
444
+ local.send(pt.data, (_err) => { /* swallow */ });
445
+ }
446
+
447
+ private handleWsProxyClose(
448
+ pt: Extract<Plaintext, { kind: "ws_proxy_close" }>,
449
+ p: PeerSecrets,
450
+ ): void {
451
+ const local = this.peerTunnels(p).get(pt.tunnel_ws_id);
452
+ if (!local) return;
453
+ this.peerTunnels(p).delete(pt.tunnel_ws_id);
454
+ try { local.close(pt.code ?? 1000, pt.reason ?? ""); } catch { /* ignore */ }
455
+ }
456
+
457
+ private broadcastEvent(session: Session): void {
458
+ for (const p of this.peers) {
459
+ this.sendToPeer(p, { kind: "event", session });
460
+ }
461
+ }
462
+
463
+ private sendToPeer(p: PeerSecrets, msg: Plaintext): void {
464
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
465
+ const env = encodeEnvelope(msg, p.sharedSecret, `phone:${p.peer.peer_id}`);
466
+ this.ws.send(JSON.stringify(env), (_err) => { /* swallow */ });
467
+ }
468
+ }
@@ -0,0 +1,57 @@
1
+ import { encrypt, decrypt } from "./crypto.js";
2
+ import type { Session } from "./types.js";
3
+
4
+ export const PROTOCOL_VERSION = 1;
5
+
6
+ export interface Envelope {
7
+ v: number;
8
+ to: string; // "daemon" | `phone:${peer_id}`
9
+ ct: string; // base64
10
+ nonce: string; // base64 (24 bytes)
11
+ ts: number; // sender unix ms
12
+ }
13
+
14
+ // Plaintext kinds (after decryption)
15
+ export type Plaintext =
16
+ | { kind: "event"; session: Session }
17
+ | { kind: "state_snapshot"; sessions: Session[] }
18
+ | { kind: "cmd_focus"; session_uuid?: string; cwd?: string }
19
+ | { kind: "cmd_send"; session_uuid?: string; cwd?: string; prompt: string }
20
+ | { kind: "request_snapshot" }
21
+ | { kind: "ping"; echo: string }
22
+ | { kind: "pong"; echo: string }
23
+ | { kind: "pair_offer"; phone_pk: string; phone_name: string }
24
+ | { kind: "pair_ack"; ok: boolean }
25
+ | { kind: "pair_reject"; reason: string }
26
+ // ─── Remote RPC tunnel — phone & remote-web clients proxy local server.ts ───
27
+ | { kind: "http_proxy"; request_id: string; method: string; path: string; headers?: Record<string, string>; body?: string }
28
+ | { kind: "http_proxy_response"; request_id: string; status: number; headers: Record<string, string>; body: string }
29
+ | { kind: "ws_proxy_open"; tunnel_ws_id: string; path: string }
30
+ | { kind: "ws_proxy_opened"; tunnel_ws_id: string }
31
+ | { kind: "ws_proxy_msg"; tunnel_ws_id: string; data: string }
32
+ | { kind: "ws_proxy_send"; tunnel_ws_id: string; data: string }
33
+ | { kind: "ws_proxy_close"; tunnel_ws_id: string; code?: number; reason?: string };
34
+
35
+ export function encodeEnvelope(
36
+ plaintext: Plaintext,
37
+ sharedSecret: Uint8Array,
38
+ to: string,
39
+ ): Envelope {
40
+ const json = JSON.stringify(plaintext);
41
+ const { ct, nonce } = encrypt(json, sharedSecret);
42
+ return { v: PROTOCOL_VERSION, to, ct, nonce, ts: Date.now() };
43
+ }
44
+
45
+ export function decodeEnvelope(env: Envelope, sharedSecret: Uint8Array): Plaintext | null {
46
+ if (env.v !== PROTOCOL_VERSION) return null;
47
+ const json = decrypt(env.ct, env.nonce, sharedSecret);
48
+ if (json === null) return null;
49
+ try {
50
+ const parsed = JSON.parse(json);
51
+ if (typeof parsed !== "object" || parsed === null) return null;
52
+ if (typeof parsed.kind !== "string") return null;
53
+ return parsed as Plaintext;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }