@linkshell/gateway 0.2.47 → 0.3.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 (40) hide show
  1. package/README.md +14 -13
  2. package/dist/gateway/src/agent-permission-http.d.ts +10 -37
  3. package/dist/gateway/src/agent-permission-http.js +16 -56
  4. package/dist/gateway/src/agent-permission-http.js.map +1 -1
  5. package/dist/gateway/src/embedded.js +121 -57
  6. package/dist/gateway/src/embedded.js.map +1 -1
  7. package/dist/gateway/src/index.js +161 -94
  8. package/dist/gateway/src/index.js.map +1 -1
  9. package/dist/gateway/src/pairings.d.ts +3 -3
  10. package/dist/gateway/src/pairings.js +4 -5
  11. package/dist/gateway/src/pairings.js.map +1 -1
  12. package/dist/gateway/src/relay.d.ts +2 -2
  13. package/dist/gateway/src/relay.js +27 -38
  14. package/dist/gateway/src/relay.js.map +1 -1
  15. package/dist/gateway/src/sessions.d.ts +31 -28
  16. package/dist/gateway/src/sessions.js +163 -145
  17. package/dist/gateway/src/sessions.js.map +1 -1
  18. package/dist/gateway/src/state-store.d.ts +9 -6
  19. package/dist/gateway/src/state-store.js +26 -19
  20. package/dist/gateway/src/state-store.js.map +1 -1
  21. package/dist/gateway/src/tokens.d.ts +27 -7
  22. package/dist/gateway/src/tokens.js +86 -60
  23. package/dist/gateway/src/tokens.js.map +1 -1
  24. package/dist/gateway/src/tunnel.d.ts +11 -13
  25. package/dist/gateway/src/tunnel.js +36 -36
  26. package/dist/gateway/src/tunnel.js.map +1 -1
  27. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  28. package/dist/shared-protocol/src/index.d.ts +3961 -5788
  29. package/dist/shared-protocol/src/index.js +19 -84
  30. package/dist/shared-protocol/src/index.js.map +1 -1
  31. package/package.json +10 -10
  32. package/src/agent-permission-http.ts +20 -63
  33. package/src/embedded.ts +124 -56
  34. package/src/index.ts +165 -94
  35. package/src/pairings.ts +6 -7
  36. package/src/relay.ts +38 -48
  37. package/src/sessions.ts +174 -150
  38. package/src/state-store.ts +41 -25
  39. package/src/tokens.ts +109 -63
  40. package/src/tunnel.ts +49 -43
package/src/tokens.ts CHANGED
@@ -1,47 +1,45 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { GatewayStateStore } from "./state-store.js";
3
3
 
4
- const CLEANUP_INTERVAL = 5 * 60_000;
5
- const SESSION_TTL = 7 * 24 * 60 * 60_000; // 7 days — prune stale bindings
4
+ interface DeviceAuthorization {
5
+ authorizationId: string;
6
+ hostDeviceId: string;
7
+ clientDeviceId: string | undefined;
8
+ clientName: string | undefined;
9
+ createdAt: number;
10
+ lastUsedAt: number;
11
+ }
6
12
 
7
13
  interface TokenRecord {
8
14
  token: string;
9
- sessionIds: Set<string>;
15
+ authorizations: Map<string, DeviceAuthorization>;
10
16
  createdAt: number;
11
17
  lastUsedAt: number;
12
18
  }
13
19
 
