@linkshell/gateway 0.5.3 → 0.6.0

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 (109) hide show
  1. package/Dockerfile +8 -22
  2. package/README.md +99 -44
  3. package/dist/gateway/src/accounts.js.map +1 -0
  4. package/dist/gateway/src/index.d.ts +4 -1
  5. package/dist/gateway/src/index.js +4 -863
  6. package/dist/gateway/src/index.js.map +1 -1
  7. package/dist/gateway/src/main.js +57 -0
  8. package/dist/gateway/src/main.js.map +1 -0
  9. package/dist/gateway/src/rate-limit.d.ts +22 -0
  10. package/dist/gateway/src/rate-limit.js +66 -0
  11. package/dist/gateway/src/rate-limit.js.map +1 -0
  12. package/dist/gateway/src/relay.d.ts +84 -5
  13. package/dist/gateway/src/relay.js +402 -529
  14. package/dist/gateway/src/relay.js.map +1 -1
  15. package/dist/gateway/src/serve.d.ts +34 -0
  16. package/dist/gateway/src/serve.js +102 -0
  17. package/dist/gateway/src/serve.js.map +1 -0
  18. package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
  19. package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
  20. package/dist/gateway/src/store.js.map +1 -0
  21. package/dist/gateway/src/subscription.d.ts +18 -0
  22. package/dist/gateway/src/subscription.js +46 -0
  23. package/dist/gateway/src/subscription.js.map +1 -0
  24. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  25. package/dist/wire/src/direct.d.ts +22 -0
  26. package/dist/wire/src/direct.js +37 -0
  27. package/dist/wire/src/direct.js.map +1 -0
  28. package/dist/wire/src/index.d.ts +1 -0
  29. package/dist/wire/src/index.js +1 -0
  30. package/dist/wire/src/index.js.map +1 -1
  31. package/dist/wire/src/model.d.ts +287 -6
  32. package/dist/wire/src/model.js +33 -0
  33. package/dist/wire/src/model.js.map +1 -1
  34. package/dist/wire/src/relay.d.ts +8 -8
  35. package/dist/wire/src/rpc.d.ts +1780 -44
  36. package/dist/wire/src/rpc.js +48 -3
  37. package/dist/wire/src/rpc.js.map +1 -1
  38. package/dist/wire/src/updates.d.ts +356 -0
  39. package/dist/wire/src/updates.js +11 -1
  40. package/dist/wire/src/updates.js.map +1 -1
  41. package/package.json +12 -12
  42. package/src/accounts.ts +32 -0
  43. package/src/index.ts +4 -997
  44. package/src/main.ts +65 -0
  45. package/src/rate-limit.ts +72 -0
  46. package/src/relay.ts +435 -620
  47. package/src/serve.ts +130 -0
  48. package/src/store.ts +135 -0
  49. package/src/subscription.ts +51 -0
  50. package/dist/gateway/src/auth-middleware.d.ts +0 -44
  51. package/dist/gateway/src/auth-middleware.js +0 -204
  52. package/dist/gateway/src/auth-middleware.js.map +0 -1
  53. package/dist/gateway/src/cors.d.ts +0 -2
  54. package/dist/gateway/src/cors.js +0 -7
  55. package/dist/gateway/src/cors.js.map +0 -1
  56. package/dist/gateway/src/embedded.d.ts +0 -21
  57. package/dist/gateway/src/embedded.js +0 -438
  58. package/dist/gateway/src/embedded.js.map +0 -1
  59. package/dist/gateway/src/host-auth.d.ts +0 -32
  60. package/dist/gateway/src/host-auth.js +0 -78
  61. package/dist/gateway/src/host-auth.js.map +0 -1
  62. package/dist/gateway/src/pairings.d.ts +0 -38
  63. package/dist/gateway/src/pairings.js +0 -119
  64. package/dist/gateway/src/pairings.js.map +0 -1
  65. package/dist/gateway/src/presence.d.ts +0 -28
  66. package/dist/gateway/src/presence.js +0 -92
  67. package/dist/gateway/src/presence.js.map +0 -1
  68. package/dist/gateway/src/sessions.d.ts +0 -113
  69. package/dist/gateway/src/sessions.js +0 -352
  70. package/dist/gateway/src/sessions.js.map +0 -1
  71. package/dist/gateway/src/state-store.d.ts +0 -21
  72. package/dist/gateway/src/state-store.js +0 -103
  73. package/dist/gateway/src/state-store.js.map +0 -1
  74. package/dist/gateway/src/static-web.d.ts +0 -15
  75. package/dist/gateway/src/static-web.js +0 -129
  76. package/dist/gateway/src/static-web.js.map +0 -1
  77. package/dist/gateway/src/tokens.d.ts +0 -19
  78. package/dist/gateway/src/tokens.js +0 -151
  79. package/dist/gateway/src/tokens.js.map +0 -1
  80. package/dist/gateway/src/tunnel.d.ts +0 -61
  81. package/dist/gateway/src/tunnel.js +0 -576
  82. package/dist/gateway/src/tunnel.js.map +0 -1
  83. package/dist/gateway-v2/src/accounts.js.map +0 -1
  84. package/dist/gateway-v2/src/index.d.ts +0 -3
  85. package/dist/gateway-v2/src/index.js +0 -4
  86. package/dist/gateway-v2/src/index.js.map +0 -1
  87. package/dist/gateway-v2/src/main.js +0 -17
  88. package/dist/gateway-v2/src/main.js.map +0 -1
  89. package/dist/gateway-v2/src/server.d.ts +0 -84
  90. package/dist/gateway-v2/src/server.js +0 -426
  91. package/dist/gateway-v2/src/server.js.map +0 -1
  92. package/dist/gateway-v2/src/store.js.map +0 -1
  93. package/dist/shared-protocol/src/index.d.ts +0 -17029
  94. package/dist/shared-protocol/src/index.js +0 -1064
  95. package/dist/shared-protocol/src/index.js.map +0 -1
  96. package/src/auth-middleware.ts +0 -266
  97. package/src/cors.ts +0 -8
  98. package/src/embedded.ts +0 -526
  99. package/src/host-auth.ts +0 -88
  100. package/src/pairings.ts +0 -133
  101. package/src/presence.ts +0 -118
  102. package/src/sessions.ts +0 -432
  103. package/src/state-store.ts +0 -140
  104. package/src/static-web.ts +0 -133
  105. package/src/tokens.ts +0 -159
  106. package/src/tunnel.ts +0 -644
  107. /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
  108. /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
  109. /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
