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