@linkshell/gateway 0.3.9 → 0.4.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 (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 +85 -161
  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 +11978 -3423
  30. package/dist/shared-protocol/src/index.js +114 -163
  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 +97 -193
  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,85 @@ 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":
186
+ case "agent.v2.notice":
283
187
  // Multi-terminal: host → clients
284
188
  case "terminal.spawned":
285
189
  case "terminal.list":
286
190
  case "terminal.browse.result":
287
191
  case "terminal.file.read.result":
288
- broadcastToClients(session, envelope, raw);
192
+ broadcastToClients(session, envelope);
289
193
  break;
290
194
  // Structured status from hooks
291
195
  case "terminal.status":
292
196
  sessions.cacheStatus(session.id, envelope);
293
- broadcastToClients(session, envelope, raw);
197
+ broadcastToClients(session, envelope);
294
198
  break;
295
199
  default:
296
- broadcastToClients(session, envelope, raw);
200
+ broadcastToClients(session, envelope);
297
201
  break;
298
202
  }
299
203
  }
300
204
 
301
205
  function handleClientMessage(
302
206
  envelope: Envelope,
303
- raw: string,
304
207
  socket: WebSocket,
305
- session: ReturnType<DeviceManager["get"]> & {},
208
+ session: ReturnType<SessionManager["get"]> & {},
306
209
  deviceId: string,
307
- sessions: DeviceManager,
210
+ sessions: SessionManager,
308
211
  ): void {
309
212
  const requireController = (): boolean => {
310
- if (clientHasControl(session, deviceId)) return true;
311
- sendControlConflict(socket, session.id);
213
+ if (session.controllerId === deviceId) return true;
214
+ socket.send(
215
+ serializeEnvelope(
216
+ createEnvelope({
217
+ type: "session.error",
218
+ sessionId: session.id,
219
+ payload: {
220
+ code: "control_conflict",
221
+ message: "Not the controller",
222
+ },
223
+ }),
224
+ ),
225
+ );
312
226
  return false;
313
227
  };
314
228
 
315
229
  switch (envelope.type) {
316
230
  case "terminal.input": {
317
231
  if (!requireController()) return;
318
- sendToHost(session, envelope, raw);
232
+ sendToHost(session, envelope);
319
233
  break;
320
234
  }
321
235
  case "terminal.resize": {
322
236
  if (!requireController()) return;
323
- sendToHost(session, envelope, raw);
237
+ sendToHost(session, envelope);
324
238
  break;
325
239
  }
326
- case "device.ack": {
240
+ case "session.ack": {
327
241
  // Forward ACK to host
328
- sendToHost(session, envelope, raw);
242
+ sendToHost(session, envelope);
329
243
  break;
330
244
  }
331
- case "device.resume": {
332
- const p = parseTypedPayload("device.resume", envelope.payload);
245
+ case "session.resume": {
246
+ const p = parseTypedPayload("session.resume", envelope.payload);
333
247
  // Replay from gateway buffer first
334
248
  const replay = sessions.getReplayFrom(
335
249
  session.id,
@@ -338,24 +252,22 @@ function handleClientMessage(
338
252
  );
339
253
  for (const msg of replay) {
340
254
  const payload = msg.payload as Record<string, unknown>;
341
- safeSend(
342
- socket,
255
+ socket.send(
343
256
  serializeEnvelope(
344
257
  createEnvelope({
345
258
  type: "terminal.output",
346
- hostDeviceId: session.id,
259
+ sessionId: session.id,
347
260
  terminalId: msg.terminalId,
348
261
  seq: msg.seq,
349
262
  payload: { ...payload, isReplay: true },
350
263
  }),
351
264
  ),
352
- session.id,
353
265
  );
354
266
  }
355
267
  // Replay last terminal.status for each terminal
356
268
  const statusReplay = sessions.getStatusReplay(session.id);
357
269
  for (const statusMsg of statusReplay) {
358
- safeSend(socket, serializeEnvelope(statusMsg), session.id);
270
+ socket.send(serializeEnvelope(statusMsg));
359
271
  }
360
272
  // Also forward resume to host so it can fill gaps beyond gateway buffer.
361
273
  sendToHost(session, session.machineId
@@ -373,7 +285,7 @@ function handleClientMessage(
373
285
  sessions.claimControl(session.id, deviceId);
374
286
  const grantMsg = createEnvelope({
375
287
  type: "control.grant",
376
- hostDeviceId: session.id,
288
+ sessionId: session.id,
377
289
  payload: { deviceId },
378
290
  });
379
291
  // Broadcast to ALL clients so previous controller updates its state
@@ -385,86 +297,78 @@ function handleClientMessage(
385
297
  sessions.releaseControl(session.id, deviceId);
386
298
  const releaseMsg = createEnvelope({
387
299
  type: "control.release",
388
- hostDeviceId: session.id,
300
+ sessionId: session.id,
389
301
  payload: { deviceId },
390
302
  });
391
303
  broadcastToClients(session, releaseMsg);
392
304
  sendToHost(session, releaseMsg);
393
305
  break;
394
306
  }
395
- case "device.heartbeat":
307
+ case "session.heartbeat":
396
308
  break;
397
309
  // Screen sharing: client → host
398
310
  case "screen.start":
399
311
  case "screen.stop":
400
312
  case "screen.answer":
401
313
  case "screen.ice":
314
+ case "agent.session.new":
315
+ case "agent.session.load":
316
+ case "agent.prompt":
317
+ case "agent.cancel":
318
+ case "agent.permission.response":
402
319
  case "agent.v2.conversation.open":
403
320
  case "agent.v2.prompt":
404
321
  case "agent.v2.command.execute":
405
322
  case "agent.v2.cancel":
406
323
  case "agent.v2.permission.respond":
407
324
  case "agent.v2.structured_input.respond":
408
- // Multi-terminal: client → host
325
+ // Multi-terminal write ops: client → host (require controller)
409
326
  case "terminal.spawn":
410
327
  case "terminal.kill":
411
- case "terminal.list":
412
- case "terminal.browse":
413
- case "terminal.file.read":
414
328
  case "terminal.mkdir":
415
- case "terminal.history.request":
416
329
  case "file.upload":
417
330
  case "permission.decision":
418
331
  if (!requireController()) return;
419
- sendToHost(session, envelope, raw);
332
+ sendToHost(session, envelope);
420
333
  break;
334
+ // Read-only ops: any client may issue (no controller gate)
335
+ case "terminal.list":
336
+ case "terminal.browse":
337
+ case "terminal.file.read":
338
+ case "terminal.history.request":
339
+ case "agent.initialize":
340
+ case "agent.session.list":
421
341
  case "agent.v2.capabilities.request":
422
342
  case "agent.v2.conversation.list":
423
343
  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);
344
+ sendToHost(session, envelope);
430
345
  break;
431
346
  default:
432
- sendToHost(session, envelope, raw);
347
+ sendToHost(session, envelope);
433
348
  break;
434
349
  }
435
350
  }
436
351
 
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
352
  function broadcastToClients(
452
- session: ReturnType<DeviceManager["get"]> & {},
353
+ session: ReturnType<SessionManager["get"]> & {},
453
354
  envelope: Envelope,
454
- raw?: string,
455
355
  ): void {
456
- const data = raw ?? serializeEnvelope(envelope);
356
+ const data = serializeEnvelope(envelope);
457
357
  for (const [, client] of session.clients) {
458
- safeSend(client.socket, data, session.id);
358
+ if (client.socket.readyState === client.socket.OPEN) {
359
+ client.socket.send(data);
360
+ }
459
361
  }
460
362
  }
461
363
 
462
364
  function sendToHost(
463
- session: ReturnType<DeviceManager["get"]> & {},
365
+ session: ReturnType<SessionManager["get"]> & {},
464
366
  envelope: Envelope,
465
- raw?: string,
466
367
  ): void {
467
- if (session.host) {
468
- safeSend(session.host.socket, raw ?? serializeEnvelope(envelope), session.id);
368
+ if (
369
+ session.host &&
370
+ session.host.socket.readyState === session.host.socket.OPEN
371
+ ) {
372
+ session.host.socket.send(serializeEnvelope(envelope));
469
373
  }
470
374
  }