package/src/relay.ts CHANGED
@@ -1,669 +1,484 @@
1
- import type WebSocket from "ws";
1
+ import { createServer, type IncomingMessage, type Server } from "node:http";
2
+ import { randomBytes, randomInt, randomUUID } from "node:crypto";
3
+ import type { Duplex } from "node:stream";
4
+ import WebSocket, { WebSocketServer } from "ws";
2
5
  import {
3
- agentV2MessageRoute,
4
- isProtocolVersionCompatible,
5
- parseEnvelope,
6
- parseTypedPayload,
7
- protocolMessageSchemas,
8
- serializeEnvelope,
9
- createEnvelope,
10
- } from "@linkshell/protocol";
11
- import type { Envelope, ProtocolMessageType } from "@linkshell/protocol";
12
- import { ZodError } from "zod";
13
- import type { SessionManager, ConnectedDevice } from "./sessions.js";
14
- import {
15
- handleTunnelResponse,
16
- handleTunnelWsData,
17
- handleTunnelWsClose,
18
- } from "./tunnel.js";
19
-
20
- export function handleSocketMessage(
21
- socket: WebSocket,
22
- raw: string,
23
- role: "host" | "client",
24
- sessionId: string,
25
- deviceId: string,
26
- sessions: SessionManager,
27
- ): void {
28
- let envelope: Envelope;
29
- try {
30
- envelope = parseEnvelope(raw);
31
- } catch {
32
- sendSessionError(socket, sessionId, "invalid_message", "Failed to parse envelope");
33
- return;
34
- }
35
-
36
- if (envelope.sessionId !== sessionId) {
37
- sendSessionError(
38
- socket,
39
- sessionId,
40
- "invalid_message",
41
- "Envelope sessionId does not match connection sessionId",
42
- );
43
- return;
44
- }
45
-
46
- const session = sessions.get(sessionId);
47
- if (!session) {
48
- sendSessionError(socket, sessionId, "session_not_found", "Session not found");
49
- return;
50
- }
6
+ clientFrameSchema,
7
+ gatewayChallengeMessage,
8
+ idOf,
9
+ RELAY_PATH,
10
+ verifySignature,
11
+ type DeviceEntry,
12
+ type MachineEntry,
13
+ type PeerRole,
14
+ type RelayEvents,
15
+ type ServerFrame,
16
+ } from "@linkshell/wire";
17
+ import type { VerifyToken } from "./accounts.js";
18
+ import { GatewayStore, type PeerRecord } from "./store.js";
51
19
 
52
- try {
53
- if (shouldValidatePayloadAtGateway(envelope.type)) {
54
- envelope = {
55
- ...envelope,
56
- payload: parseTypedPayload(envelope.type, envelope.payload),
57
- };
58
- }
59
-
60
- if (role === "host") {
61
- handleHostMessage(envelope, session, sessions);
62
- } else {
63
- handleClientMessage(envelope, socket, session, deviceId, sessions);
64
- }
65
- } catch (error) {
66
- if (error instanceof ZodError) {
67
- sendSessionError(
68
- socket,
69
- sessionId,
70
- "invalid_message",
71
- error.errors[0]?.message ?? "Invalid message payload",
72
- );
73
- return;
74
- }
75
- sendSessionError(socket, sessionId, "invalid_message", "Failed to handle message");
76
- }
20
+ export interface GatewayOptions {
21
+ port: number;
22
+ host?: string;
23
+ databasePath: string;
24
+ /** Validates account tokens; without it, accounts are off and only pairing works. */
25
+ verifyToken?: VerifyToken;
26
+ log?: (message: string) => void;
27
+ /** Called with every frame the gateway routes, for tests that check it only sees ciphertext. */
28
+ onRouted?: (frame: string) => void;
29
+ pairingTtlMs?: number;
30
+ heartbeatMs?: number;
31
+ /**
32
+ * Final say on a connecting peer, after its key (and token, if any) checked
33
+ * out: return a message to refuse it. The official gateway uses this to
34
+ * require a subscription for computers.
35
+ */
36
+ admit?: (peer: { role: PeerRole; userId?: string }) => Promise<string | undefined>;
37
+ /**
38
+ * How much may wait to be written to one peer before the gateway stops
39
+ * reading from whoever is sending to it (default 4 MB), and how long a peer
40
+ * may stay that far behind before it is dropped (default 60 s).
41
+ */
42
+ maxBufferedBytes?: number;
43
+ stalledPeerMs?: number;
77
44
  }
78
45
 
