@linkshell/gateway 0.5.3 → 0.6.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.
- package/Dockerfile +8 -22
- package/README.md +102 -44
- package/dist/gateway/src/accounts.js.map +1 -0
- package/dist/gateway/src/index.d.ts +4 -1
- package/dist/gateway/src/index.js +4 -863
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/main.js +57 -0
- package/dist/gateway/src/main.js.map +1 -0
- package/dist/gateway/src/rate-limit.d.ts +32 -0
- package/dist/gateway/src/rate-limit.js +106 -0
- package/dist/gateway/src/rate-limit.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +84 -5
- package/dist/gateway/src/relay.js +402 -529
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/serve.d.ts +34 -0
- package/dist/gateway/src/serve.js +115 -0
- package/dist/gateway/src/serve.js.map +1 -0
- package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
- package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
- package/dist/gateway/src/store.js.map +1 -0
- package/dist/gateway/src/subscription.d.ts +18 -0
- package/dist/gateway/src/subscription.js +46 -0
- package/dist/gateway/src/subscription.js.map +1 -0
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/direct.d.ts +22 -0
- package/dist/wire/src/direct.js +37 -0
- package/dist/wire/src/direct.js.map +1 -0
- package/dist/wire/src/index.d.ts +1 -0
- package/dist/wire/src/index.js +1 -0
- package/dist/wire/src/index.js.map +1 -1
- package/dist/wire/src/model.d.ts +287 -6
- package/dist/wire/src/model.js +33 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/relay.d.ts +8 -8
- package/dist/wire/src/rpc.d.ts +1780 -44
- package/dist/wire/src/rpc.js +48 -3
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +356 -0
- package/dist/wire/src/updates.js +11 -1
- package/dist/wire/src/updates.js.map +1 -1
- package/package.json +13 -13
- package/src/accounts.ts +32 -0
- package/src/index.ts +4 -997
- package/src/main.ts +65 -0
- package/src/rate-limit.ts +114 -0
- package/src/relay.ts +435 -620
- package/src/serve.ts +142 -0
- package/src/store.ts +135 -0
- package/src/subscription.ts +51 -0
- package/dist/gateway/src/auth-middleware.d.ts +0 -44
- package/dist/gateway/src/auth-middleware.js +0 -204
- package/dist/gateway/src/auth-middleware.js.map +0 -1
- package/dist/gateway/src/cors.d.ts +0 -2
- package/dist/gateway/src/cors.js +0 -7
- package/dist/gateway/src/cors.js.map +0 -1
- package/dist/gateway/src/embedded.d.ts +0 -21
- package/dist/gateway/src/embedded.js +0 -438
- package/dist/gateway/src/embedded.js.map +0 -1
- package/dist/gateway/src/host-auth.d.ts +0 -32
- package/dist/gateway/src/host-auth.js +0 -78
- package/dist/gateway/src/host-auth.js.map +0 -1
- package/dist/gateway/src/pairings.d.ts +0 -38
- package/dist/gateway/src/pairings.js +0 -119
- package/dist/gateway/src/pairings.js.map +0 -1
- package/dist/gateway/src/presence.d.ts +0 -28
- package/dist/gateway/src/presence.js +0 -92
- package/dist/gateway/src/presence.js.map +0 -1
- package/dist/gateway/src/sessions.d.ts +0 -113
- package/dist/gateway/src/sessions.js +0 -352
- package/dist/gateway/src/sessions.js.map +0 -1
- package/dist/gateway/src/state-store.d.ts +0 -21
- package/dist/gateway/src/state-store.js +0 -103
- package/dist/gateway/src/state-store.js.map +0 -1
- package/dist/gateway/src/static-web.d.ts +0 -15
- package/dist/gateway/src/static-web.js +0 -129
- package/dist/gateway/src/static-web.js.map +0 -1
- package/dist/gateway/src/tokens.d.ts +0 -19
- package/dist/gateway/src/tokens.js +0 -151
- package/dist/gateway/src/tokens.js.map +0 -1
- package/dist/gateway/src/tunnel.d.ts +0 -61
- package/dist/gateway/src/tunnel.js +0 -576
- package/dist/gateway/src/tunnel.js.map +0 -1
- package/dist/gateway-v2/src/accounts.js.map +0 -1
- package/dist/gateway-v2/src/index.d.ts +0 -3
- package/dist/gateway-v2/src/index.js +0 -4
- package/dist/gateway-v2/src/index.js.map +0 -1
- package/dist/gateway-v2/src/main.js +0 -17
- package/dist/gateway-v2/src/main.js.map +0 -1
- package/dist/gateway-v2/src/server.d.ts +0 -84
- package/dist/gateway-v2/src/server.js +0 -426
- package/dist/gateway-v2/src/server.js.map +0 -1
- package/dist/gateway-v2/src/store.js.map +0 -1
- package/dist/shared-protocol/src/index.d.ts +0 -17029
- package/dist/shared-protocol/src/index.js +0 -1064
- package/dist/shared-protocol/src/index.js.map +0 -1
- package/src/auth-middleware.ts +0 -266
- package/src/cors.ts +0 -8
- package/src/embedded.ts +0 -526
- package/src/host-auth.ts +0 -88
- package/src/pairings.ts +0 -133
- package/src/presence.ts +0 -118
- package/src/sessions.ts +0 -432
- package/src/state-store.ts +0 -140
- package/src/static-web.ts +0 -133
- package/src/tokens.ts +0 -159
- package/src/tunnel.ts +0 -644
- /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
- /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
- /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
|
@@ -1,561 +1,434 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
71
|
+
this.heartbeat = setInterval(() => this.sweep(), this.options.heartbeatMs ?? 30_000);
|
|
72
|
+
this.heartbeat.unref();
|
|
42
73
|
}
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
471
|
-
|
|
367
|
+
return {};
|
|
368
|
+
}
|
|
369
|
+
default:
|
|
370
|
+
throw new RelayError("unknown_method", `unknown method ${method}`);
|
|
472
371
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|