14
- export class TokenManager {
20
+ export class AuthorizationManager {
15
21
  private tokens = new Map<string, TokenRecord>();
16
- private sessionToToken = new Map<string, string>();
17
- private cleanupTimer: ReturnType<typeof setInterval>;
22
+ private hostDeviceToTokens = new Map<string, Set<string>>();
18
23
 
19
- constructor(private readonly store?: GatewayStateStore) {
20
- this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
21
- }
24
+ constructor(private readonly store?: GatewayStateStore) {}
22
25
 
23
26
  async hydrate(): Promise<void> {
24
27
  if (!this.store) return;
25
28
  try {
26
- const records = await this.store.loadTokens();
27
- const now = Date.now();
29
+ const records = await this.store.loadAuthorizations();
28
30
  for (const record of records) {
29
- if (now - record.lastUsedAt > SESSION_TTL) {
30
- void this.store.deleteToken(record.token).catch(() => {});
31
- continue;
32
- }
33
- this.tokens.set(record.token, {
34
- token: record.token,
35
- sessionIds: new Set(record.sessionIds),
31
+ const token = this.register(record.token);
32
+ this.authorize(token, record.hostDeviceId, {
33
+ authorizationId: record.authorizationId,
34
+ clientDeviceId: record.clientDeviceId,
35
+ clientName: record.clientName,
36
36
  createdAt: record.createdAt,
37
37
  lastUsedAt: record.lastUsedAt,
38
+ persist: false,
38
39
  });
39
- for (const sessionId of record.sessionIds) {
40
- this.sessionToToken.set(sessionId, record.token);
41
- }
42
40
  }
43
41
  } catch (err) {
44
- process.stderr.write(`[gateway] token store hydrate failed, using memory only: ${err}\n`);
42
+ process.stderr.write(`[gateway] authorization store hydrate failed, using memory only: ${err}\n`);
45
43
  }
46
44
  }
47
45
 
@@ -49,83 +47,131 @@ export class TokenManager {
49
47
  if (deviceToken && this.tokens.has(deviceToken)) {
50
48
  const record = this.tokens.get(deviceToken)!;
51
49
  record.lastUsedAt = Date.now();
52
- this.persist(record);
53
50
  return deviceToken;
54
51
  }
55
52
  const token = deviceToken || randomUUID();
56
53
  this.tokens.set(token, {
57
54
  token,
58
- sessionIds: new Set(),
55
+ authorizations: new Map(),
59
56
  createdAt: Date.now(),
60
57
  lastUsedAt: Date.now(),
61
58
  });
62
- this.persist(this.tokens.get(token)!);
63
59
  return token;
64
60
  }
65
61
 
66
- bind(token: string, sessionId: string): boolean {
62
+ authorize(
63
+ token: string,
64
+ hostDeviceId: string,
65
+ input: {
66
+ authorizationId?: string;
67
+ clientDeviceId?: string;
68
+ clientName?: string;
69
+ createdAt?: number;
70
+ lastUsedAt?: number;
71
+ persist?: boolean;
72
+ } = {},
73
+ ): DeviceAuthorization | undefined {
67
74
  const record = this.tokens.get(token);
68
- if (!record) return false;
69
- record.sessionIds.add(sessionId);
70
- record.lastUsedAt = Date.now();
71
- this.sessionToToken.set(sessionId, token);
72
- this.persist(record);
73
- return true;
75
+ if (!record) return undefined;
76
+ const existing = record.authorizations.get(hostDeviceId);
77
+ const now = Date.now();
78
+ const authorization: DeviceAuthorization = {
79
+ authorizationId: input.authorizationId ?? existing?.authorizationId ?? randomUUID(),
80
+ hostDeviceId,
81
+ clientDeviceId: input.clientDeviceId ?? existing?.clientDeviceId,
82
+ clientName: input.clientName ?? existing?.clientName,
83
+ createdAt: input.createdAt ?? existing?.createdAt ?? now,
84
+ lastUsedAt: input.lastUsedAt ?? now,
85
+ };
86
+ record.authorizations.set(hostDeviceId, authorization);
87
+ record.lastUsedAt = now;
88
+ let tokens = this.hostDeviceToTokens.get(hostDeviceId);
89
+ if (!tokens) {
90
+ tokens = new Set();
91
+ this.hostDeviceToTokens.set(hostDeviceId, tokens);
92
+ }
93
+ tokens.add(token);
94
+ if (input.persist !== false) {
95
+ this.persist(token, authorization);
96
+ }
97
+ return authorization;
74
98
  }
75
99
 
76
100
  validate(token: string): boolean {
77
101
  const record = this.tokens.get(token);
78
102
  if (!record) return false;
79
103
  record.lastUsedAt = Date.now();
80
- this.persist(record);
81
104
  return true;
82
105
  }
83
106
 
84
- owns(token: string, sessionId: string): boolean {
107
+ owns(token: string, hostDeviceId: string): boolean {
85
108
  const record = this.tokens.get(token);
86
109
  if (!record) return false;
87
- record.lastUsedAt = Date.now();
88
- this.persist(record);
89
- return record.sessionIds.has(sessionId);
110
+ const authorization = record.authorizations.get(hostDeviceId);
111
+ if (!authorization) return false;
112
+ const now = Date.now();
113
+ record.lastUsedAt = now;
114
+ authorization.lastUsedAt = now;
115
+ this.persist(token, authorization);
116
+ return true;
90
117
  }
91
118
 
92
- getSessionIds(token: string): Set<string> {
119
+ revoke(token: string, hostDeviceId: string, authorizationId: string): boolean {
120
+ const record = this.tokens.get(token);
121
+ const authorization = record?.authorizations.get(hostDeviceId);
122
+ if (!record || !authorization || authorization.authorizationId !== authorizationId) {
123
+ return false;
124
+ }
125
+ record.authorizations.delete(hostDeviceId);
126
+ const tokens = this.hostDeviceToTokens.get(hostDeviceId);
127
+ tokens?.delete(token);
128
+ if (tokens && tokens.size === 0) {
129
+ this.hostDeviceToTokens.delete(hostDeviceId);
130
+ }
131
+ void this.store?.deleteAuthorization(authorizationId).catch((err) => {
132
+ process.stderr.write(`[gateway] authorization store delete failed: ${err}\n`);
133
+ });
134
+ return true;
135
+ }
136
+
137
+ getHostDeviceIds(token: string): Set<string> {
93
138
  const record = this.tokens.get(token);
94
139
  if (!record) return new Set();
95
140
  record.lastUsedAt = Date.now();
96
- this.persist(record);
97
- return record.sessionIds;
141
+ return new Set(record.authorizations.keys());
98
142
  }
99
143
 
100
- getTokenForSession(sessionId: string): string | undefined {
101
- return this.sessionToToken.get(sessionId);
144
+ getSessionIds(token: string): Set<string> {
145
+ return this.getHostDeviceIds(token);
102
146
  }
103
147
 
104
- private cleanup(): void {
105
- const now = Date.now();
106
- for (const [token, record] of this.tokens) {
107
- if (now - record.lastUsedAt > SESSION_TTL) {
108
- for (const sid of record.sessionIds) {
109
- this.sessionToToken.delete(sid);
110
- }
111
- this.tokens.delete(token);
112
- void this.store?.deleteToken(token).catch(() => {});
113
- }
114
- }
148
+ getAuthorizationId(token: string, hostDeviceId: string): string | undefined {
149
+ return this.tokens.get(token)?.authorizations.get(hostDeviceId)?.authorizationId;
150
+ }
151
+
152
+ getTokenForHostDevice(hostDeviceId: string): string | undefined {
153
+ return this.hostDeviceToTokens.get(hostDeviceId)?.values().next().value;
154
+ }
155
+
156
+ bind(token: string, hostDeviceId: string): boolean {
157
+ return !!this.authorize(token, hostDeviceId);
115
158
  }
116
159
 
117
- private persist(record: TokenRecord): void {
118
- void this.store?.saveToken({
119
- token: record.token,
120
- sessionIds: [...record.sessionIds],
121
- createdAt: record.createdAt,
122
- lastUsedAt: record.lastUsedAt,
160
+ private persist(token: string, authorization: DeviceAuthorization): void {
161
+ void this.store?.saveAuthorization({
162
+ token,
163
+ authorizationId: authorization.authorizationId,
164
+ hostDeviceId: authorization.hostDeviceId,
165
+ clientDeviceId: authorization.clientDeviceId,
166
+ clientName: authorization.clientName,
167
+ createdAt: authorization.createdAt,
168
+ lastUsedAt: authorization.lastUsedAt,
123
169
  }).catch((err) => {
124
- process.stderr.write(`[gateway] token store save failed: ${err}\n`);
170
+ process.stderr.write(`[gateway] authorization store save failed: ${err}\n`);
125
171
  });
126
172
  }
127
173
 
128
- destroy(): void {
129
- clearInterval(this.cleanupTimer);
130
- }
174
+ destroy(): void {}
131
175
  }
176
+
177
+ export class TokenManager extends AuthorizationManager {}
package/src/tunnel.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  createEnvelope,
6
6
  serializeEnvelope,
7
7
  } from "@linkshell/protocol";
8
- import type { SessionManager } from "./sessions.js";
8
+ import type { DeviceManager } from "./sessions.js";
9
9
  import type { TokenManager } from "./tokens.js";
10
10
  import { AUTH_REQUIRED } from "./auth-middleware.js";
11
11
 
@@ -26,23 +26,23 @@ export interface PendingTunnelWs {
26
26
  const pendingRequests = new Map<string, PendingTunnelRequest>();
27
27
  const pendingWsSockets = new Map<string, PendingTunnelWs>();
28
28
 
29
- // Track requestIds per session for cleanup on host disconnect
30
- const sessionRequests = new Map<string, Set<string>>();
29
+ // Track requestIds per host device for cleanup on host disconnect
30
+ const deviceRequests = new Map<string, Set<string>>();
31
31
 
32
- function trackRequest(sessionId: string, requestId: string): void {
33
- let set = sessionRequests.get(sessionId);
32
+ function trackRequest(hostDeviceId: string, requestId: string): void {
33
+ let set = deviceRequests.get(hostDeviceId);
34
34
  if (!set) {
35
35
  set = new Set();
36
- sessionRequests.set(sessionId, set);
36
+ deviceRequests.set(hostDeviceId, set);
37
37
  }
38
38
  set.add(requestId);
39
39
  }
40
40
 
41
- function untrackRequest(sessionId: string, requestId: string): void {
42
- const set = sessionRequests.get(sessionId);
41
+ function untrackRequest(hostDeviceId: string, requestId: string): void {
42
+ const set = deviceRequests.get(hostDeviceId);
43
43
  if (set) {
44
44
  set.delete(requestId);
45
- if (set.size === 0) sessionRequests.delete(sessionId);
45
+ if (set.size === 0) deviceRequests.delete(hostDeviceId);
46
46
  }
47
47
  }
48
48
 
@@ -65,19 +65,19 @@ function extractToken(req: IncomingMessage, url: URL): string | null {
65
65
  return null;
66
66
  }
67
67
 
68
- /** Parse lsh_tunnel cookie: "sessionId:port:token" */
69
- export function parseTunnelCookie(req: IncomingMessage): { sessionId: string; port: number; token: string } | null {
68
+ /** Parse lsh_tunnel cookie: "hostDeviceId:port:token" */
69
+ export function parseTunnelCookie(req: IncomingMessage): { hostDeviceId: string; port: number; token: string } | null {
70
70
  const cookie = req.headers.cookie;
71
71
  if (!cookie) return null;
72
72
  const match = cookie.match(/lsh_tunnel=([^;]+)/);
73
73
  if (!match?.[1]) return null;
74
74
  const parts = decodeURIComponent(match[1]).split(":");
75
75
  if (parts.length < 3) return null;
76
- const sessionId = parts[0]!;
76
+ const hostDeviceId = parts[0]!;
77
77
  const port = Number(parts[1]);
78
78
  const token = parts.slice(2).join(":"); // token may contain colons
79
- if (!sessionId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
80
- return { sessionId, port, token };
79
+ if (!hostDeviceId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
80
+ return { hostDeviceId, port, token };
81
81
  }
82
82
 
83
83
  function errorResponse(res: ServerResponse, status: number, message: string): void {
@@ -89,32 +89,38 @@ function errorResponse(res: ServerResponse, status: number, message: string): vo
89
89
  res.end(message);
90
90
  }
91
91
 
92
- export function parseTunnelPath(pathname: string): { sessionId: string; port: number; path: string } | null {
92
+ export function parseTunnelPath(pathname: string): { hostDeviceId: string; port: number; path: string } | null {
93
93
  const match = pathname.match(/^\/tunnel\/([^/]+)\/(\d+)(\/.*)?$/);
94
94
  if (!match) return null;
95
95
  const port = Number(match[2]);
96
96
  if (port < 1 || port > 65535) return null;
97
97
  return {
98
- sessionId: match[1]!,
98
+ hostDeviceId: match[1]!,
99
99
  port,
100
100
  path: match[3] || "/",
101
101
  };
102
102
  }
103
103
 
104
+ type ParsedTunnelTarget = {
105
+ hostDeviceId: string;
106
+ port: number;
107
+ path: string;
108
+ };
109
+
104
110
  export async function handleTunnelRequest(
105
111
  req: IncomingMessage,
106
112
  res: ServerResponse,
107
- sessions: SessionManager,
113
+ sessions: DeviceManager,
108
114
  tokens: TokenManager,
109
- parsed: { sessionId: string; port: number; path: string },
115
+ parsed: ParsedTunnelTarget,
110
116
  url: URL,
111
117
  preAuthToken?: string,
112
118
  ): Promise<void> {
113
- const { sessionId, port, path } = parsed;
119
+ const { hostDeviceId, port, path } = parsed;
114
120
 
115
121
  // Auth: device token OR Supabase JWT (userId owns session)
116
122
  const token = preAuthToken || extractToken(req, url);
117
- const tokenOwns = token && tokens.owns(token, sessionId);
123
+ const tokenOwns = token && tokens.owns(token, hostDeviceId);
118
124
  let authOwns = false;
119
125
  let authJwt: string | null = null;
120
126
  if (!tokenOwns && AUTH_REQUIRED) {
@@ -135,7 +141,7 @@ export async function handleTunnelRequest(
135
141
  });
136
142
  if (userRes.ok) {
137
143
  const user = (await userRes.json()) as { id: string };
138
- const session = sessions.get(sessionId);
144
+ const session = sessions.get(hostDeviceId);
139
145
  if (user.id && session?.userId && user.id === session.userId) {
140
146
  authOwns = true;
141
147
  authJwt = jwtCandidate;
@@ -153,12 +159,12 @@ export async function handleTunnelRequest(
153
159
  // Set auth cookie for subsequent sub-resource requests
154
160
  const cookieToken = tokenOwns ? token : authJwt;
155
161
  if (cookieToken) {
156
- const cookieVal = encodeURIComponent(`${sessionId}:${port}:${cookieToken}`);
162
+ const cookieVal = encodeURIComponent(`${hostDeviceId}:${port}:${cookieToken}`);
157
163
  res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; SameSite=Lax`);
158
164
  }
159
165
 
160
166
  // Validate session & host
161
- const session = sessions.get(sessionId);
167
+ const session = sessions.get(hostDeviceId);
162
168
  if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
163
169
  errorResponse(res, 502, "Host not connected");
164
170
  return;
@@ -204,17 +210,17 @@ export async function handleTunnelRequest(
204
210
  headersSent: false,
205
211
  timeout: setTimeout(() => {
206
212
  pendingRequests.delete(requestId);
207
- untrackRequest(sessionId, requestId);
213
+ untrackRequest(hostDeviceId, requestId);
208
214
  errorResponse(res, 504, "Tunnel request timed out");
209
215
  }, TUNNEL_TIMEOUT),
210
216
  };
211
217
  pendingRequests.set(requestId, pending);
212
- trackRequest(sessionId, requestId);
218
+ trackRequest(hostDeviceId, requestId);
213
219
 
214
220
  // Send tunnel.request to host
215
221
  const envelope = createEnvelope({
216
222
  type: "tunnel.request",
217
- sessionId,
223
+ hostDeviceId,
218
224
  payload: {
219
225
  requestId,
220
226
  method,
@@ -232,7 +238,7 @@ export async function handleTunnelRequest(
232
238
  if (p) {
233
239
  clearTimeout(p.timeout);
234
240
  pendingRequests.delete(requestId);
235
- untrackRequest(sessionId, requestId);
241
+ untrackRequest(hostDeviceId, requestId);
236
242
  }
237
243
  });
238
244
  }
@@ -306,8 +312,8 @@ export function removeTunnelWs(requestId: string): void {
306
312
  pendingWsSockets.delete(requestId);
307
313
  }
308
314
 
309
- export function cleanupSessionTunnels(sessionId: string): void {
310
- const requestIds = sessionRequests.get(sessionId);
315
+ export function cleanupSessionTunnels(hostDeviceId: string): void {
316
+ const requestIds = deviceRequests.get(hostDeviceId);
311
317
  if (!requestIds) return;
312
318
  for (const rid of requestIds) {
313
319
  const pending = pendingRequests.get(rid);
@@ -322,21 +328,21 @@ export function cleanupSessionTunnels(sessionId: string): void {
322
328
  pendingWsSockets.delete(rid);
323
329
  }
324
330
  }
325
- sessionRequests.delete(sessionId);
331
+ deviceRequests.delete(hostDeviceId);
326
332
  }
327
333
 
328
334
  export async function handleTunnelWsUpgrade(
329
335
  ws: WebSocket,
330
- parsed: { sessionId: string; port: number; path: string },
336
+ parsed: ParsedTunnelTarget,
331
337
  url: URL,
332
- sessions: SessionManager,
338
+ sessions: DeviceManager,
333
339
  tokens: TokenManager,
334
340
  ): Promise<void> {
335
- const { sessionId, port, path } = parsed;
341
+ const { hostDeviceId, port, path } = parsed;
336
342
 
337
343
  // Auth: device token OR Supabase JWT (userId owns session)
338
344
  const token = url.searchParams.get("token");
339
- const tokenOwns = token && tokens.owns(token, sessionId);
345
+ const tokenOwns = token && tokens.owns(token, hostDeviceId);
340
346
  let authOwns = false;
341
347
  if (!tokenOwns && AUTH_REQUIRED) {
342
348
  // Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
@@ -352,7 +358,7 @@ export async function handleTunnelWsUpgrade(
352
358
  });
353
359
  if (userRes.ok) {
354
360
  const user = (await userRes.json()) as { id: string };
355
- const session = sessions.get(sessionId);
361
+ const session = sessions.get(hostDeviceId);
356
362
  if (user.id && session?.userId && user.id === session.userId) {
357
363
  authOwns = true;
358
364
  }
@@ -366,7 +372,7 @@ export async function handleTunnelWsUpgrade(
366
372
  return;
367
373
  }
368
374
 
369
- const session = sessions.get(sessionId);
375
+ const session = sessions.get(hostDeviceId);
370
376
  if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
371
377
  ws.close(4002, "Host not connected");
372
378
  return;
@@ -377,12 +383,12 @@ export async function handleTunnelWsUpgrade(
377
383
 
378
384
  // Register this WS so host responses route here
379
385
  registerTunnelWs(requestId, ws);
380
- trackRequest(sessionId, requestId);
386
+ trackRequest(hostDeviceId, requestId);
381
387
 
382
388
  // Send tunnel.request with upgrade header to host
383
389
  const envelope = createEnvelope({
384
390
  type: "tunnel.request",
385
- sessionId,
391
+ hostDeviceId,
386
392
  payload: {
387
393
  requestId,
388
394
  method: "GET",
@@ -397,13 +403,13 @@ export async function handleTunnelWsUpgrade(
397
403
  // Forward data from browser WS to host
398
404
  ws.on("message", (data: Buffer | string) => {
399
405
  try {
400
- const s = sessions.get(sessionId);
406
+ const s = sessions.get(hostDeviceId);
401
407
  if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
402
408
  const isBinary = typeof data !== "string";
403
409
  const buf = typeof data === "string" ? Buffer.from(data) : data;
404
410
  const fwd = createEnvelope({
405
411
  type: "tunnel.ws.data",
406
- sessionId,
412
+ hostDeviceId,
407
413
  payload: {
408
414
  requestId,
409
415
  data: buf.toString("base64"),
@@ -417,13 +423,13 @@ export async function handleTunnelWsUpgrade(
417
423
  ws.on("close", (code, reason) => {
418
424
  try {
419
425
  removeTunnelWs(requestId);
420
- untrackRequest(sessionId, requestId);
421
- const s = sessions.get(sessionId);
426
+ untrackRequest(hostDeviceId, requestId);
427
+ const s = sessions.get(hostDeviceId);
422
428
  if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
423
429
  const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
424
430
  const fwd = createEnvelope({
425
431
  type: "tunnel.ws.close",
426
- sessionId,
432
+ hostDeviceId,
427
433
  payload: {
428
434
  requestId,
429
435
  code: safeCode,