79
- function isProtocolMessageType(type: string): type is ProtocolMessageType {
80
- return Object.prototype.hasOwnProperty.call(protocolMessageSchemas, type);
46
+ interface Peer {
47
+ record: PeerRecord;
48
+ socket: WebSocket;
49
+ email?: string;
50
+ /** Not being read: a peer it sends to can't keep up. */
51
+ held?: boolean;
81
52
  }
82
53
 
83
- function shouldValidatePayloadAtGateway(type: string): type is ProtocolMessageType {
84
- if (agentV2MessageRoute(type) !== null) return false;
85
- return isProtocolMessageType(type);
54
+ interface Offer {
55
+ code: string;
56
+ machineId: string;
57
+ expiresAt: number;
58
+ attempts: number;
86
59
  }
87
60
 
88
- function sendSessionError(
89
- socket: WebSocket,
90
- sessionId: string,
91
- code: string,
92
- message: string,
93
- ): void {
94
- if (socket.readyState !== socket.OPEN) return;
95
- socket.send(
96
- serializeEnvelope(
97
- createEnvelope({
98
- type: "session.error",
99
- sessionId,
100
- payload: { code, message },
101
- }),
102
- ),
103
- );
61
+ class RelayError extends Error {
62
+ constructor(
63
+ readonly code: string,
64
+ message: string,
65
+ ) {
66
+ super(message);
67
+ }
104
68
  }
105
69
 
106
- type AgentConversationLike = {
107
- id: string;
108
- status?: string;
109
- provider?: string;
110
- title?: string;
111
- lastMessagePreview?: string;
112
- lastActivityAt?: number;
113
- usage?: {
114
- inputTokens?: number;
115
- outputTokens?: number;
116
- cacheReadTokens?: number;
117
- totalTokens?: number;
118
- contextWindow?: number;
119
- totalCostUsd?: number;
120
- };
121
- };
122
-
123
- function truncateDetail(text: string, max = 72): string {
124
- const line = text.replace(/\s+/g, " ").trim();
125
- return line.length > max ? `${line.slice(0, max - 1)}…` : line;
126
- }
70
+ const AUTH_TIMEOUT_MS = 10_000;
71
+ const PAIR_DECISION_MS = 30_000;
72
+ const MAX_CLAIM_ATTEMPTS = 8;
127
73
 
128
- function commandOrPathFromToolInput(toolInput?: string): { command?: string; path?: string } {
129
- if (!toolInput) return {};
130
- try {
131
- const parsed = JSON.parse(toolInput) as Record<string, unknown>;
132
- if (parsed && typeof parsed === "object") {
133
- return {
134
- command: typeof parsed.command === "string" ? parsed.command : undefined,
135
- path: typeof parsed.path === "string"
136
- ? parsed.path
137
- : typeof parsed.file_path === "string"
138
- ? parsed.file_path
139
- : undefined,
140
- };
141
- }
142
- } catch {
143
- const match = toolInput.match(/\$\s+([^\n]+)/);
144
- if (match?.[1]) return { command: match[1].trim() };
74
+ /**
75
+ * Gateway v2: authenticates peers by key, routes their end-to-end encrypted
76
+ * frames to peers they may reach (paired, or same account), and brokers
77
+ * pairing. It has no way to read what it routes.
78
+ */
79
+ export class Gateway {
80
+ readonly store: GatewayStore;
81
+ private server?: Server;
82
+ private readonly peers = new Map<string, Peer>();
83
+ private readonly offers = new Map<string, Offer>();
84
+ private readonly decisions = new Map<string, { machineId: string; resolve: (accept: boolean) => void }>();
85
+ private readonly claimTimes = new Map<string, number[]>();
86
+ private heartbeat?: ReturnType<typeof setInterval>;
87
+ /** Sockets that answered the last ping. */
88
+ private readonly alive = new WeakSet<WebSocket>();
89
+ private stopped = false;
90
+ private readonly log: (message: string) => void;
91
+
92
+ constructor(private readonly options: GatewayOptions) {
93
+ this.store = new GatewayStore(options.databasePath);
94
+ this.log = options.log ?? ((message) => process.stderr.write(`${new Date().toISOString()} [gateway] ${message}\n`));
145
95
  }
146
- return {};
147
- }
148
96
 
149
- /** List-line for a live permission request: "运行命令 · pnpm test". */
150
- function permissionAttentionDetail(p: {
151
- toolName?: string;
152
- toolInput?: string;
153
- context?: string;
154
- }): string | undefined {
155
- const { command, path } = commandOrPathFromToolInput(p.toolInput);
156
- const name = (p.toolName ?? "").toLowerCase();
157
- if (command) {
158
- return truncateDetail(
159
- /bash|shell|command/.test(name) || !p.toolName
160
- ? `运行命令 · ${command}`
161
- : `${p.toolName} · ${command}`,
162
- );
97
+ private readonly wss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: 48 * 1024 * 1024 });
98
+
99
+ /** Runs on its own port. */
100
+ async start(): Promise<number> {
101
+ const server = createServer((request, response) => {
102
+ if (request.url === "/healthz") {
103
+ response.writeHead(200, { "content-type": "application/json" });
104
+ response.end(JSON.stringify({ ok: true, peers: this.peers.size }));
105
+ return;
106
+ }
107
+ response.writeHead(404);
108
+ response.end();
109
+ });
110
+ server.on("upgrade", (request: IncomingMessage, socket, head) => {
111
+ if (!request.url?.startsWith(RELAY_PATH)) {
112
+ socket.destroy();
113
+ return;
114
+ }
115
+ this.handleUpgrade(request, socket, head);
116
+ });
117
+ this.server = server;
118
+ await new Promise<void>((resolve) => server.listen(this.options.port, this.options.host ?? "0.0.0.0", resolve));
119
+ this.attach();
120
+ const address = server.address();
121
+ return typeof address === "object" && address ? address.port : this.options.port;
163
122
  }
164
- if (path) {
165
- const leaf = path.split("/").filter(Boolean).pop() ?? path;
166
- if (/write|edit|multiedit/.test(name)) return truncateDetail(`改文件 · ${leaf}`);
167
- if (/(^|_)read(_|$)|readfile/.test(name)) return truncateDetail(`读取 · ${leaf}`);
168
- return truncateDetail(`${p.toolName || "文件"} · ${leaf}`);
123
+
124
+ /**
125
+ * Runs inside another HTTP server, which passes it upgrades for
126
+ * `RELAY_PATH` through `handleUpgrade`.
127
+ */
128
+ attach(): void {
129
+ if (this.heartbeat) return;
130
+ this.heartbeat = setInterval(() => this.sweep(), this.options.heartbeatMs ?? 30_000);
131
+ this.heartbeat.unref();
169
132
  }
170
- if (p.context?.trim()) return truncateDetail(p.context.trim());
171
- if (p.toolName) return truncateDetail(`需要授权 · ${p.toolName}`);
172
- return undefined;
173
- }
174
133
 
175
- function conversationAttentionDetail(
176
- conversation: AgentConversationLike | undefined,
177
- status: string | undefined,
178
- current?: { agentStatus?: string; agentDetail?: string },
179
- ): string | null | undefined {
180
- if (status === "waiting_permission") {
181
- if (current?.agentStatus === "waiting_permission" && current.agentDetail) return undefined;
182
- return conversation?.lastMessagePreview;
134
+ handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
135
+ this.wss.handleUpgrade(request, socket, head, (ws) => this.accept(ws));
183
136
  }
184
- if (status === "running" || status === "error") {
185
- return conversation?.lastMessagePreview ?? null;
137
+
138
+ get connected(): number {
139
+ return this.peers.size;
186
140
  }
187
- return status ? null : undefined;
188
- }
189
141
 
190
- function pickConversationSummary(
191
- conversations: AgentConversationLike[],
192
- activeConversationId?: string,
193
- ): AgentConversationLike | undefined {
194
- const active = conversations.find((c) => c.id === activeConversationId);
195
- if (active) return active;
196
- const waiting = conversations.find((c) => c.status === "waiting_permission");
197
- if (waiting) return waiting;
198
- const running = conversations.find((c) => c.status === "running");
199
- if (running) return running;
200
- return [...conversations].sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0))[0];
201
- }
142
+ /** Bytes accepted from senders and not yet written to their receivers. */
143
+ get bufferedBytes(): number {
144
+ let total = 0;
145
+ for (const peer of this.peers.values()) total += peer.socket.bufferedAmount;
146
+ return total;
147
+ }
202
148
 
203
- function aggregateAgentStatus(conversations: AgentConversationLike[], fallback?: string): string | undefined {
204
- if (conversations.some((c) => c.status === "waiting_permission")) return "waiting_permission";
205
- if (conversations.some((c) => c.status === "running")) return "running";
206
- return fallback;
207
- }
149
+ /** Drops connections that didn't answer the previous ping, pings the rest. */
150
+ private sweep(): void {
151
+ for (const peer of this.peers.values()) {
152
+ // A held peer isn't read, so its pong can't arrive; it is checked again once released.
153
+ if (peer.held) continue;
154
+ if (!this.alive.has(peer.socket)) {
155
+ peer.socket.terminate();
156
+ continue;
157
+ }
158
+ this.alive.delete(peer.socket);
159
+ peer.socket.ping();
160
+ }
161
+ }
208
162
 
209
- function agentStatusFromTerminalPhase(phase: string): string | undefined {
210
- switch (phase) {
211
- case "thinking":
212
- case "tool_use":
213
- case "outputting":
214
- return "running";
215
- case "waiting":
216
- return "waiting_permission";
217
- case "idle":
218
- return "idle";
219
- case "error":
220
- return "error";
221
- default:
222
- return undefined;
163
+ async stop(): Promise<void> {
164
+ this.stopped = true;
165
+ clearInterval(this.heartbeat);
166
+ for (const peer of this.peers.values()) peer.socket.terminate();
167
+ await new Promise<void>((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
168
+ this.store.close();
223
169
  }
224
- }
225
170
 
226
- function terminalStatusTitle(payload: { phase: string; toolName?: string; summary?: string }): string {
227
- if (payload.toolName) return `外部终端 · ${payload.toolName}`;
228
- if (payload.summary) return `外部终端 · ${payload.summary}`;
229
- return "外部终端";
230
- }
171
+ // ── connection ────────────────────────────────────────────────────
231
172
 
232
- function shouldPromoteTerminalStatus(status: string, current?: string): boolean {
233
- if (status !== "idle") return true;
234
- return current !== "running" && current !== "waiting_permission";
235
- }
173
+ private accept(socket: WebSocket): void {
174
+ this.alive.add(socket);
175
+ socket.on("pong", () => this.alive.add(socket));
176
+ const nonce = randomBytes(32).toString("base64url");
177
+ let peer: Peer | undefined;
178
+ const send = (frame: ServerFrame) => {
179
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame));
180
+ };
181
+ const timer = setTimeout(() => {
182
+ if (!peer) socket.close(4001, "auth timeout");
183
+ }, AUTH_TIMEOUT_MS);
184
+ send({ t: "challenge", nonce });
236
185
 
237
- function cacheAgentEnvelope(envelope: Envelope, sessions: SessionManager): void {
238
- try {
239
- if (envelope.type === "agent.v2.conversation.list.result") {
240
- const p = parseTypedPayload("agent.v2.conversation.list.result", envelope.payload);
241
- const picked = pickConversationSummary(p.conversations);
242
- const status = aggregateAgentStatus(p.conversations, picked?.status ?? "idle");
243
- sessions.cacheAgentSummary(envelope.sessionId, {
244
- status,
245
- provider: picked?.provider,
246
- conversationId: picked?.id,
247
- title: picked?.title,
248
- detail: conversationAttentionDetail(picked, status, sessions.get(envelope.sessionId)),
249
- lastActivity: picked?.lastActivityAt ?? Date.now(),
250
- usage: picked?.usage ?? null,
251
- });
252
- return;
253
- }
254
- if (envelope.type === "agent.v2.conversation.opened") {
255
- const p = parseTypedPayload("agent.v2.conversation.opened", envelope.payload);
256
- const conversation = p.conversation;
257
- sessions.cacheAgentSummary(envelope.sessionId, {
258
- status: conversation.status,
259
- provider: conversation.provider,
260
- conversationId: conversation.id,
261
- title: conversation.title,
262
- detail: conversationAttentionDetail(conversation, conversation.status, sessions.get(envelope.sessionId)),
263
- lastActivity: conversation.lastActivityAt ?? Date.now(),
264
- usage: conversation.usage ?? null,
265
- });
266
- return;
267
- }
268
- if (envelope.type === "agent.v2.snapshot") {
269
- const p = parseTypedPayload("agent.v2.snapshot", envelope.payload);
270
- const picked = pickConversationSummary(p.conversations, p.activeConversationId);
271
- const status = aggregateAgentStatus(p.conversations, picked?.status ?? "idle");
272
- sessions.cacheAgentSummary(envelope.sessionId, {
273
- status,
274
- provider: picked?.provider,
275
- conversationId: picked?.id ?? p.activeConversationId,
276
- title: picked?.title,
277
- detail: conversationAttentionDetail(picked, status, sessions.get(envelope.sessionId)),
278
- lastActivity: picked?.lastActivityAt ?? Date.now(),
279
- usage: picked?.usage ?? null,
280
- });
281
- return;
282
- }
283
- if (envelope.type === "agent.v2.event") {
284
- const p = parseTypedPayload("agent.v2.event", envelope.payload);
285
- const current = sessions.get(envelope.sessionId);
286
- if (p.conversation) {
287
- sessions.cacheAgentSummary(envelope.sessionId, {
288
- status: p.conversation.status,
289
- provider: p.conversation.provider,
290
- conversationId: p.conversation.id,
291
- title: p.conversation.title,
292
- detail: conversationAttentionDetail(p.conversation, p.conversation.status, current),
293
- lastActivity: p.conversation.lastActivityAt,
294
- usage: p.conversation.usage ?? null,
295
- });
296
- } else if (p.patch?.status) {
297
- sessions.cacheAgentSummary(envelope.sessionId, {
298
- status: p.patch.status,
299
- conversationId: p.conversationId,
300
- detail: conversationAttentionDetail(undefined, p.patch.status, current),
301
- lastActivity: Date.now(),
302
- });
186
+ socket.on("message", async (data) => {
187
+ let parsed;
188
+ try {
189
+ parsed = clientFrameSchema.safeParse(JSON.parse(data.toString()));
190
+ } catch {
191
+ parsed = undefined;
303
192
  }
304
- return;
305
- }
306
- if (envelope.type === "agent.v2.permission.request") {
307
- const p = parseTypedPayload("agent.v2.permission.request", envelope.payload);
308
- sessions.cacheAgentSummary(envelope.sessionId, {
309
- status: "waiting_permission",
310
- conversationId: p.conversationId,
311
- detail: permissionAttentionDetail(p),
312
- lastActivity: Date.now(),
313
- });
314
- return;
315
- }
316
- if (envelope.type === "agent.snapshot") {
317
- const p = parseTypedPayload("agent.snapshot", envelope.payload);
318
- sessions.cacheAgentSummary(envelope.sessionId, {
319
- status: p.status,
320
- conversationId: p.agentSessionId,
321
- lastActivity: Date.now(),
322
- });
323
- return;
324
- }
325
- if (envelope.type === "agent.update") {
326
- const p = parseTypedPayload("agent.update", envelope.payload);
327
- if (p.status) {
328
- sessions.cacheAgentSummary(envelope.sessionId, {
329
- status: p.status,
330
- conversationId: p.agentSessionId,
331
- lastActivity: Date.now(),
332
- });
193
+ if (!parsed?.success) {
194
+ send({ t: "error", code: "bad_frame", message: "unreadable frame" });
195
+ return;
333
196
  }
334
- return;
335
- }
336
- if (envelope.type === "agent.permission.request") {
337
- const p = parseTypedPayload("agent.permission.request", envelope.payload);
338
- sessions.cacheAgentSummary(envelope.sessionId, {
339
- status: "waiting_permission",
340
- conversationId: p.agentSessionId,
341
- detail: permissionAttentionDetail(p),
342
- lastActivity: Date.now(),
343
- });
344
- return;
345
- }
346
- if (envelope.type === "terminal.status") {
347
- const p = parseTypedPayload("terminal.status", envelope.payload);
348
- const status = agentStatusFromTerminalPhase(p.phase);
349
- const currentStatus = sessions.get(envelope.sessionId)?.agentStatus;
350
- if (status && shouldPromoteTerminalStatus(status, currentStatus)) {
351
- sessions.cacheAgentSummary(envelope.sessionId, {
352
- status,
353
- provider: p.provider ?? null,
354
- conversationId: null,
355
- title: terminalStatusTitle(p),
356
- lastActivity: Date.now(),
357
- });
197
+ const frame = parsed.data;
198
+ if (!peer) {
199
+ if (frame.t !== "auth") {
200
+ socket.close(4001, "auth first");
201
+ return;
202
+ }
203
+ try {
204
+ peer = await this.authenticate(frame, nonce, socket);
205
+ } catch (error) {
206
+ const code = error instanceof RelayError ? error.code : "auth_failed";
207
+ send({ t: "error", code, message: error instanceof Error ? error.message : String(error) });
208
+ socket.close(4001, code);
209
+ return;
210
+ }
211
+ clearTimeout(timer);
212
+ send({ t: "ready", userId: peer.record.userId, email: peer.email });
213
+ this.announce(peer.record, true);
214
+ return;
215
+ }
216
+ switch (frame.t) {
217
+ case "ping":
218
+ send({ t: "pong" });
219
+ return;
220
+ case "to":
221
+ this.route(peer, frame.to, frame.d);
222
+ return;
223
+ case "req":
224
+ try {
225
+ const result = await this.handle(peer, frame.method, (frame.params ?? {}) as Record<string, unknown>);
226
+ send({ t: "res", id: frame.id, result });
227
+ } catch (error) {
228
+ const code = error instanceof RelayError ? error.code : "internal";
229
+ send({ t: "res", id: frame.id, error: { code, message: error instanceof Error ? error.message : String(error) } });
230
+ }
231
+ return;
232
+ case "auth":
233
+ return;
358
234
  }
359
- return;
235
+ });
236
+
237
+ socket.on("close", () => {
238
+ clearTimeout(timer);
239
+ if (this.stopped || !peer || this.peers.get(peer.record.id)?.socket !== socket) return;
240
+ this.peers.delete(peer.record.id);
241
+ this.store.touch(peer.record.id);
242
+ this.announce(peer.record, false);
243
+ });
244
+ }
245
+
246
+ private async authenticate(frame: Extract<import("@linkshell/wire").ClientFrame, { t: "auth" }>, nonce: string, socket: WebSocket) {
247
+ const { identity, role } = frame;
248
+ if (idOf(identity.signKey) !== identity.id) throw new RelayError("bad_identity", "id doesn't match key");
249
+ if (!verifySignature(identity.signKey, gatewayChallengeMessage(nonce), frame.signature)) {
250
+ throw new RelayError("bad_signature", "signature check failed");
360
251
  }
361
- if (envelope.type === "agent.v2.usage.report") {
362
- // Cache the full usage report on the session so the session-list page
363
- // (HTTP /sessions) can show summary cards without a WebSocket connection.
364
- const session = sessions.get(envelope.sessionId);
365
- if (session) session.agentUsageReport = envelope.payload;
366
- return;
252
+ const existing = this.store.peer(identity.id);
253
+ if (existing && existing.role !== role) throw new RelayError("bad_identity", "this key is registered with another role");
254
+ let userId: string | undefined;
255
+ let email: string | undefined;
256
+ if (frame.token) {
257
+ if (!this.options.verifyToken) throw new RelayError("accounts_disabled", "this gateway doesn't support accounts");
258
+ const account = await this.options.verifyToken(frame.token);
259
+ if (!account) throw new RelayError("token_invalid", "account token is invalid or expired");
260
+ userId = account.userId;
261
+ email = account.email;
367
262
  }
368
- } catch {
369
- // Agent summaries are best-effort; never block relay on a status parse.
263
+ const refusal = await this.options.admit?.({ role: role as PeerRole, userId });
264
+ if (refusal) throw new RelayError("not_admitted", refusal);
265
+ const record: PeerRecord = {
266
+ id: identity.id,
267
+ role: role as PeerRole,
268
+ signKey: identity.signKey,
269
+ boxKey: identity.boxKey,
270
+ name: frame.name,
271
+ platform: frame.platform,
272
+ userId,
273
+ lastSeen: Date.now(),
274
+ };
275
+ this.store.upsertPeer(record);
276
+ const previous = this.peers.get(record.id);
277
+ const peer: Peer = { record, socket, email };
278
+ this.peers.set(record.id, peer);
279
+ // One live connection per identity: the newest wins.
280
+ previous?.socket.close(4000, "replaced by a newer connection");
281
+ this.log(`${role} ${record.id.slice(0, 8)} "${record.name}" online${userId ? " (account)" : ""}`);
282
+ return peer;
370
283
  }
371
- }
372
284
 
373
- function handleHostMessage(
374
- envelope: Envelope,
375
- session: ReturnType<SessionManager["get"]> & {},
376
- sessions: SessionManager,
377
- ): void {
378
- if (agentV2MessageRoute(envelope.type) === "host_to_client") {
379
- cacheAgentEnvelope(envelope, sessions);
380
- sessions.bufferAgentEnvelope(session.id, envelope);
381
- broadcastToClients(session, envelope);
382
- return;
285
+ // ── routing ───────────────────────────────────────────────────────
286
+
287
+ private route(from: Peer, to: string, data: string): void {
288
+ const via = this.store.route(from.record.id, to);
289
+ const target = this.peers.get(to);
290
+ const undeliverable = (reason: string) =>
291
+ from.socket.send(JSON.stringify({ t: "undeliverable", to, reason } satisfies ServerFrame));
292
+ if (!via) return undeliverable("not_allowed");
293
+ if (!target) return undeliverable("offline");
294
+ const frame = JSON.stringify({
295
+ t: "from",
296
+ from: { id: from.record.id, signKey: from.record.signKey, boxKey: from.record.boxKey },
297
+ via,
298
+ d: data,
299
+ } satisfies ServerFrame);
300
+ this.options.onRouted?.(frame);
301
+ target.socket.send(frame);
302
+ this.holdWhileBehind(from, target);
383
303
  }
384
304
 
385
- switch (envelope.type) {
386
- case "session.connect": {
387
- // Extract metadata from host's connect message
388
- const p = parseTypedPayload("session.connect", envelope.payload);
389
- // Non-breaking version negotiation: warn (never disconnect) when a host
390
- // advertises an incompatible protocol version. CLI and app update
391
- // independently, so an out-of-date peer must keep working in degraded
392
- // mode rather than being rejected.
393
- if (!isProtocolVersionCompatible(p.protocolVersion)) {
394
- process.stderr.write(
395
- `[gateway] host on session ${session.id} advertises protocol v${p.protocolVersion}, ` +
396
- `which is older than the minimum compatible version — continuing in degraded mode\n`,
397
- );
305
+ /**
306
+ * Backpressure. What a peer can't take yet would otherwise pile up here, in
307
+ * memory every user of this gateway shares: stop reading the sender instead,
308
+ * so the data waits on its side until the receiver catches up.
309
+ */
310
+ private holdWhileBehind(from: Peer, target: Peer): void {
311
+ const limit = this.options.maxBufferedBytes ?? 4 * 1024 * 1024;
312
+ if (from.held || target.socket.bufferedAmount <= limit) return;
313
+ from.held = true;
314
+ from.socket.pause();
315
+ const since = Date.now();
316
+ const timer = setInterval(() => {
317
+ const open = target.socket.readyState === WebSocket.OPEN;
318
+ if (open && target.socket.bufferedAmount > limit / 4) {
319
+ if (Date.now() - since < (this.options.stalledPeerMs ?? 60_000)) return;
320
+ this.log(`${target.record.role} ${target.record.id.slice(0, 8)} can't keep up; dropping it`);
321
+ target.socket.terminate();
398
322
  }
399
- if (p.provider || p.machineId || p.hostname || p.platform || p.cwd || p.projectName) {
400
- sessions.setMetadata(
401
- session.id,
402
- p.provider ?? undefined,
403
- p.machineId ?? undefined,
404
- p.hostname ?? undefined,
405
- p.platform ?? undefined,
406
- p.cwd ?? undefined,
407
- p.projectName ?? undefined,
408
- );
409
- }
410
- break;
411
- }
412
- case "terminal.output": {
413
- sessions.bufferOutput(session.id, envelope);
414
- broadcastToClients(session, envelope);
415
- break;
416
- }
417
- case "terminal.exit": {
418
- // Don't terminate session — other terminals may still be running
419
- broadcastToClients(session, envelope);
420
- break;
421
- }
422
- case "session.heartbeat":
423
- break;
424
- case "permission.decision.result": {
425
- broadcastToClients(session, envelope);
426
- break;
427
- }
428
- // Tunnel: host → gateway (not broadcast to clients)
429
- case "tunnel.response": {
430
- const p = parseTypedPayload("tunnel.response", envelope.payload);
431
- handleTunnelResponse(p);
432
- return;
433
- }
434
- case "tunnel.ws.data": {
435
- const p = parseTypedPayload("tunnel.ws.data", envelope.payload);
436
- handleTunnelWsData(p);
437
- return;
438
- }
439
- case "tunnel.ws.close": {
440
- const p = parseTypedPayload("tunnel.ws.close", envelope.payload);
441
- handleTunnelWsClose(p);
442
- return;
443
- }
444
- case "control.grant":
445
- case "control.reject":
446
- broadcastToClients(session, envelope);
447
- break;
448
- // Screen sharing: host → clients
449
- case "screen.frame":
450
- case "screen.status":
451
- case "screen.offer":
452
- case "screen.ice":
453
- // Agent GUI: host → clients
454
- case "agent.capabilities":
455
- case "agent.update":
456
- case "agent.permission.request":
457
- case "agent.snapshot":
458
- cacheAgentEnvelope(envelope, sessions);
459
- broadcastToClients(session, envelope);
460
- break;
461
- // Multi-terminal: host → clients
462
- case "terminal.spawned":
463
- case "terminal.list":
464
- case "terminal.browse.result":
465
- case "terminal.file.read.result":
466
- broadcastToClients(session, envelope);
467
- break;
468
- // Structured status from hooks
469
- case "terminal.status":
470
- cacheAgentEnvelope(envelope, sessions);
471
- sessions.cacheStatus(session.id, envelope);
472
- broadcastToClients(session, envelope);
473
- break;
474
- default:
475
- broadcastToClients(session, envelope);
476
- break;
323
+ clearInterval(timer);
324
+ from.held = false;
325
+ this.alive.add(from.socket);
326
+ if (from.socket.readyState === WebSocket.OPEN) from.socket.resume();
327
+ }, 20);
328
+ timer.unref();
477
329
  }
478
- }
479
330
 
480
- function handleClientMessage(
481
- envelope: Envelope,
482
- socket: WebSocket,
483
- session: ReturnType<SessionManager["get"]> & {},
484
- deviceId: string,
485
- sessions: SessionManager,
486
- ): void {
487
- const requireController = (): boolean => {
488
- if (session.controllerId === deviceId) return true;
489
- socket.send(
490
- serializeEnvelope(
491
- createEnvelope({
492
- type: "session.error",
493
- sessionId: session.id,
494
- payload: {
495
- code: "control_conflict",
496
- message: "Not the controller",
497
- },
498
- }),
499
- ),
500
- );
501
- return false;
502
- };
503
-
504
- const agentRoute = agentV2MessageRoute(envelope.type);
505
- if (agentRoute === "client_write") {
506
- if (!requireController()) return;
507
- sendToHost(session, envelope);
508
- return;
331
+ private event<N extends keyof RelayEvents>(peerId: string, name: N, data: RelayEvents[N]): void {
332
+ const peer = this.peers.get(peerId);
333
+ if (peer?.socket.readyState === WebSocket.OPEN) peer.socket.send(JSON.stringify({ t: "event", name, data } satisfies ServerFrame));
509
334
  }
510
- if (agentRoute === "client_read") {
511
- sendToHost(session, envelope);
512
- return;
335
+
336
+ /** Tells everyone who can reach `record` that it came or went. */
337
+ private announce(record: PeerRecord, online: boolean): void {
338
+ for (const { peer } of this.store.reachable(record.id)) this.event(peer.id, "presence", { id: record.id, online });
513
339
  }
514
340
 
515
- switch (envelope.type) {
516
- case "terminal.input": {
517
- if (!requireController()) return;
518
- sendToHost(session, envelope);
519
- break;
520
- }
521
- case "terminal.resize": {
522
- if (!requireController()) return;
523
- sendToHost(session, envelope);
524
- break;
525
- }
526
- case "session.ack": {
527
- // Forward ACK to host
528
- sendToHost(session, envelope);
529
- break;
530
- }
531
- case "session.resume": {
532
- const p = parseTypedPayload("session.resume", envelope.payload);
533
- // Replay from gateway buffer first
534
- const replay = sessions.getReplayFrom(
535
- session.id,
536
- p.lastAckedSeqByTerminal,
537
- p.lastAckedSeq,
538
- );
539
- for (const msg of replay) {
540
- const payload = msg.payload as Record<string, unknown>;
541
- socket.send(
542
- serializeEnvelope(
543
- createEnvelope({
544
- type: "terminal.output",
545
- sessionId: session.id,
546
- terminalId: msg.terminalId,
547
- seq: msg.seq,
548
- payload: { ...payload, isReplay: true },
549
- }),
550
- ),
551
- );
341
+ private machineEntry(peer: PeerRecord, via: "paired" | "account"): MachineEntry {
342
+ return {
343
+ id: peer.id,
344
+ signKey: peer.signKey,
345
+ boxKey: peer.boxKey,
346
+ name: peer.name,
347
+ platform: peer.platform,
348
+ online: this.peers.has(peer.id),
349
+ via,
350
+ };
351
+ }
352
+
353
+ private deviceEntry(peer: PeerRecord, pairedAt?: number): DeviceEntry {
354
+ return { id: peer.id, signKey: peer.signKey, boxKey: peer.boxKey, name: peer.name, online: this.peers.has(peer.id), pairedAt };
355
+ }
356
+
357
+ // ── control methods ───────────────────────────────────────────────
358
+
359
+ private async handle(peer: Peer, method: string, params: Record<string, unknown>): Promise<unknown> {
360
+ const role = peer.record.role;
361
+ const need = (wanted: PeerRole) => {
362
+ if (role !== wanted) throw new RelayError("forbidden", `${method} is for ${wanted}s`);
363
+ };
364
+ switch (method) {
365
+ case "pair.offer": {
366
+ need("machine");
367
+ for (const [code, offer] of this.offers) if (offer.machineId === peer.record.id) this.offers.delete(code);
368
+ let code: string;
369
+ do code = String(randomInt(0, 1_000_000)).padStart(6, "0");
370
+ while (this.offers.has(code));
371
+ const expiresAt = Date.now() + (this.options.pairingTtlMs ?? 10 * 60_000);
372
+ this.offers.set(code, { code, machineId: peer.record.id, expiresAt, attempts: 0 });
373
+ return { code, expiresAt };
552
374
  }
553
- // Replay last terminal.status for each terminal
554
- const statusReplay = sessions.getStatusReplay(session.id);
555
- for (const statusMsg of statusReplay) {
556
- socket.send(serializeEnvelope(statusMsg));
375
+ case "pair.decide": {
376
+ need("machine");
377
+ const pending = this.decisions.get(String(params.requestId));
378
+ if (!pending || pending.machineId !== peer.record.id) throw new RelayError("not_found", "no such pairing request");
379
+ pending.resolve(params.accept === true);
380
+ return {};
557
381
  }
558
- replayAgentToSocket(socket, session.id, sessions);
559
- // Also forward resume to host so it can fill gaps beyond gateway buffer.
560
- sendToHost(session, session.machineId
561
- ? {
562
- ...envelope,
563
- payload: {
564
- ...(envelope.payload as Record<string, unknown>),
565
- machineId: session.machineId,
566
- },
567
- }
568
- : envelope);
569
- break;
570
- }
571
- case "control.claim": {
572
- sessions.claimControl(session.id, deviceId);
573
- const grantMsg = createEnvelope({
574
- type: "control.grant",
575
- sessionId: session.id,
576
- payload: { deviceId },
577
- });
578
- // Broadcast to ALL clients so previous controller updates its state
579
- broadcastToClients(session, grantMsg);
580
- sendToHost(session, grantMsg);
581
- break;
582
- }
583
- case "control.release": {
584
- sessions.releaseControl(session.id, deviceId);
585
- const releaseMsg = createEnvelope({
586
- type: "control.release",
587
- sessionId: session.id,
588
- payload: { deviceId },
589
- });
590
- broadcastToClients(session, releaseMsg);
591
- sendToHost(session, releaseMsg);
592
- break;
382
+ case "pair.claim":
383
+ need("device");
384
+ return this.claim(peer, params);
385
+ case "machines.list":
386
+ need("device");
387
+ return {
388
+ machines: this.store
389
+ .reachable(peer.record.id)
390
+ .map(({ peer: machine, via }) => this.machineEntry(machine, via))
391
+ .sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name)),
392
+ };
393
+ case "machines.forget": {
394
+ need("device");
395
+ const machineId = String(params.machineId ?? "");
396
+ const unpaired = this.store.unlink(machineId, peer.record.id);
397
+ if (unpaired) this.event(machineId, "presence", { id: peer.record.id, online: false });
398
+ // A computer that is listed because it signed in to this account, and is gone (reinstalled,
399
+ // given away): taken off the account, so the list stops showing it. One that is connected
400
+ // stays: it is signed in right now, and would only be back at once.
401
+ const machine = this.store.peer(machineId);
402
+ const ours = machine?.role === "machine" && peer.record.userId !== undefined && machine.userId === peer.record.userId;
403
+ if (ours && !this.peers.has(machineId)) this.store.disown(machineId);
404
+ return {};
405
+ }
406
+ case "devices.list":
407
+ need("machine");
408
+ return {
409
+ devices: this.store.reachable(peer.record.id).map(({ peer: device, pairedAt }) => this.deviceEntry(device, pairedAt)),
410
+ };
411
+ case "devices.revoke": {
412
+ need("machine");
413
+ const deviceId = String(params.deviceId ?? "");
414
+ if (this.store.unlink(peer.record.id, deviceId)) {
415
+ this.event(deviceId, "machines.changed", {});
416
+ this.peers.get(deviceId)?.socket.close(4003, "unpaired");
417
+ }
418
+ return {};
419
+ }
420
+ default:
421
+ throw new RelayError("unknown_method", `unknown method ${method}`);
593
422
  }
594
- case "session.heartbeat":
595
- break;
596
- // Screen sharing: client → host
597
- case "screen.start":
598
- case "screen.stop":
599
- case "screen.answer":
600
- case "screen.ice":
601
- case "agent.session.new":
602
- case "agent.session.load":
603
- case "agent.prompt":
604
- case "agent.cancel":
605
- case "agent.permission.response":
606
- // Multi-terminal write ops: client → host (require controller)
607
- case "terminal.spawn":
608
- case "terminal.kill":
609
- case "terminal.mkdir":
610
- case "file.upload":
611
- case "permission.decision":
612
- if (!requireController()) return;
613
- sendToHost(session, envelope);
614
- break;
615
- // Read-only ops: any client may issue (no controller gate)
616
- case "terminal.list":
617
- case "terminal.browse":
618
- case "terminal.file.read":
619
- case "terminal.history.request":
620
- case "agent.initialize":
621
- case "agent.session.list":
622
- sendToHost(session, envelope);
623
- break;
624
- default:
625
- sendToHost(session, envelope);
626
- break;
627
423
  }
628
- }
629
424
 
630
- /** Skip clients whose send buffer exceeds this, so one slow/stalled client
631
- * can't grow gateway memory without bound (backpressure). */
632
- const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024; // 8MB
633
-
634
- function broadcastToClients(
635
- session: ReturnType<SessionManager["get"]> & {},
636
- envelope: Envelope,
637
- ): void {
638
- const data = serializeEnvelope(envelope);
639
- for (const [, client] of session.clients) {
640
- if (client.socket.readyState !== client.socket.OPEN) continue;
641
- if (client.socket.bufferedAmount > MAX_CLIENT_BUFFER_BYTES) continue;
642
- client.socket.send(data);
643
- }
644
- }
425
+ private async claim(device: Peer, params: Record<string, unknown>): Promise<{ machine: MachineEntry }> {
426
+ // Throttle guessing: a handful of claims per device per minute.
427
+ const now = Date.now();
428
+ const recent = (this.claimTimes.get(device.record.id) ?? []).filter((t) => now - t < 60_000);
429
+ if (recent.length >= 6) throw new RelayError("rate_limited", "too many pairing attempts, wait a minute");
430
+ this.claimTimes.set(device.record.id, [...recent, now]);
645
431
 
646
- /** Replay the last agent snapshot + recent host→client agent envelopes. */
647
- export function replayAgentToSocket(
648
- socket: WebSocket,
649
- sessionId: string,
650
- sessions: SessionManager,
651
- ): void {
652
- if (socket.readyState !== socket.OPEN) return;
653
- for (const envelope of sessions.getAgentReplay(sessionId)) {
654
- if (socket.bufferedAmount > MAX_CLIENT_BUFFER_BYTES) break;
655
- socket.send(serializeEnvelope(envelope));
656
- }
657
- }
432
+ const code = typeof params.code === "string" ? params.code.replace(/\D/g, "") : undefined;
433
+ const machineId = typeof params.machineId === "string" ? params.machineId : undefined;
434
+ const offer = code
435
+ ? this.offers.get(code)
436
+ : [...this.offers.values()].find((candidate) => candidate.machineId === machineId);
437
+ if (!offer || offer.expiresAt < now) {
438
+ if (offer) this.offers.delete(offer.code);
439
+ throw new RelayError("pairing_expired", "this pairing code is wrong or has expired");
440
+ }
441
+ if (++offer.attempts > MAX_CLAIM_ATTEMPTS) {
442
+ this.offers.delete(offer.code);
443
+ throw new RelayError("pairing_expired", "too many attempts on this code; start pairing again");
444
+ }
445
+ const machine = this.peers.get(offer.machineId);
446
+ if (!machine) throw new RelayError("machine_offline", "the computer is offline");
658
447
 
659
- function sendToHost(
660
- session: ReturnType<SessionManager["get"]> & {},
661
- envelope: Envelope,
662
- ): void {
663
- if (
664
- session.host &&
665
- session.host.socket.readyState === session.host.socket.OPEN
666
- ) {
667
- session.host.socket.send(serializeEnvelope(envelope));
448
+ // The machine checks the proof (it holds the QR secret) and decides.
449
+ const requestId = randomUUID();
450
+ const accepted = await new Promise<boolean>((resolve) => {
451
+ const timer = setTimeout(() => {
452
+ this.decisions.delete(requestId);
453
+ resolve(false);
454
+ }, PAIR_DECISION_MS);
455
+ this.decisions.set(requestId, {
456
+ machineId: machine.record.id,
457
+ resolve: (accept) => {
458
+ clearTimeout(timer);
459
+ this.decisions.delete(requestId);
460
+ resolve(accept);
461
+ },
462
+ });
463
+ this.event(machine.record.id, "pair.request", {
464
+ requestId,
465
+ device: { id: device.record.id, signKey: device.record.signKey, boxKey: device.record.boxKey, name: device.record.name },
466
+ proof: String(params.proof ?? ""),
467
+ code: offer.code,
468
+ });
469
+ });
470
+ if (!accepted) {
471
+ this.event(machine.record.id, "pair.done", { requestId, accepted: false });
472
+ throw new RelayError("pairing_refused", "the computer refused this pairing");
473
+ }
474
+ this.offers.delete(offer.code);
475
+ this.store.link(machine.record.id, device.record.id);
476
+ this.event(machine.record.id, "pair.done", {
477
+ requestId,
478
+ accepted: true,
479
+ device: this.deviceEntry(device.record, Date.now()),
480
+ });
481
+ this.log(`paired device ${device.record.id.slice(0, 8)} with machine ${machine.record.id.slice(0, 8)}`);
482
+ return { machine: this.machineEntry(machine.record, "paired") };
668
483
  }
669
484
  }