@linkshell/gateway 0.3.8 → 0.3.10

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