@linkshell/gateway 0.3.9 → 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 +78 -156
  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 -200
  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 -3423
  30. package/dist/shared-protocol/src/index.js +98 -164
  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 +90 -188
  38. package/src/sessions.ts +150 -213
  39. package/src/state-store.ts +25 -41
  40. package/src/tokens.ts +63 -109
  41. package/src/tunnel.ts +43 -49
package/src/relay.ts CHANGED
@@ -2,12 +2,13 @@ import type WebSocket from "ws";
2
2
  import {
3
3
  parseEnvelope,
4
4
  parseTypedPayload,
5
+ protocolMessageSchemas,
5
6
  serializeEnvelope,
6
7
  createEnvelope,
7
8
  } from "@linkshell/protocol";
8
9
  import type { Envelope, ProtocolMessageType } from "@linkshell/protocol";
9
10
  import { ZodError } from "zod";
10
- import type { DeviceManager, ConnectedDevice } from "./sessions.js";
11
+ import type { SessionManager, ConnectedDevice } from "./sessions.js";
11
12
  import {
12
13
  handleTunnelResponse,
13
14
  handleTunnelWsData,
@@ -15,88 +16,40 @@ import {
15
16
  } from "./tunnel.js";
16
17
  import { resolveAgentPermissionHttpAck } from "./agent-permission-http.js";
17
18
 
18
- const AGENT_SNAPSHOT_WARN_BYTES = Number(
19
- process.env.AGENT_SNAPSHOT_WARN_BYTES ?? 1024 * 1024,
20
- );
21
- const DEFAULT_WS_BUFFERED_AMOUNT_LIMIT = 16 * 1024 * 1024;
22
-
23
- function wsBufferedAmountLimit(): number {
24
- return Number(process.env.WS_BUFFERED_AMOUNT_LIMIT ?? DEFAULT_WS_BUFFERED_AMOUNT_LIMIT);
25
- }
26
-
27
19
  export function handleSocketMessage(
28
20
  socket: WebSocket,
29
21
  raw: string,
30
22
  role: "host" | "client",
31
- hostDeviceId: string,
23
+ sessionId: string,
32
24
  deviceId: string,
33
- sessions: DeviceManager,
25
+ sessions: SessionManager,
34
26
  ): void {
35
- const codexHeader = tryParseCodexRpcHeader(raw);
36
- if (codexHeader && codexHeader.type === "agent.codex.rpc") {
37
- if (codexHeader.hostDeviceId !== hostDeviceId) {
38
- sendSessionError(
39
- socket,
40
- hostDeviceId,
41
- "invalid_message",
42
- "Envelope hostDeviceId does not match connection hostDeviceId",
43
- );
44
- return;
45
- }
46
-
47
- const session = sessions.get(hostDeviceId);
48
- if (!session) {
49
- sendSessionError(socket, hostDeviceId, "device_not_found", "Device not found");
50
- return;
51
- }
52
-
53
- if (role === "client" && !clientHasControl(session, deviceId)) {
54
- sendControlConflict(socket, hostDeviceId);
55
- return;
56
- }
57
-
58
- const routeEnvelope: Envelope = {
59
- id: "",
60
- type: "agent.codex.rpc",
61
- hostDeviceId,
62
- timestamp: new Date().toISOString(),
63
- payload: {},
64
- };
65
-
66
- if (role === "host") {
67
- broadcastToClients(session, routeEnvelope, raw);
68
- } else {
69
- sendToHost(session, routeEnvelope, raw);
70
- }
71
- return;
72
- }
73
-
74
27
  let envelope: Envelope;
75
28
  try {
76
29
  envelope = parseEnvelope(raw);
77
30
  } catch {
78
- sendSessionError(socket, hostDeviceId, "invalid_message", "Failed to parse envelope");
31
+ sendSessionError(socket, sessionId, "invalid_message", "Failed to parse envelope");
79
32
  return;
80
33
  }
81
34
 
82
- if (envelope.hostDeviceId !== hostDeviceId) {
35
+ if (envelope.sessionId !== sessionId) {
83
36
  sendSessionError(
84
37
  socket,
85
- hostDeviceId,
38
+ sessionId,
86
39
  "invalid_message",
87
- "Envelope hostDeviceId does not match connection hostDeviceId",
40
+ "Envelope sessionId does not match connection sessionId",
88
41
  );
89
42
  return;
90
43
  }
91
44
 
92
- const session = sessions.get(hostDeviceId);
45
+ const session = sessions.get(sessionId);
93
46
  if (!session) {
94
- sendSessionError(socket, hostDeviceId, "device_not_found", "Device not found");
47
+ sendSessionError(socket, sessionId, "session_not_found", "Session not found");
95
48
  return;
96
49
  }
97
50
 
98
51
  try {
99
- if (shouldValidatePayload(envelope.type)) {
52
+ if (isProtocolMessageType(envelope.type)) {
100
53
  envelope = {
101
54
  ...envelope,
102
55
  payload: parseTypedPayload(envelope.type, envelope.payload),
@@ -104,73 +57,31 @@ export function handleSocketMessage(
104
57
  }
105
58
 
106
59
  if (role === "host") {
107
- handleHostMessage(envelope, raw, session, sessions);
60
+ handleHostMessage(envelope, session, sessions);
108
61
  } else {
109
- handleClientMessage(envelope, raw, socket, session, deviceId, sessions);
62
+ handleClientMessage(envelope, socket, session, deviceId, sessions);
110
63
  }
111
64
  } catch (error) {
112
65
  if (error instanceof ZodError) {
113
66
  sendSessionError(
114
67
  socket,
115
- hostDeviceId,
68
+ sessionId,
116
69
  "invalid_message",
117
- error.issues[0]?.message ?? "Invalid message payload",
70
+ error.errors[0]?.message ?? "Invalid message payload",
118
71
  );
119
72
  return;
120
73
  }
121
- sendSessionError(socket, hostDeviceId, "invalid_message", "Failed to handle message");
74
+ sendSessionError(socket, sessionId, "invalid_message", "Failed to handle message");
122
75
  }
123
76
  }
124
77
 
125
- function tryParseCodexRpcHeader(raw: string): { type: "agent.codex.rpc"; hostDeviceId: string } | undefined {
126
- const trimmed = raw.trimStart();
127
- if (!trimmed.startsWith("{")) return;
128
- const snippet = trimmed.slice(0, 8192);
129
- const hasType = /"type"\s*:\s*"agent\.codex\.rpc"/.test(snippet);
130
- if (!hasType) return;
131
- const hostMatch = /"hostDeviceId"\s*:\s*"([^"\\]*?)"/.exec(snippet);
132
- if (!hostMatch?.[1]) return;
133
- return { type: "agent.codex.rpc", hostDeviceId: hostMatch[1] };
134
- }
135
-
136
- function clientHasControl(session: ReturnType<DeviceManager["get"]> & {}, deviceId: string): boolean {
137
- return session.controllerId === deviceId;
138
- }
139
-
140
- function sendControlConflict(socket: WebSocket, hostDeviceId: string): void {
141
- safeSend(
142
- socket,
143
- serializeEnvelope(
144
- createEnvelope({
145
- type: "device.error",
146
- hostDeviceId,
147
- payload: {
148
- code: "control_conflict",
149
- message: "Not the controller",
150
- },
151
- }),
152
- ),
153
- hostDeviceId,
154
- );
155
- }
156
-
157
- function shouldValidatePayload(type: string): type is ProtocolMessageType {
158
- return (
159
- type === "device.connect" ||
160
- type === "device.ack" ||
161
- type === "device.resume" ||
162
- type === "terminal.input" ||
163
- type === "terminal.resize" ||
164
- type === "permission.decision" ||
165
- type === "permission.decision.result" ||
166
- type === "control.claim" ||
167
- type === "control.release"
168
- );
78
+ function isProtocolMessageType(type: string): type is ProtocolMessageType {
79
+ return Object.prototype.hasOwnProperty.call(protocolMessageSchemas, type);
169
80
  }
170
81
 
171
82
  function sendSessionError(
172
83
  socket: WebSocket,
173
- hostDeviceId: string,
84
+ sessionId: string,
174
85
  code: string,
175
86
  message: string,
176
87
  ): void {
@@ -178,8 +89,8 @@ function sendSessionError(
178
89
  socket.send(
179
90
  serializeEnvelope(
180
91
  createEnvelope({
181
- type: "device.error",
182
- hostDeviceId,
92
+ type: "session.error",
93
+ sessionId,
183
94
  payload: { code, message },
184
95
  }),
185
96
  ),
@@ -188,44 +99,42 @@ function sendSessionError(
188
99
 
189
100
  function handleHostMessage(
190
101
  envelope: Envelope,
191
- raw: string,
192
- session: ReturnType<DeviceManager["get"]> & {},
193
- sessions: DeviceManager,
102
+ session: ReturnType<SessionManager["get"]> & {},
103
+ sessions: SessionManager,
194
104
  ): void {
195
105
  switch (envelope.type) {
196
- case "device.connect": {
106
+ case "session.connect": {
197
107
  // Extract metadata from host's connect message
198
- const p = parseTypedPayload("device.connect", envelope.payload);
199
- if (p.machineId || p.hostname || p.platform || p.cwd || p.capabilities) {
108
+ const p = parseTypedPayload("session.connect", envelope.payload);
109
+ if (p.provider || p.machineId || p.hostname || p.platform || p.cwd || p.projectName) {
200
110
  sessions.setMetadata(
201
111
  session.id,
202
- undefined,
112
+ p.provider ?? undefined,
203
113
  p.machineId ?? undefined,
204
114
  p.hostname ?? undefined,
205
115
  p.platform ?? undefined,
206
116
  p.cwd ?? undefined,
207
- undefined,
208
- p.capabilities ?? undefined,
117
+ p.projectName ?? undefined,
209
118
  );
210
119
  }
211
120
  break;
212
121
  }
213
122
  case "terminal.output": {
214
123
  sessions.bufferOutput(session.id, envelope);
215
- broadcastToClients(session, envelope, raw);
124
+ broadcastToClients(session, envelope);
216
125
  break;
217
126
  }
218
127
  case "terminal.exit": {
219
128
  // Don't terminate session — other terminals may still be running
220
- broadcastToClients(session, envelope, raw);
129
+ broadcastToClients(session, envelope);
221
130
  break;
222
131
  }
223
- case "device.heartbeat":
132
+ case "session.heartbeat":
224
133
  break;
225
134
  case "permission.decision.result": {
226
135
  const p = parseTypedPayload("permission.decision.result", envelope.payload);
227
136
  resolveAgentPermissionHttpAck({
228
- hostDeviceId: session.id,
137
+ sessionId: session.id,
229
138
  ack: {
230
139
  requestId: p.requestId,
231
140
  decision: p.decision,
@@ -235,7 +144,7 @@ function handleHostMessage(
235
144
  message: p.message,
236
145
  },
237
146
  });
238
- broadcastToClients(session, envelope, raw);
147
+ broadcastToClients(session, envelope);
239
148
  break;
240
149
  }
241
150
  // Tunnel: host → gateway (not broadcast to clients)
@@ -256,80 +165,84 @@ function handleHostMessage(
256
165
  }
257
166
  case "control.grant":
258
167
  case "control.reject":
259
- broadcastToClients(session, envelope, raw);
168
+ broadcastToClients(session, envelope);
260
169
  break;
261
170
  // Screen sharing: host → clients
262
171
  case "screen.frame":
263
172
  case "screen.status":
264
173
  case "screen.offer":
265
174
  case "screen.ice":
266
- // Codex app-server JSON-RPC: host → clients.
267
- case "agent.codex.rpc":
268
- // Agent Workspace: host → clients
175
+ // Agent GUI: host → clients
176
+ case "agent.capabilities":
177
+ case "agent.update":
178
+ case "agent.permission.request":
179
+ case "agent.snapshot":
269
180
  case "agent.v2.capabilities":
270
181
  case "agent.v2.conversation.opened":
271
182
  case "agent.v2.conversation.list.result":
272
183
  case "agent.v2.event":
273
184
  case "agent.v2.snapshot":
274
- if (envelope.type === "agent.v2.snapshot" && Buffer.byteLength(raw, "utf8") > AGENT_SNAPSHOT_WARN_BYTES) {
275
- process.stderr.write(`[gateway:warn] oversized agent snapshot host=${session.id} bytes=${Buffer.byteLength(raw, "utf8")}\n`);
276
- }
277
- broadcastToClients(session, envelope, raw);
278
- break;
279
- case "agent.v2.history.page":
280
- case "agent.v2.delta":
281
- case "agent.v2.running_state":
282
185
  case "agent.v2.permission.request":
283
186
  // Multi-terminal: host → clients
284
187
  case "terminal.spawned":
285
188
  case "terminal.list":
286
189
  case "terminal.browse.result":
287
190
  case "terminal.file.read.result":
288
- broadcastToClients(session, envelope, raw);
191
+ broadcastToClients(session, envelope);
289
192
  break;
290
193
  // Structured status from hooks
291
194
  case "terminal.status":
292
195
  sessions.cacheStatus(session.id, envelope);
293
- broadcastToClients(session, envelope, raw);
196
+ broadcastToClients(session, envelope);
294
197
  break;
295
198
  default:
296
- broadcastToClients(session, envelope, raw);
199
+ broadcastToClients(session, envelope);
297
200
  break;
298
201
  }
299
202
  }
300
203
 
301
204
  function handleClientMessage(
302
205
  envelope: Envelope,
303
- raw: string,
304
206
  socket: WebSocket,
305
- session: ReturnType<DeviceManager["get"]> & {},
207
+ session: ReturnType<SessionManager["get"]> & {},
306
208
  deviceId: string,
307
- sessions: DeviceManager,
209
+ sessions: SessionManager,
308
210
  ): void {
309
211
  const requireController = (): boolean => {
310
- if (clientHasControl(session, deviceId)) return true;
311
- sendControlConflict(socket, session.id);
212
+ if (session.controllerId === deviceId) return true;
213
+ socket.send(
214
+ serializeEnvelope(
215
+ createEnvelope({
216
+ type: "session.error",
217
+ sessionId: session.id,
218
+ payload: {
219
+ code: "control_conflict",
220
+ message: "Not the controller",
221
+ },
222
+ }),
223
+ ),
224
+ );
312
225
  return false;
313
226
  };
314
227
 
315
228
  switch (envelope.type) {
316
229
  case "terminal.input": {
317
230
  if (!requireController()) return;
318
- sendToHost(session, envelope, raw);
231
+ sendToHost(session, envelope);
319
232
  break;
320
233
  }
321
234
  case "terminal.resize": {
322
235
  if (!requireController()) return;
323
- sendToHost(session, envelope, raw);
236
+ sendToHost(session, envelope);
324
237
  break;
325
238
  }
326
- case "device.ack": {
239
+ case "session.ack": {
327
240
  // Forward ACK to host
328
- sendToHost(session, envelope, raw);
241
+ sendToHost(session, envelope);
329
242
  break;
330
243
  }
331
- case "device.resume": {
332
- const p = parseTypedPayload("device.resume", envelope.payload);
244
+ case "session.resume": {
245
+ const p = parseTypedPayload("session.resume", envelope.payload);
333
246
  // Replay from gateway buffer first
334
247
  const replay = sessions.getReplayFrom(
335
248
  session.id,
@@ -338,24 +251,22 @@ function handleClientMessage(
338
251
  );
339
252
  for (const msg of replay) {
340
253
  const payload = msg.payload as Record<string, unknown>;
341
- safeSend(
342
- socket,
254
+ socket.send(
343
255
  serializeEnvelope(
344
256
  createEnvelope({
345
257
  type: "terminal.output",
346
- hostDeviceId: session.id,
258
+ sessionId: session.id,
347
259
  terminalId: msg.terminalId,
348
260
  seq: msg.seq,
349
261
  payload: { ...payload, isReplay: true },
350
262
  }),
351
263
  ),
352
- session.id,
353
264
  );
354
265
  }
355
266
  // Replay last terminal.status for each terminal
356
267
  const statusReplay = sessions.getStatusReplay(session.id);
357
268
  for (const statusMsg of statusReplay) {
358
- safeSend(socket, serializeEnvelope(statusMsg), session.id);
269
+ socket.send(serializeEnvelope(statusMsg));
359
270
  }
360
271
  // Also forward resume to host so it can fill gaps beyond gateway buffer.
361
272
  sendToHost(session, session.machineId
@@ -373,7 +284,7 @@ function handleClientMessage(
373
284
  sessions.claimControl(session.id, deviceId);
374
285
  const grantMsg = createEnvelope({
375
286
  type: "control.grant",
376
- hostDeviceId: session.id,
287
+ sessionId: session.id,
377
288
  payload: { deviceId },
378
289
  });
379
290
  // Broadcast to ALL clients so previous controller updates its state
@@ -385,20 +296,25 @@ function handleClientMessage(
385
296
  sessions.releaseControl(session.id, deviceId);
386
297
  const releaseMsg = createEnvelope({
387
298
  type: "control.release",
388
- hostDeviceId: session.id,
299
+ sessionId: session.id,
389
300
  payload: { deviceId },
390
301
  });
391
302
  broadcastToClients(session, releaseMsg);
392
303
  sendToHost(session, releaseMsg);
393
304
  break;
394
305
  }
395
- case "device.heartbeat":
306
+ case "session.heartbeat":
396
307
  break;
397
308
  // Screen sharing: client → host
398
309
  case "screen.start":
399
310
  case "screen.stop":
400
311
  case "screen.answer":
401
312
  case "screen.ice":
313
+ case "agent.session.new":
314
+ case "agent.session.load":
315
+ case "agent.prompt":
316
+ case "agent.cancel":
317
+ case "agent.permission.response":
402
318
  case "agent.v2.conversation.open":
403
319
  case "agent.v2.prompt":
404
320
  case "agent.v2.command.execute":
@@ -416,55 +332,41 @@ function handleClientMessage(
416
332
  case "file.upload":
417
333
  case "permission.decision":
418
334
  if (!requireController()) return;
419
- sendToHost(session, envelope, raw);
335
+ sendToHost(session, envelope);
420
336
  break;
337
+ case "agent.initialize":
338
+ case "agent.session.list":
421
339
  case "agent.v2.capabilities.request":
422
340
  case "agent.v2.conversation.list":
423
341
  case "agent.v2.snapshot.request":
424
- case "agent.v2.history.request":
425
- case "agent.v2.delta.request":
426
- // Codex app-server JSON-RPC: client → host.
427
- case "agent.codex.rpc":
428
- if (!requireController()) return;
429
- sendToHost(session, envelope, raw);
342
+ sendToHost(session, envelope);
430
343
  break;
431
344
  default:
432
- sendToHost(session, envelope, raw);
345
+ sendToHost(session, envelope);
433
346
  break;
434
347
  }
435
348
  }
436
349
 
437
- function safeSend(socket: WebSocket, data: string, hostDeviceId: string): boolean {
438
- if (socket.readyState !== socket.OPEN) return false;
439
- const limit = wsBufferedAmountLimit();
440
- const frameBytes = Buffer.byteLength(data);
441
- const queuedBytes = socket.bufferedAmount + frameBytes;
442
- if (queuedBytes > limit) {
443
- process.stderr.write(`[gateway:warn] closing slow socket host=${hostDeviceId} buffered=${socket.bufferedAmount} frame=${frameBytes} limit=${limit}\n`);
444
- socket.close(1013, "client too slow");
445
- return false;
446
- }
447
- socket.send(data);
448
- return true;
449
- }
450
-
451
350
  function broadcastToClients(
452
- session: ReturnType<DeviceManager["get"]> & {},
351
+ session: ReturnType<SessionManager["get"]> & {},
453
352
  envelope: Envelope,
454
- raw?: string,
455
353
  ): void {
456
- const data = raw ?? serializeEnvelope(envelope);
354
+ const data = serializeEnvelope(envelope);
457
355
  for (const [, client] of session.clients) {
458
- safeSend(client.socket, data, session.id);
356
+ if (client.socket.readyState === client.socket.OPEN) {
357
+ client.socket.send(data);
358
+ }
459
359
  }
460
360
  }
461
361
 
462
362
  function sendToHost(
463
- session: ReturnType<DeviceManager["get"]> & {},
363
+ session: ReturnType<SessionManager["get"]> & {},
464
364
  envelope: Envelope,
465
- raw?: string,
466
365
  ): void {
467
- if (session.host) {
468
- safeSend(session.host.socket, raw ?? serializeEnvelope(envelope), session.id);
366
+ if (
367
+ session.host &&
368
+ session.host.socket.readyState === session.host.socket.OPEN
369
+ ) {
370
+ session.host.socket.send(serializeEnvelope(envelope));
469
371
  }
470
372
  }