@dev-anywhere/relay 0.1.8 → 0.2.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.
@@ -4,373 +4,85 @@
4
4
  import express from "express";
5
5
  import { existsSync } from "fs";
6
6
  import { createServer } from "http";
7
- import { homedir as homedir2 } from "os";
7
+ import { homedir } from "os";
8
8
  import { dirname as dirname2, resolve } from "path";
9
9
  import { fileURLToPath as fileURLToPath2 } from "url";
10
10
  import { WebSocketServer } from "ws";
11
11
 
12
12
  // src/registry.ts
13
13
  import { WebSocket } from "ws";
14
- var RelayRegistry = class {
15
- proxyStates = /* @__PURE__ */ new Map();
16
- clientBindings = /* @__PURE__ */ new Map();
17
- connectedClients = /* @__PURE__ */ new Set();
18
- registerProxy(proxyId, ws, name) {
19
- const existing = this.proxyStates.get(proxyId);
20
- if (existing) {
21
- if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
22
- existing.ws.terminate();
23
- }
24
- existing.ws = ws;
25
- existing.connectionState = "online";
26
- existing.disconnectedAt = null;
27
- if (name !== void 0) existing.name = name;
28
- return "reconnected";
29
- }
30
- this.proxyStates.set(proxyId, {
31
- ws,
32
- connectionState: "online",
33
- sessions: /* @__PURE__ */ new Set(),
34
- disconnectedAt: null,
35
- name
36
- });
37
- return "new";
38
- }
39
- // 标记 proxy 离线,保留所有状态等待重连,不设超时
40
- // 清理只在 proxy 主动退出(proxy_disconnect)或 relay 启动清理废弃数据时发生
41
- markProxyOffline(proxyId) {
42
- const state = this.proxyStates.get(proxyId);
43
- if (!state) return;
44
- state.ws = null;
45
- state.connectionState = "offline";
46
- state.disconnectedAt = Date.now();
47
- }
48
- // 显式状态转换,校验 from 状态匹配后更新 connectionState
49
- transitionProxy(proxyId, from, to) {
50
- if (from === to) {
51
- throw new Error(`Invalid proxy transition: ${from} -> ${to} (same state)`);
52
- }
53
- const state = this.proxyStates.get(proxyId);
54
- if (!state) {
55
- throw new Error(`Proxy not found: ${proxyId}`);
56
- }
57
- if (state.connectionState !== from) {
58
- throw new Error(
59
- `Proxy ${proxyId} state mismatch: expected ${from}, actual ${state.connectionState}`
60
- );
61
- }
62
- state.connectionState = to;
63
- if (to === "offline") {
64
- state.ws = null;
65
- state.disconnectedAt = Date.now();
66
- }
67
- }
68
- // 显式客户端状态转换,校验 from 状态匹配后更新 connectionState
69
- transitionClient(clientId, from, to) {
70
- if (from === to) {
71
- throw new Error(`Invalid client transition: ${from} -> ${to} (same state)`);
72
- }
73
- const binding = this.clientBindings.get(clientId);
74
- if (!binding) {
75
- throw new Error(`Client not found: ${clientId}`);
76
- }
77
- if (binding.connectionState !== from) {
78
- throw new Error(
79
- `Client ${clientId} state mismatch: expected ${from}, actual ${binding.connectionState}`
80
- );
81
- }
82
- binding.connectionState = to;
83
- }
84
- getProxyConnectionState(proxyId) {
85
- return this.proxyStates.get(proxyId)?.connectionState;
86
- }
87
- getClientConnectionState(clientId) {
88
- return this.clientBindings.get(clientId)?.connectionState;
89
- }
90
- // 彻底清理 proxy 状态和客户端绑定
91
- cleanupProxy(proxyId) {
92
- const state = this.proxyStates.get(proxyId);
93
- if (!state) return;
94
- for (const [clientId, binding] of this.clientBindings) {
95
- if (binding.proxyId === proxyId) {
96
- this.clientBindings.delete(clientId);
97
- }
98
- }
99
- this.proxyStates.delete(proxyId);
100
- }
101
- unregisterProxy(proxyId) {
102
- this.cleanupProxy(proxyId);
103
- }
104
- getProxy(proxyId) {
105
- const state = this.proxyStates.get(proxyId);
106
- return state?.ws ?? void 0;
107
- }
108
- isProxyOnline(proxyId) {
109
- const state = this.proxyStates.get(proxyId);
110
- if (!state || state.connectionState !== "online") return false;
111
- if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
112
- return true;
113
- }
114
- // proxy 是否存在(含宽限期中的)
115
- hasProxy(proxyId) {
116
- return this.proxyStates.has(proxyId);
117
- }
118
- listProxies() {
119
- return Array.from(this.proxyStates.keys());
120
- }
121
- // 返回 proxyId、name、online 的列表,用于 proxy_list_response
122
- listProxiesWithName() {
123
- return Array.from(this.proxyStates.entries()).map(([proxyId, state]) => ({
124
- proxyId,
125
- ...state.name !== void 0 ? { name: state.name } : {},
126
- online: state.connectionState === "online"
127
- }));
128
- }
129
- getProxyName(proxyId) {
130
- return this.proxyStates.get(proxyId)?.name;
131
- }
132
- // 将 sessionId 关联到 proxy
133
- addSessionToProxy(proxyId, sessionId) {
134
- const state = this.proxyStates.get(proxyId);
135
- if (state) {
136
- state.sessions.add(sessionId);
137
- }
138
- }
139
- // 通过 sessionId 反查所属 proxyId
140
- getProxyForSession(sessionId) {
141
- for (const [proxyId, state] of this.proxyStates) {
142
- if (state.sessions.has(sessionId)) {
143
- return proxyId;
144
- }
145
- }
146
- return void 0;
147
- }
148
- // 获取 proxy 关联的所有 sessionId
149
- getSessionsForProxy(proxyId) {
150
- const state = this.proxyStates.get(proxyId);
151
- return state ? Array.from(state.sessions) : [];
152
- }
153
- // clientId 绑定方式
154
- bindClientById(clientId, proxyId, ws) {
155
- if (!this.proxyStates.has(proxyId)) {
156
- return false;
157
- }
158
- this.clientBindings.set(clientId, { proxyId, ws, connectionState: "bound" });
159
- return true;
160
- }
161
- updateClientSocket(clientId, ws) {
162
- const binding = this.clientBindings.get(clientId);
163
- if (binding) {
164
- binding.ws = ws;
165
- }
166
- }
167
- // 断开客户端 WebSocket 但保留绑定关系,重连时可恢复
168
- unbindClientById(clientId) {
169
- const binding = this.clientBindings.get(clientId);
170
- if (binding) {
171
- binding.ws = null;
172
- }
173
- }
174
- getClientBinding(clientId) {
175
- return this.clientBindings.get(clientId);
176
- }
177
- // 获取绑定到指定 proxy 的所有活跃客户端 WebSocket
178
- getClientsForProxy(proxyId) {
179
- const clients = [];
180
- for (const [, binding] of this.clientBindings) {
181
- if (binding.proxyId === proxyId && binding.ws && binding.ws.readyState === WebSocket.OPEN) {
182
- clients.push(binding.ws);
183
- }
184
- }
185
- return clients;
186
- }
187
- countClients() {
188
- let count = 0;
189
- for (const [, binding] of this.clientBindings) {
190
- if (binding.ws) count++;
191
- }
192
- return count;
193
- }
194
- addClientWs(ws) {
195
- this.connectedClients.add(ws);
196
- }
197
- removeClientWs(ws) {
198
- this.connectedClients.delete(ws);
199
- }
200
- getAllClientWs() {
201
- const clients = [];
202
- for (const ws of this.connectedClients) {
203
- if (ws.readyState === WebSocket.OPEN) {
204
- clients.push(ws);
205
- }
206
- }
207
- return clients;
208
- }
209
- // 获取单个 proxy 的详细状态信息
210
- getProxyDetail(proxyId) {
211
- const state = this.proxyStates.get(proxyId);
212
- if (!state) return void 0;
213
- return {
214
- proxyId,
215
- ...state.name !== void 0 ? { name: state.name } : {},
216
- online: state.connectionState === "online",
217
- connectionState: state.connectionState,
218
- sessions: Array.from(state.sessions),
219
- disconnectedAt: state.disconnectedAt
220
- };
221
- }
222
- // 获取所有客户端绑定的详细信息
223
- getClientDetails() {
224
- const details = [];
225
- for (const [clientId, binding] of this.clientBindings) {
226
- details.push({
227
- clientId,
228
- proxyId: binding.proxyId,
229
- online: binding.ws !== null && binding.ws !== void 0 && binding.ws.readyState === WebSocket.OPEN,
230
- connectionState: binding.connectionState
231
- });
232
- }
233
- return details;
234
- }
235
- };
236
-
237
- // src/health.ts
238
- import { Router } from "express";
239
-
240
- // src/version.ts
241
- import { readFileSync } from "fs";
242
- import { dirname, join } from "path";
243
- import { fileURLToPath } from "url";
244
- function readRelayVersion() {
245
- const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
246
- const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
247
- return pkg.version ?? "unknown";
248
- }
249
- var RELAY_VERSION = readRelayVersion();
250
-
251
- // src/health.ts
252
- function bearerToken(authHeader) {
253
- const match = /^Bearer\s+(.+)$/i.exec(authHeader ?? "");
254
- return match?.[1] ?? null;
255
- }
256
- function healthRouter(registry, options = {}) {
257
- const router = Router();
258
- const proxyTokenRequired = options.proxyTokenRequired ?? false;
259
- const clientTokenRequired = options.clientTokenRequired ?? false;
260
- router.get("/health", (_req, res) => {
261
- res.json({
262
- status: "ok",
263
- version: RELAY_VERSION,
264
- uptime: process.uptime(),
265
- auth: {
266
- proxyTokenRequired,
267
- clientTokenRequired
268
- }
269
- });
270
- });
271
- router.get("/auth/client", (req, res) => {
272
- if (!clientTokenRequired) {
273
- res.status(204).end();
274
- return;
275
- }
276
- const token = bearerToken(req.get("authorization"));
277
- if (options.validateClientToken?.(token)) {
278
- res.status(204).end();
279
- return;
280
- }
281
- res.status(401).json({ error: "invalid_client_token" });
282
- });
283
- router.get("/admin/client-token", (req, res) => {
284
- if (!proxyTokenRequired) {
285
- res.status(401).json({ error: "proxy_token_required" });
286
- return;
287
- }
288
- const token = bearerToken(req.get("authorization"));
289
- if (!options.validateProxyToken?.(token)) {
290
- res.status(401).json({ error: "invalid_proxy_token" });
291
- return;
292
- }
293
- const clientToken = options.getClientToken?.() ?? null;
294
- if (!clientToken) {
295
- res.status(204).end();
296
- return;
297
- }
298
- res.json({ clientToken });
299
- });
300
- router.get("/status", (_req, res) => {
301
- res.json({
302
- version: RELAY_VERSION,
303
- proxyCount: registry.listProxies().length,
304
- clientCount: registry.countClients(),
305
- uptime: process.uptime()
306
- });
307
- });
308
- router.get("/api/status", (_req, res) => {
309
- res.json({
310
- version: RELAY_VERSION,
311
- proxyCount: registry.listProxies().length,
312
- clientCount: registry.countClients(),
313
- uptime: process.uptime(),
314
- bindings: registry.getClientDetails()
315
- });
316
- });
317
- router.get("/api/proxies", (_req, res) => {
318
- const proxyIds = registry.listProxies();
319
- const details = proxyIds.map((id) => registry.getProxyDetail(id)).filter((d) => d !== void 0);
320
- res.json(details);
321
- });
322
- router.get("/api/clients", (_req, res) => {
323
- res.json(registry.getClientDetails());
324
- });
325
- return router;
326
- }
327
-
328
- // src/handlers/proxy.ts
329
- import { WebSocket as WebSocket3 } from "ws";
330
14
 
331
15
  // ../../packages/shared/dist/schemas/envelope.js
332
- import { z as z5 } from "zod";
16
+ import { z as z6 } from "zod";
333
17
 
334
- // ../../packages/shared/dist/schemas/chat.js
18
+ // ../../packages/shared/dist/schemas/id.js
335
19
  import { z } from "zod";
336
- var UserInputPayloadSchema = z.object({
337
- text: z.string().min(1),
338
- messageId: z.string().min(1).optional()
20
+ var MAX_ID_LENGTH = 256;
21
+ var IdSchema = z.string().min(1).max(MAX_ID_LENGTH);
22
+
23
+ // ../../packages/shared/dist/schemas/chat.js
24
+ import { z as z2 } from "zod";
25
+ var UserInputPayloadSchema = z2.object({
26
+ text: z2.string().min(1),
27
+ messageId: z2.string().min(1).optional()
339
28
  });
340
- var AssistantMessagePayloadSchema = z.object({
341
- text: z.string(),
342
- isPartial: z.boolean()
29
+ var AssistantMessagePayloadSchema = z2.object({
30
+ text: z2.string(),
31
+ isPartial: z2.boolean()
343
32
  });
344
- var ThinkingPayloadSchema = z.object({
345
- text: z.string()
33
+ var ThinkingPayloadSchema = z2.object({
34
+ text: z2.string()
346
35
  });
347
36
 
348
37
  // ../../packages/shared/dist/schemas/tool.js
349
- import { z as z2 } from "zod";
350
- var ToolUseRequestPayloadSchema = z2.object({
351
- toolName: z2.string(),
352
- toolId: z2.string(),
353
- parameters: z2.record(z2.string(), z2.unknown())
38
+ import { z as z3 } from "zod";
39
+ var ToolUseRequestPayloadSchema = z3.object({
40
+ toolName: z3.string(),
41
+ toolId: IdSchema,
42
+ parameters: z3.record(z3.string(), z3.unknown())
354
43
  });
355
- var ToolApprovePayloadSchema = z2.object({
356
- toolId: z2.string(),
357
- whitelistTool: z2.boolean().optional()
44
+ var ToolApprovePayloadSchema = z3.object({
45
+ toolId: IdSchema,
46
+ whitelistTool: z3.boolean().optional()
358
47
  });
359
- var ToolDenyPayloadSchema = z2.object({
360
- toolId: z2.string(),
361
- reason: z2.string().optional()
48
+ var ToolDenyPayloadSchema = z3.object({
49
+ toolId: IdSchema,
50
+ reason: z3.string().optional()
362
51
  });
363
- var ToolResultPayloadSchema = z2.object({
364
- toolId: z2.string(),
365
- result: z2.unknown(),
366
- isError: z2.boolean()
52
+ var ToolResultPayloadSchema = z3.object({
53
+ toolId: IdSchema,
54
+ result: z3.unknown(),
55
+ isError: z3.boolean()
367
56
  });
368
57
 
369
58
  // ../../packages/shared/dist/schemas/session.js
370
- import { z as z3 } from "zod";
371
- var sessionStateValues = ["idle", "working", "waiting_approval", "error", "terminated"];
59
+ import { z as z4 } from "zod";
60
+
61
+ // ../../packages/shared/dist/constants/enums.js
372
62
  var providerValues = ["claude", "codex"];
373
63
  var ptyOwnerValues = ["local-terminal", "proxy-hosted"];
64
+ var sessionModeValues = ["pty", "json"];
65
+
66
+ // ../../packages/shared/dist/constants/pty.js
67
+ var PtySemanticState = {
68
+ WORKING: "working",
69
+ TURN_COMPLETE: "turn_complete",
70
+ APPROVAL_WAIT: "approval_wait"
71
+ };
72
+ var ptySemanticStateValues = [
73
+ PtySemanticState.WORKING,
74
+ PtySemanticState.TURN_COMPLETE,
75
+ PtySemanticState.APPROVAL_WAIT
76
+ ];
77
+
78
+ // ../../packages/shared/dist/schemas/session.js
79
+ var sessionStateValues = [
80
+ "idle",
81
+ "working",
82
+ "waiting_approval",
83
+ "error",
84
+ "terminated"
85
+ ];
374
86
  var agentStatusPhaseValues = [
375
87
  "idle",
376
88
  "thinking",
@@ -379,171 +91,180 @@ var agentStatusPhaseValues = [
379
91
  "waiting_permission",
380
92
  "error"
381
93
  ];
382
- var SessionInfoSchema = z3.object({
383
- sessionId: z3.string(),
384
- name: z3.string().optional(),
385
- state: z3.enum(sessionStateValues),
386
- mode: z3.enum(["pty", "json"]).optional(),
387
- provider: z3.enum(providerValues),
94
+ var SessionInfoSchema = z4.object({
95
+ sessionId: IdSchema,
96
+ name: z4.string().optional(),
97
+ state: z4.enum(sessionStateValues),
98
+ mode: z4.enum(sessionModeValues).optional(),
99
+ provider: z4.enum(providerValues),
388
100
  // PTY 尺寸所有权:
389
101
  // - local-terminal: 本地 terminal 进程持有真实 PTY,Web 只按原始 cols/rows 展示
390
102
  // - proxy-hosted: serve 内托管 PTY,Web 可按视口请求 resize
391
- ptyOwner: z3.enum(ptyOwnerValues).optional(),
392
- lastActive: z3.number().optional()
103
+ ptyOwner: z4.enum(ptyOwnerValues).optional(),
104
+ lastActive: z4.number().optional()
393
105
  });
394
- var SessionCreatePayloadSchema = z3.object({
395
- name: z3.string().optional(),
396
- cwd: z3.string().optional(),
397
- streamDelta: z3.boolean().optional()
106
+ var SessionCreatePayloadSchema = z4.object({
107
+ name: z4.string().optional(),
108
+ cwd: z4.string().optional(),
109
+ streamDelta: z4.boolean().optional()
398
110
  });
399
- var SessionListPayloadSchema = z3.object({
400
- sessions: z3.array(SessionInfoSchema)
111
+ var SessionListPayloadSchema = z4.object({
112
+ sessions: z4.array(SessionInfoSchema)
401
113
  });
402
- var SessionSwitchPayloadSchema = z3.object({
403
- sessionId: z3.string()
114
+ var SessionSwitchPayloadSchema = z4.object({
115
+ sessionId: IdSchema
404
116
  });
405
- var SessionTerminatePayloadSchema = z3.object({
406
- sessionId: z3.string()
117
+ var SessionTerminatePayloadSchema = z4.object({
118
+ sessionId: IdSchema
407
119
  });
408
- var SessionStatusPayloadSchema = z3.object({
409
- sessionId: z3.string(),
410
- state: z3.enum(sessionStateValues),
411
- lastActive: z3.number()
120
+ var SessionStatusPayloadSchema = z4.object({
121
+ sessionId: IdSchema,
122
+ state: z4.enum(sessionStateValues),
123
+ lastActive: z4.number()
412
124
  });
413
- var PtyStatePayloadSchema = z3.object({
414
- state: z3.enum(["working", "turn_complete", "approval_wait", "mid_pause"]),
415
- title: z3.string().optional(),
416
- tool: z3.string().optional()
125
+ var PtyStatePayloadSchema = z4.object({
126
+ state: z4.enum(ptySemanticStateValues),
127
+ title: z4.string().optional(),
128
+ tool: z4.string().optional()
417
129
  });
418
- var AgentStatusPayloadSchema = z3.object({
419
- provider: z3.enum(providerValues),
420
- phase: z3.enum(agentStatusPhaseValues),
421
- seq: z3.number().int().nonnegative(),
422
- updatedAt: z3.number(),
423
- toolName: z3.string().optional(),
424
- toolInput: z3.record(z3.string(), z3.unknown()).optional(),
425
- permissionRequest: z3.object({
426
- requestId: z3.string(),
427
- toolName: z3.string(),
428
- input: z3.record(z3.string(), z3.unknown())
130
+ var AgentStatusPayloadSchema = z4.object({
131
+ provider: z4.enum(providerValues),
132
+ phase: z4.enum(agentStatusPhaseValues),
133
+ seq: z4.number().int().nonnegative(),
134
+ updatedAt: z4.number(),
135
+ toolName: z4.string().optional(),
136
+ toolInput: z4.record(z4.string(), z4.unknown()).optional(),
137
+ permissionRequest: z4.object({
138
+ requestId: IdSchema,
139
+ toolName: z4.string(),
140
+ input: z4.record(z4.string(), z4.unknown())
429
141
  }).optional(),
430
- permissionResolution: z3.object({
431
- requestId: z3.string(),
432
- outcome: z3.enum(["allow", "deny"])
142
+ permissionResolution: z4.object({
143
+ requestId: IdSchema,
144
+ outcome: z4.enum(["allow", "deny"])
433
145
  }).optional(),
434
- summary: z3.string().optional()
146
+ summary: z4.string().optional()
435
147
  });
436
148
 
437
149
  // ../../packages/shared/dist/schemas/system.js
438
- import { z as z4 } from "zod";
439
- var HeartbeatPayloadSchema = z4.object({});
440
- var AuthPayloadSchema = z4.object({
441
- pairingCode: z4.string().optional(),
442
- token: z4.string().optional()
150
+ import { z as z5 } from "zod";
151
+ var HeartbeatPayloadSchema = z5.object({});
152
+ var AuthPayloadSchema = z5.object({
153
+ pairingCode: z5.string().optional(),
154
+ token: z5.string().optional()
443
155
  });
444
- var SyncRequestPayloadSchema = z4.object({
445
- lastSeq: z4.number().int().nonnegative()
156
+ var SyncRequestPayloadSchema = z5.object({
157
+ lastSeq: z5.number().int().nonnegative()
446
158
  });
447
- var SyncResponsePayloadSchema = z4.object({
448
- messages: z4.array(z4.record(z4.string(), z4.unknown()))
159
+ var SyncResponsePayloadSchema = z5.object({
160
+ messages: z5.array(z5.record(z5.string(), z5.unknown()))
449
161
  });
450
162
 
451
163
  // ../../packages/shared/dist/schemas/envelope.js
452
164
  var BaseEnvelopeFields = {
453
- seq: z5.number().int().nonnegative(),
454
- sessionId: z5.string(),
455
- timestamp: z5.number(),
456
- source: z5.enum(["proxy", "client"]),
457
- version: z5.string()
165
+ seq: z6.number().int().nonnegative(),
166
+ timestamp: z6.number(),
167
+ source: z6.enum(["proxy", "client"]),
168
+ version: z6.string()
169
+ };
170
+ var SessionedEnvelopeFields = {
171
+ ...BaseEnvelopeFields,
172
+ sessionId: IdSchema
458
173
  };
459
- var MessageEnvelopeSchema = z5.discriminatedUnion("type", [
174
+ var MessageEnvelopeSchema = z6.discriminatedUnion("type", [
460
175
  // chat (3)
461
- z5.object({
462
- ...BaseEnvelopeFields,
463
- type: z5.literal("user_input"),
176
+ z6.object({
177
+ ...SessionedEnvelopeFields,
178
+ type: z6.literal("user_input"),
464
179
  payload: UserInputPayloadSchema
465
180
  }),
466
- z5.object({
467
- ...BaseEnvelopeFields,
468
- type: z5.literal("assistant_message"),
181
+ z6.object({
182
+ ...SessionedEnvelopeFields,
183
+ type: z6.literal("assistant_message"),
469
184
  payload: AssistantMessagePayloadSchema
470
185
  }),
471
- z5.object({
472
- ...BaseEnvelopeFields,
473
- type: z5.literal("thinking"),
186
+ z6.object({
187
+ ...SessionedEnvelopeFields,
188
+ type: z6.literal("thinking"),
474
189
  payload: ThinkingPayloadSchema
475
190
  }),
476
191
  // tool (4): 工具审批决策属于 relay control,不进入会话消息信封。
477
192
  // tool_use_request: 审批流请求(proxy → client),toolId 是 approval requestId
478
- z5.object({
479
- ...BaseEnvelopeFields,
480
- type: z5.literal("tool_use_request"),
193
+ z6.object({
194
+ ...SessionedEnvelopeFields,
195
+ type: z6.literal("tool_use_request"),
481
196
  payload: ToolUseRequestPayloadSchema
482
197
  }),
483
198
  // tool_result: 工具执行结果(proxy → client),toolId 对应 assistant_tool_use / tool_use_request 的 toolId
484
- z5.object({
485
- ...BaseEnvelopeFields,
486
- type: z5.literal("tool_result"),
199
+ z6.object({
200
+ ...SessionedEnvelopeFields,
201
+ type: z6.literal("tool_result"),
487
202
  payload: ToolResultPayloadSchema
488
203
  }),
489
204
  // assistant_tool_use: 纯展示型工具调用(proxy → client),区别于 tool_use_request 无审批语义
490
205
  // payload 结构复用 ToolUseRequestPayloadSchema;toolId 是 Claude 分配的 tool_use id
491
- z5.object({
492
- ...BaseEnvelopeFields,
493
- type: z5.literal("assistant_tool_use"),
206
+ z6.object({
207
+ ...SessionedEnvelopeFields,
208
+ type: z6.literal("assistant_tool_use"),
494
209
  payload: ToolUseRequestPayloadSchema
495
210
  }),
496
211
  // session (5)
497
- z5.object({
498
- ...BaseEnvelopeFields,
499
- type: z5.literal("session_create"),
212
+ z6.object({
213
+ ...SessionedEnvelopeFields,
214
+ type: z6.literal("session_create"),
500
215
  payload: SessionCreatePayloadSchema
501
216
  }),
502
- z5.object({
217
+ // session_list 是全局广播 (列出所有 session), 不绑定具体 sessionId, 不携带该字段。
218
+ z6.object({
503
219
  ...BaseEnvelopeFields,
504
- type: z5.literal("session_list"),
220
+ type: z6.literal("session_list"),
505
221
  payload: SessionListPayloadSchema
506
222
  }),
507
- z5.object({
508
- ...BaseEnvelopeFields,
509
- type: z5.literal("session_switch"),
223
+ z6.object({
224
+ ...SessionedEnvelopeFields,
225
+ type: z6.literal("session_switch"),
510
226
  payload: SessionSwitchPayloadSchema
511
227
  }),
512
- z5.object({
513
- ...BaseEnvelopeFields,
514
- type: z5.literal("session_terminate"),
228
+ z6.object({
229
+ ...SessionedEnvelopeFields,
230
+ type: z6.literal("session_terminate"),
515
231
  payload: SessionTerminatePayloadSchema
516
232
  }),
517
- z5.object({
518
- ...BaseEnvelopeFields,
519
- type: z5.literal("session_status"),
233
+ z6.object({
234
+ ...SessionedEnvelopeFields,
235
+ type: z6.literal("session_status"),
520
236
  payload: SessionStatusPayloadSchema
521
237
  }),
522
- // system (5)
523
- z5.object({
238
+ // system (5): 心跳 / 认证 / 同步——全局, 无 sessionId
239
+ z6.object({
524
240
  ...BaseEnvelopeFields,
525
- type: z5.literal("heartbeat"),
241
+ type: z6.literal("heartbeat"),
526
242
  payload: HeartbeatPayloadSchema
527
243
  }),
528
- z5.object({
244
+ z6.object({
529
245
  ...BaseEnvelopeFields,
530
- type: z5.literal("auth"),
246
+ type: z6.literal("auth"),
531
247
  payload: AuthPayloadSchema
532
248
  }),
533
- z5.object({
249
+ z6.object({
534
250
  ...BaseEnvelopeFields,
535
- type: z5.literal("sync_request"),
251
+ type: z6.literal("sync_request"),
536
252
  payload: SyncRequestPayloadSchema
537
253
  }),
538
- z5.object({
254
+ z6.object({
539
255
  ...BaseEnvelopeFields,
540
- type: z5.literal("sync_response"),
256
+ type: z6.literal("sync_response"),
541
257
  payload: SyncResponsePayloadSchema
542
258
  })
543
259
  ]);
544
260
 
261
+ // ../../packages/shared/dist/builders/index.js
262
+ function serializeControl(msg) {
263
+ return JSON.stringify(msg);
264
+ }
265
+
545
266
  // ../../packages/shared/dist/schemas/relay-control.js
546
- import { z as z6 } from "zod";
267
+ import { z as z7 } from "zod";
547
268
 
548
269
  // ../../packages/shared/dist/constants/relay-errors.js
549
270
  var RelayErrorCode = {
@@ -570,297 +291,301 @@ var ControlErrorCode = {
570
291
  };
571
292
 
572
293
  // ../../packages/shared/dist/schemas/relay-control.js
573
- var ProxyInfoSchema = z6.object({
574
- proxyId: z6.string(),
575
- name: z6.string().optional(),
576
- online: z6.boolean(),
577
- sessions: z6.array(z6.string()).optional()
294
+ var ProxyInfoSchema = z7.object({
295
+ proxyId: IdSchema,
296
+ name: z7.string().optional(),
297
+ online: z7.boolean(),
298
+ sessions: z7.array(z7.string()).optional()
578
299
  });
579
- var AgentCliAvailabilitySchema = z6.object({
580
- available: z6.boolean(),
581
- command: z6.string().optional(),
582
- error: z6.string().optional(),
583
- suggestions: z6.array(z6.string()).optional()
300
+ var AgentCliAvailabilitySchema = z7.object({
301
+ available: z7.boolean(),
302
+ command: z7.string().optional(),
303
+ error: z7.string().optional(),
304
+ suggestions: z7.array(z7.string()).optional()
584
305
  });
585
- var AgentCliStatusSchema = z6.object({
306
+ var AgentCliStatusSchema = z7.object({
586
307
  claude: AgentCliAvailabilitySchema,
587
308
  codex: AgentCliAvailabilitySchema
588
309
  });
589
- var DirEntrySchema = z6.object({ name: z6.string(), isDir: z6.boolean() });
590
- var FileTreeGroupSchema = z6.object({
591
- path: z6.string(),
592
- entries: z6.array(DirEntrySchema)
310
+ var DirEntrySchema = z7.object({ name: z7.string(), isDir: z7.boolean() });
311
+ var FileTreeGroupSchema = z7.object({
312
+ path: z7.string(),
313
+ entries: z7.array(DirEntrySchema)
593
314
  });
594
- var CommandEntrySchema = z6.object({
595
- name: z6.string(),
596
- description: z6.string(),
597
- argumentHint: z6.string().optional(),
598
- source: z6.string()
315
+ var CommandEntrySchema = z7.object({
316
+ name: z7.string(),
317
+ description: z7.string(),
318
+ argumentHint: z7.string().optional(),
319
+ source: z7.string()
599
320
  });
600
- var HistorySessionSchema = z6.object({
601
- id: z6.string(),
602
- title: z6.string(),
603
- projectDir: z6.string(),
604
- updatedAt: z6.number(),
605
- provider: z6.enum(["claude", "codex"]).optional()
321
+ var HistorySessionSchema = z7.object({
322
+ id: z7.string(),
323
+ title: z7.string(),
324
+ projectDir: z7.string(),
325
+ updatedAt: z7.number(),
326
+ provider: z7.enum(providerValues).optional()
606
327
  });
607
- var SessionHistoryMessageSchema = z6.object({
608
- role: z6.enum(["user", "assistant"]),
609
- text: z6.string(),
610
- timestamp: z6.number().optional(),
611
- cursor: z6.string().optional()
328
+ var SessionHistoryMessageSchema = z7.object({
329
+ role: z7.enum(["user", "assistant"]),
330
+ text: z7.string(),
331
+ timestamp: z7.number().optional(),
332
+ cursor: z7.string().optional()
612
333
  });
613
- var RequestIdShape = { requestId: z6.string().min(1).optional() };
614
- var ControlErrorCodeSchema = z6.enum(Object.values(ControlErrorCode));
334
+ var RequestIdShape = { requestId: IdSchema.optional() };
335
+ var ControlErrorCodeSchema = z7.enum(Object.values(ControlErrorCode));
615
336
  var RequestErrorShape = {
616
- error: z6.string().optional(),
337
+ error: z7.string().optional(),
617
338
  errorCode: ControlErrorCodeSchema.optional()
618
339
  };
619
- var ClipboardImageMimeTypeSchema = z6.enum(["image/png", "image/jpeg", "image/webp", "image/gif"]);
340
+ var ClipboardImageMimeTypeSchema = z7.enum(["image/png", "image/jpeg", "image/webp", "image/gif"]);
620
341
  function control(type, shape, directions) {
621
342
  return {
622
343
  type,
623
344
  directions: new Set(Array.isArray(directions) ? directions : directions ? [directions] : []),
624
- schema: z6.object({
625
- type: z6.literal(type),
345
+ schema: z7.object({
346
+ type: z7.literal(type),
626
347
  ...shape ?? {}
627
348
  })
628
349
  };
629
350
  }
630
351
  var relayControlDefinitions = [
631
352
  control("proxy_register", {
632
- proxyId: z6.string().min(1),
633
- name: z6.string().optional()
353
+ proxyId: IdSchema,
354
+ name: z7.string().optional()
634
355
  }),
635
356
  control("proxy_register_response", {
636
- status: z6.enum(["new", "reconnected"])
357
+ status: z7.enum(["new", "reconnected"])
637
358
  }),
638
359
  control("proxy_list_request", RequestIdShape),
639
360
  control("proxy_list_response", {
640
361
  ...RequestIdShape,
641
- proxies: z6.array(ProxyInfoSchema)
362
+ proxies: z7.array(ProxyInfoSchema)
642
363
  }),
643
- control("proxy_select", { ...RequestIdShape, proxyId: z6.string().min(1) }),
364
+ control("proxy_select", { ...RequestIdShape, proxyId: IdSchema }),
644
365
  control("proxy_select_response", {
645
366
  ...RequestIdShape,
646
- success: z6.boolean(),
647
- proxyId: z6.string().optional(),
367
+ success: z7.boolean(),
368
+ proxyId: IdSchema.optional(),
648
369
  ...RequestErrorShape
649
370
  }),
650
371
  control("relay_error", {
651
- code: z6.enum(Object.values(RelayErrorCode)),
652
- message: z6.string()
372
+ code: z7.enum(Object.values(RelayErrorCode)),
373
+ message: z7.string()
653
374
  }),
654
375
  // 客户端注册协议
655
376
  control("client_register", {
656
- clientId: z6.string().min(1)
377
+ clientId: IdSchema
657
378
  }),
658
379
  control("client_register_response", {
659
- status: z6.enum(["restored", "proxy_offline", "new"]),
660
- proxyId: z6.string().optional()
380
+ status: z7.enum(["restored", "proxy_offline", "new"]),
381
+ proxyId: IdSchema.optional()
661
382
  }),
662
383
  // Proxy 离线通知
663
384
  control("proxy_offline", {
664
- proxyId: z6.string()
385
+ proxyId: IdSchema
665
386
  }),
666
387
  // Proxy 主动断开,relay 立即清理资源
667
388
  control("proxy_disconnect", {
668
- proxyId: z6.string().min(1)
389
+ proxyId: IdSchema
669
390
  }),
670
391
  // Proxy 重连后通知 client 恢复
671
392
  control("proxy_online", {
672
- proxyId: z6.string().min(1)
393
+ proxyId: IdSchema
673
394
  }),
674
395
  // 目录列表请求与响应
675
396
  control("dir_list_request", {
676
- proxyId: z6.string().min(1).optional(),
397
+ proxyId: IdSchema.optional(),
677
398
  ...RequestIdShape,
678
- path: z6.string()
399
+ path: z7.string()
679
400
  }, "client_to_proxy"),
680
- control("dir_list_response", { ...RequestIdShape, ...RequestErrorShape, entries: z6.array(DirEntrySchema), path: z6.string() }, "proxy_to_client"),
401
+ control("dir_list_response", { ...RequestIdShape, ...RequestErrorShape, entries: z7.array(DirEntrySchema), path: z7.string() }, "proxy_to_client"),
681
402
  // 目录创建请求与响应
682
- control("dir_create_request", { ...RequestIdShape, path: z6.string() }, "client_to_proxy"),
403
+ control("dir_create_request", { ...RequestIdShape, path: z7.string() }, "client_to_proxy"),
683
404
  control("dir_create_response", {
684
405
  ...RequestIdShape,
685
406
  ...RequestErrorShape,
686
- path: z6.string(),
687
- success: z6.boolean()
407
+ path: z7.string(),
408
+ success: z7.boolean()
688
409
  }, "proxy_to_client"),
689
410
  // 命令列表推送,proxy 将可用命令列表推给 client
690
- control("command_list_push", { commands: z6.array(CommandEntrySchema) }, "proxy_to_client"),
411
+ control("command_list_push", { commands: z7.array(CommandEntrySchema) }, "proxy_to_client"),
691
412
  // 文件树推送: 按目录分组, 首组 path 即为 session cwd
692
413
  // 前端直接把每组写入 tree[path], 与 dir_list_response 共享 cache slot
693
414
  control("file_tree_push", {
694
- groups: z6.array(FileTreeGroupSchema)
415
+ groups: z7.array(FileTreeGroupSchema)
695
416
  }, "proxy_to_client"),
696
417
  // 会话列表请求与权限模式变更
697
418
  control("session_list", void 0, ["client_to_proxy", "proxy_to_client"]),
698
419
  control("permission_mode_change", {
699
- mode: z6.enum(["default", "auto_accept", "plan"]),
420
+ mode: z7.enum(["default", "auto_accept", "plan"]),
700
421
  // sessionId 可选:传入时 proxy 按该会话的 mode 分叉(PTY 发 Tab ANSI),未传走全局日志行为
701
- sessionId: z6.string().optional()
422
+ sessionId: IdSchema.optional()
702
423
  }, "client_to_proxy"),
703
424
  // 会话历史浏览
704
425
  control("session_history_request", RequestIdShape, "client_to_proxy"),
705
- control("session_history_response", { ...RequestIdShape, sessions: z6.array(HistorySessionSchema) }, "proxy_to_client"),
426
+ control("session_history_response", { ...RequestIdShape, sessions: z7.array(HistorySessionSchema) }, "proxy_to_client"),
706
427
  // PTY 语义状态,从 Envelope 迁移到 Control 层
707
- control("pty_state", { sessionId: z6.string(), payload: PtyStatePayloadSchema }, "proxy_to_client"),
428
+ control("pty_state", { sessionId: IdSchema, payload: PtyStatePayloadSchema }, "proxy_to_client"),
708
429
  // Provider 语义状态,来自 Claude/Codex hook 等结构化事件,不从 PTY 字节推断
709
- control("agent_status", { sessionId: z6.string(), payload: AgentStatusPayloadSchema }, "proxy_to_client"),
430
+ control("agent_status", { sessionId: IdSchema, payload: AgentStatusPayloadSchema }, "proxy_to_client"),
710
431
  // 终端标题变化,proxy -> client
711
- control("terminal_title", { sessionId: z6.string(), title: z6.string() }, "proxy_to_client"),
432
+ control("terminal_title", { sessionId: IdSchema, title: z7.string() }, "proxy_to_client"),
712
433
  // 终端尺寸变化,proxy -> client
713
- control("terminal_resize", { sessionId: z6.string(), cols: z6.number().int().positive(), rows: z6.number().int().positive() }, "proxy_to_client"),
714
- control("terminal_resize_request", { sessionId: z6.string(), cols: z6.number().int().positive(), rows: z6.number().int().positive() }, "client_to_proxy"),
434
+ control("terminal_resize", { sessionId: IdSchema, cols: z7.number().int().positive(), rows: z7.number().int().positive() }, "proxy_to_client"),
435
+ control("terminal_resize_request", { sessionId: IdSchema, cols: z7.number().int().positive(), rows: z7.number().int().positive() }, "client_to_proxy"),
715
436
  // 远程终止 JSON 会话,client -> proxy
716
- control("session_terminate", { sessionId: z6.string() }, "client_to_proxy"),
437
+ control("session_terminate", { sessionId: IdSchema }, "client_to_proxy"),
717
438
  // 中断当前 turn,client -> proxy,SIGINT 到 worker 进程让 claude CLI abort 当前流
718
- control("session_worker_abort", { sessionId: z6.string() }, "client_to_proxy"),
439
+ control("session_worker_abort", { sessionId: IdSchema }, "client_to_proxy"),
719
440
  // turn 完成信号,proxy -> client,对应 claude stream-json 的 result 事件
720
441
  control("turn_result", {
721
- sessionId: z6.string(),
722
- success: z6.boolean(),
723
- isError: z6.boolean(),
442
+ sessionId: IdSchema,
443
+ success: z7.boolean(),
444
+ isError: z7.boolean(),
724
445
  // stream-json result.result 是本轮最终文本。assistant_message 流丢失或 CLI 未发增量时,
725
446
  // Web 用它作为 JSON 模式兜底展示,避免 turn 已结束但界面空白。
726
- result: z6.string().optional()
447
+ result: z7.string().optional()
727
448
  }, "proxy_to_client"),
728
449
  // 客户端发送到 PTY 的原始字节(ANSI 序列),不追加换行
729
- control("remote_input_raw", { sessionId: z6.string().min(1), data: z6.string() }, "client_to_proxy"),
450
+ control("remote_input_raw", { sessionId: IdSchema, data: z7.string() }, "client_to_proxy"),
730
451
  control("clipboard_image_upload", {
731
452
  ...RequestIdShape,
732
- sessionId: z6.string().min(1),
453
+ sessionId: IdSchema,
733
454
  mimeType: ClipboardImageMimeTypeSchema,
734
- dataBase64: z6.string().min(1),
735
- fileName: z6.string().optional()
455
+ dataBase64: z7.string().min(1),
456
+ fileName: z7.string().optional()
736
457
  }, "client_to_proxy"),
737
458
  control("clipboard_image_upload_response", {
738
459
  ...RequestIdShape,
739
460
  ...RequestErrorShape,
740
- sessionId: z6.string().min(1),
741
- success: z6.boolean(),
742
- path: z6.string()
461
+ sessionId: IdSchema,
462
+ success: z7.boolean(),
463
+ // success=false 时 proxy 没有有效 path 可填;保持 optional 以避免占位空字符串通过校验。
464
+ path: z7.string().optional()
743
465
  }, "proxy_to_client"),
744
466
  control("image_preview_request", {
745
467
  ...RequestIdShape,
746
- sessionId: z6.string().min(1),
747
- path: z6.string().min(1)
468
+ sessionId: IdSchema,
469
+ path: z7.string().min(1)
748
470
  }, "client_to_proxy"),
749
471
  control("image_preview_response", {
750
472
  ...RequestIdShape,
751
473
  ...RequestErrorShape,
752
- sessionId: z6.string().min(1),
753
- success: z6.boolean(),
754
- path: z6.string(),
474
+ sessionId: IdSchema,
475
+ success: z7.boolean(),
476
+ // 同 clipboard_image_upload_response:失败时 proxy 不一定有路径。
477
+ path: z7.string().optional(),
755
478
  mimeType: ClipboardImageMimeTypeSchema.optional(),
756
- dataBase64: z6.string().optional(),
757
- size: z6.number().int().nonnegative().optional()
479
+ dataBase64: z7.string().optional(),
480
+ size: z7.number().int().nonnegative().optional()
758
481
  }, "proxy_to_client"),
759
482
  // 客户端询问 proxy 的环境信息 (home 路径等), client -> proxy -> response
760
483
  // FilePathPicker 用 homePath 作为 select 模式下的默认起点, 新建会话时打开即可浏览
761
484
  control("proxy_info_request", RequestIdShape, "client_to_proxy"),
762
- control("proxy_info", { ...RequestIdShape, homePath: z6.string(), agentCli: AgentCliStatusSchema }, "proxy_to_client"),
763
- control("agent_cli_config_update", { ...RequestIdShape, provider: z6.enum(["claude", "codex"]), path: z6.string().min(1) }, "client_to_proxy"),
485
+ control("proxy_info", { ...RequestIdShape, homePath: z7.string(), agentCli: AgentCliStatusSchema }, "proxy_to_client"),
486
+ control("agent_cli_config_update", { ...RequestIdShape, provider: z7.enum(providerValues), path: z7.string().min(1) }, "client_to_proxy"),
764
487
  control("agent_cli_config_update_response", {
765
488
  ...RequestIdShape,
766
- provider: z6.enum(["claude", "codex"]),
489
+ provider: z7.enum(providerValues),
767
490
  agentCli: AgentCliStatusSchema.optional(),
768
491
  ...RequestErrorShape
769
492
  }, "proxy_to_client"),
770
493
  // 远程创建 JSON 会话,client -> proxy -> response
771
494
  control("session_create", {
772
495
  ...RequestIdShape,
773
- cwd: z6.string(),
774
- provider: z6.enum(["claude", "codex"]),
775
- mode: z6.enum(["json", "pty"]).optional(),
776
- resumeSessionId: z6.string().optional(),
496
+ cwd: z7.string(),
497
+ provider: z7.enum(providerValues),
498
+ mode: z7.enum(sessionModeValues).optional(),
499
+ resumeSessionId: z7.string().optional(),
777
500
  // 透传给 claude CLI 的 --permission-mode, undefined 时 proxy 兜底为 "default"
778
- permissionMode: z6.enum(["default", "auto", "acceptEdits", "plan", "bypassPermissions", "dontAsk"]).optional()
501
+ permissionMode: z7.enum(["default", "auto", "acceptEdits", "plan", "bypassPermissions", "dontAsk"]).optional()
779
502
  }, "client_to_proxy"),
780
503
  control("session_create_response", {
781
504
  ...RequestIdShape,
782
- sessionId: z6.string(),
783
- mode: z6.enum(["json", "pty"]).optional(),
784
- provider: z6.enum(["claude", "codex"]).optional(),
785
- ptyOwner: z6.enum(["local-terminal", "proxy-hosted"]).optional(),
505
+ // 失败路径只送 errorCode/error, sessionId 此时无语义。成功路径才有 id。
506
+ sessionId: IdSchema.optional(),
507
+ mode: z7.enum(sessionModeValues).optional(),
508
+ provider: z7.enum(providerValues).optional(),
509
+ ptyOwner: z7.enum(ptyOwnerValues).optional(),
786
510
  ...RequestErrorShape
787
511
  }, "proxy_to_client"),
788
512
  // 客户端请求会话历史消息,client -> proxy
789
513
  control("session_messages_request", {
790
514
  ...RequestIdShape,
791
- sessionId: z6.string(),
792
- limit: z6.number().int().min(1).max(200).optional(),
793
- before: z6.string().optional()
515
+ sessionId: IdSchema,
516
+ limit: z7.number().int().min(1).max(200).optional(),
517
+ before: z7.string().optional()
794
518
  }, "client_to_proxy"),
795
519
  // 客户端请求会话资源(命令列表 + 文件树),client -> proxy
796
- control("session_resources_request", { ...RequestIdShape, sessionId: z6.string() }, "client_to_proxy"),
520
+ control("session_resources_request", { ...RequestIdShape, sessionId: IdSchema }, "client_to_proxy"),
797
521
  control("session_resources_response", {
798
522
  ...RequestIdShape,
799
523
  ...RequestErrorShape,
800
- sessionId: z6.string(),
801
- commands: z6.array(CommandEntrySchema),
802
- groups: z6.array(FileTreeGroupSchema)
524
+ sessionId: IdSchema,
525
+ commands: z7.array(CommandEntrySchema),
526
+ groups: z7.array(FileTreeGroupSchema)
803
527
  }, "proxy_to_client"),
804
528
  // 客户端请求当前 provider 语义状态;不经 relay 缓存,由 proxy 返回当前值
805
- control("agent_status_request", { ...RequestIdShape, sessionId: z6.string().optional() }, "client_to_proxy"),
529
+ control("agent_status_request", { ...RequestIdShape, sessionId: IdSchema.optional() }, "client_to_proxy"),
806
530
  control("agent_status_response", {
807
531
  ...RequestIdShape,
808
- statuses: z6.array(z6.object({ sessionId: z6.string(), payload: AgentStatusPayloadSchema }))
532
+ statuses: z7.array(z7.object({ sessionId: IdSchema, payload: AgentStatusPayloadSchema }))
809
533
  }, "proxy_to_client"),
810
534
  // 客户端确认已收到审批请求;proxy 只记录送达状态,不把它当成用户决策
811
- control("permission_request_delivered", { sessionId: z6.string(), requestId: z6.string() }, "client_to_proxy"),
812
- control("tool_approve", { sessionId: z6.string(), payload: ToolApprovePayloadSchema }, "client_to_proxy"),
813
- control("tool_deny", { sessionId: z6.string(), payload: ToolDenyPayloadSchema }, "client_to_proxy"),
535
+ control("permission_request_delivered", { sessionId: IdSchema, requestId: IdSchema }, "client_to_proxy"),
536
+ control("tool_approve", { sessionId: IdSchema, payload: ToolApprovePayloadSchema }, "client_to_proxy"),
537
+ control("tool_deny", { sessionId: IdSchema, payload: ToolDenyPayloadSchema }, "client_to_proxy"),
814
538
  // proxy 确认用户决策已进入 provider/worker 路径;web 用它更新审批卡片状态
815
539
  control("permission_decision_result", {
816
- sessionId: z6.string(),
817
- requestId: z6.string(),
818
- outcome: z6.enum(["allow", "deny"]),
819
- delivered: z6.boolean(),
820
- message: z6.string().optional()
540
+ sessionId: IdSchema,
541
+ requestId: IdSchema,
542
+ outcome: z7.enum(["allow", "deny"]),
543
+ delivered: z7.boolean(),
544
+ message: z7.string().optional()
821
545
  }, "proxy_to_client"),
822
546
  // proxy 推送当前 pending 的工具审批列表,client 据此恢复审批卡片
823
547
  control("pending_approvals_push", {
824
- sessionId: z6.string(),
825
- approvals: z6.array(z6.object({
826
- requestId: z6.string(),
827
- toolName: z6.string(),
828
- input: z6.record(z6.string(), z6.unknown())
548
+ sessionId: IdSchema,
549
+ approvals: z7.array(z7.object({
550
+ requestId: IdSchema,
551
+ toolName: z7.string(),
552
+ input: z7.record(z7.string(), z7.unknown())
829
553
  }))
830
554
  }, "proxy_to_client"),
831
555
  // 恢复会话时推送历史消息,proxy -> client
832
556
  control("session_history_messages", {
833
557
  ...RequestIdShape,
834
- sessionId: z6.string(),
835
- before: z6.string().optional(),
836
- messages: z6.array(SessionHistoryMessageSchema),
837
- hasMore: z6.boolean().optional(),
838
- nextBefore: z6.string().optional()
558
+ sessionId: IdSchema,
559
+ before: z7.string().optional(),
560
+ messages: z7.array(SessionHistoryMessageSchema),
561
+ hasMore: z7.boolean().optional(),
562
+ nextBefore: z7.string().optional()
839
563
  }, "proxy_to_client"),
840
- // proxy 重连后同步活跃 session 列表给 relay
564
+ // proxy 重连后同步活跃 session 列表给 relay。session_sync 由 relay 自消费(更新 proxy-session
565
+ // 关联)不转发给 client,因此**没有** direction 标注——RelayControlDirection 只描述转发流。
841
566
  control("session_sync", {
842
- sessions: z6.array(z6.object({
843
- id: z6.string(),
844
- mode: z6.enum(["pty", "json"]),
845
- provider: z6.enum(["claude", "codex"]),
846
- ptyOwner: z6.enum(["local-terminal", "proxy-hosted"]).optional(),
847
- state: z6.string()
567
+ sessions: z7.array(z7.object({
568
+ id: z7.string(),
569
+ mode: z7.enum(sessionModeValues),
570
+ provider: z7.enum(providerValues),
571
+ ptyOwner: z7.enum(ptyOwnerValues).optional(),
572
+ state: z7.enum(sessionStateValues)
848
573
  }))
849
574
  }),
850
575
  // PTY 会话订阅,client -> proxy,触发 terminal serialize() 返回当前状态
851
- control("session_subscribe", { sessionId: z6.string(), requestId: z6.string().optional() }, "client_to_proxy"),
576
+ control("session_subscribe", { sessionId: IdSchema, requestId: IdSchema.optional() }, "client_to_proxy"),
852
577
  // PTY 会话快照,proxy -> client,serialize() 的全量终端状态
853
578
  control("session_snapshot", {
854
- sessionId: z6.string(),
855
- cols: z6.number().int().positive(),
856
- rows: z6.number().int().positive(),
857
- data: z6.string(),
858
- outputSeq: z6.number().int().nonnegative(),
859
- requestId: z6.string().optional()
579
+ sessionId: IdSchema,
580
+ cols: z7.number().int().positive(),
581
+ rows: z7.number().int().positive(),
582
+ data: z7.string(),
583
+ outputSeq: z7.number().int().nonnegative(),
584
+ requestId: IdSchema.optional()
860
585
  }, "proxy_to_client")
861
586
  ];
862
587
  var relayControlSchemas = relayControlDefinitions.map((definition) => definition.schema);
863
- var RelayControlSchema = z6.discriminatedUnion("type", relayControlSchemas);
588
+ var RelayControlSchema = z7.discriminatedUnion("type", relayControlSchemas);
864
589
  var ProxyToClientRelayControlTypes = new Set(relayControlDefinitions.filter((definition) => definition.directions.has("proxy_to_client")).map((definition) => definition.type));
865
590
  function isProxyToClientRelayControlType(type) {
866
591
  return ProxyToClientRelayControlTypes.has(type);
@@ -870,106 +595,348 @@ function isClientToProxyRelayControlType(type) {
870
595
  return ClientToProxyRelayControlTypes.has(type);
871
596
  }
872
597
 
873
- // ../../packages/shared/dist/logger.js
874
- import { lstatSync, mkdirSync, readdirSync, renameSync, statSync, symlinkSync, unlinkSync } from "fs";
875
- import { homedir } from "os";
876
- import { basename, join as join2 } from "path";
877
- import pino from "pino";
878
- var DEFAULT_LOG_DIR = `${homedir()}/.dev-anywhere/logs`;
879
- var DEFAULT_LOG_RETENTION = 50;
880
- var PROCESS_LOG_RUN_ID = sanitizeRunId(`${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${process.pid}`);
881
- function sanitizeRunId(runId) {
882
- return runId.replace(/[^a-zA-Z0-9._-]/g, "_");
598
+ // ../../packages/shared/dist/binary-frame.js
599
+ var SID_LEN_BYTES = 1;
600
+ var SEQ_BYTES = 4;
601
+ var HEADER_FIXED_BYTES = SID_LEN_BYTES + SEQ_BYTES;
602
+
603
+ // ../../packages/shared/dist/state-machine.js
604
+ function computeAbsorbingSet(transitions) {
605
+ const absorbing = /* @__PURE__ */ new Set();
606
+ const entries = Object.entries(transitions);
607
+ for (const [s, outs] of entries) {
608
+ if (outs.length === 0)
609
+ absorbing.add(s);
610
+ }
611
+ let changed = true;
612
+ while (changed) {
613
+ changed = false;
614
+ for (const [s, outs] of entries) {
615
+ if (absorbing.has(s))
616
+ continue;
617
+ if (outs.length > 0 && outs.every((t) => absorbing.has(t))) {
618
+ absorbing.add(s);
619
+ changed = true;
620
+ }
621
+ }
622
+ }
623
+ return absorbing;
883
624
  }
884
- function linkLatestLog(logDir, name, filePath, runId) {
885
- const latestPath = join2(logDir, `${name}.log`);
886
- try {
887
- const stat = lstatSync(latestPath);
888
- if (stat.isSymbolicLink()) {
889
- unlinkSync(latestPath);
890
- } else {
891
- renameSync(latestPath, join2(logDir, `${name}-legacy-${runId}.log`));
625
+ function defineFSM(transitions) {
626
+ const absorbing = computeAbsorbingSet(transitions);
627
+ return {
628
+ canTransition: (from, to) => transitions[from]?.includes(to) ?? false,
629
+ isAbsorbing: (state) => absorbing.has(state)
630
+ };
631
+ }
632
+
633
+ // src/registry.ts
634
+ var proxyConnectionFSM = defineFSM({
635
+ online: ["offline"],
636
+ offline: ["online"]
637
+ });
638
+ var RelayRegistry = class {
639
+ proxyStates = /* @__PURE__ */ new Map();
640
+ clientBindings = /* @__PURE__ */ new Map();
641
+ connectedClients = /* @__PURE__ */ new Set();
642
+ registerProxy(proxyId, ws, name) {
643
+ const existing = this.proxyStates.get(proxyId);
644
+ if (existing) {
645
+ if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
646
+ existing.ws.terminate();
647
+ }
648
+ existing.ws = ws;
649
+ existing.connectionState = "online";
650
+ existing.disconnectedAt = null;
651
+ if (name !== void 0) existing.name = name;
652
+ return "reconnected";
653
+ }
654
+ this.proxyStates.set(proxyId, {
655
+ ws,
656
+ connectionState: "online",
657
+ sessions: /* @__PURE__ */ new Set(),
658
+ disconnectedAt: null,
659
+ name
660
+ });
661
+ return "new";
662
+ }
663
+ // 显式状态转换,校验 from 状态匹配后更新 connectionState
664
+ transitionProxy(proxyId, from, to) {
665
+ if (!proxyConnectionFSM.canTransition(from, to)) {
666
+ throw new Error(`Invalid proxy transition: ${from} -> ${to}`);
667
+ }
668
+ const state = this.proxyStates.get(proxyId);
669
+ if (!state) {
670
+ throw new Error(`Proxy not found: ${proxyId}`);
671
+ }
672
+ if (state.connectionState !== from) {
673
+ throw new Error(
674
+ `Proxy ${proxyId} state mismatch: expected ${from}, actual ${state.connectionState}`
675
+ );
676
+ }
677
+ state.connectionState = to;
678
+ if (to === "offline") {
679
+ state.ws = null;
680
+ state.disconnectedAt = Date.now();
681
+ }
682
+ }
683
+ getProxyConnectionState(proxyId) {
684
+ return this.proxyStates.get(proxyId)?.connectionState;
685
+ }
686
+ getClientConnectionState(clientId) {
687
+ return this.clientBindings.get(clientId)?.connectionState;
688
+ }
689
+ // 彻底清理 proxy 状态和客户端绑定
690
+ cleanupProxy(proxyId) {
691
+ const state = this.proxyStates.get(proxyId);
692
+ if (!state) return;
693
+ for (const [clientId, binding] of this.clientBindings) {
694
+ if (binding.proxyId === proxyId) {
695
+ this.clientBindings.delete(clientId);
696
+ }
697
+ }
698
+ this.proxyStates.delete(proxyId);
699
+ }
700
+ unregisterProxy(proxyId) {
701
+ this.cleanupProxy(proxyId);
702
+ }
703
+ getProxy(proxyId) {
704
+ const state = this.proxyStates.get(proxyId);
705
+ return state?.ws ?? void 0;
706
+ }
707
+ isProxyOnline(proxyId) {
708
+ const state = this.proxyStates.get(proxyId);
709
+ if (!state || state.connectionState !== "online") return false;
710
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
711
+ return true;
712
+ }
713
+ // proxy 是否存在(含宽限期中的)
714
+ hasProxy(proxyId) {
715
+ return this.proxyStates.has(proxyId);
716
+ }
717
+ listProxies() {
718
+ return Array.from(this.proxyStates.keys());
719
+ }
720
+ // 返回 proxyId、name、online 的列表,用于 proxy_list_response
721
+ listProxiesWithName() {
722
+ return Array.from(this.proxyStates.entries()).map(([proxyId, state]) => ({
723
+ proxyId,
724
+ ...state.name !== void 0 ? { name: state.name } : {},
725
+ online: state.connectionState === "online"
726
+ }));
727
+ }
728
+ getProxyName(proxyId) {
729
+ return this.proxyStates.get(proxyId)?.name;
730
+ }
731
+ // 将 sessionId 关联到 proxy
732
+ addSessionToProxy(proxyId, sessionId) {
733
+ const state = this.proxyStates.get(proxyId);
734
+ if (state) {
735
+ state.sessions.add(sessionId);
892
736
  }
893
- } catch (err) {
894
- const code = err.code;
895
- if (code !== "ENOENT")
896
- return;
897
737
  }
898
- try {
899
- symlinkSync(basename(filePath), latestPath);
900
- } catch {
738
+ // 通过 sessionId 反查所属 proxyId
739
+ getProxyForSession(sessionId) {
740
+ for (const [proxyId, state] of this.proxyStates) {
741
+ if (state.sessions.has(sessionId)) {
742
+ return proxyId;
743
+ }
744
+ }
745
+ return void 0;
901
746
  }
902
- }
903
- function resolveRetention(retention) {
904
- if (retention === void 0)
905
- return DEFAULT_LOG_RETENTION;
906
- return Number.isFinite(retention) && retention >= 0 ? Math.floor(retention) : DEFAULT_LOG_RETENTION;
907
- }
908
- function pruneOldLogs(logDir, name, currentFilePath, retention) {
909
- const keep = resolveRetention(retention);
910
- if (keep === 0)
911
- return;
912
- const currentFileName = basename(currentFilePath);
913
- const prefix = `${name}-`;
914
- const candidates = readdirSync(logDir).filter((entry) => entry.startsWith(prefix) && entry.endsWith(".log") && entry !== currentFileName).map((entry) => {
915
- const path = join2(logDir, entry);
916
- try {
917
- return { path, mtimeMs: statSync(path).mtimeMs };
918
- } catch {
919
- return null;
747
+ // 获取 proxy 关联的所有 sessionId
748
+ getSessionsForProxy(proxyId) {
749
+ const state = this.proxyStates.get(proxyId);
750
+ return state ? Array.from(state.sessions) : [];
751
+ }
752
+ // clientId 绑定方式
753
+ bindClientById(clientId, proxyId, ws) {
754
+ if (!this.proxyStates.has(proxyId)) {
755
+ return false;
920
756
  }
921
- }).filter((entry) => entry !== null).sort((a, b) => b.mtimeMs - a.mtimeMs);
922
- for (const stale of candidates.slice(Math.max(0, keep - 1))) {
923
- try {
924
- unlinkSync(stale.path);
925
- } catch {
757
+ this.clientBindings.set(clientId, { proxyId, ws, connectionState: "bound" });
758
+ return true;
759
+ }
760
+ updateClientSocket(clientId, ws) {
761
+ const binding = this.clientBindings.get(clientId);
762
+ if (binding) {
763
+ binding.ws = ws;
926
764
  }
927
765
  }
928
- }
929
- function buildPinoLogger(options) {
930
- const { name, level = "info", logDir = DEFAULT_LOG_DIR, retention, stdout = false, silent = false } = options;
931
- if (silent) {
932
- return pino({ level: "silent" });
766
+ // 断开客户端 WebSocket 但保留绑定关系,重连时可恢复
767
+ unbindClientById(clientId) {
768
+ const binding = this.clientBindings.get(clientId);
769
+ if (binding) {
770
+ binding.ws = null;
771
+ }
772
+ }
773
+ getClientBinding(clientId) {
774
+ return this.clientBindings.get(clientId);
775
+ }
776
+ // 获取绑定到指定 proxy 的所有活跃客户端 WebSocket
777
+ getClientsForProxy(proxyId) {
778
+ const clients = [];
779
+ for (const [, binding] of this.clientBindings) {
780
+ if (binding.proxyId === proxyId && binding.ws && binding.ws.readyState === WebSocket.OPEN) {
781
+ clients.push(binding.ws);
782
+ }
783
+ }
784
+ return clients;
785
+ }
786
+ countClients() {
787
+ let count = 0;
788
+ for (const [, binding] of this.clientBindings) {
789
+ if (binding.ws && binding.ws.readyState === WebSocket.OPEN) count++;
790
+ }
791
+ return count;
792
+ }
793
+ addClientWs(ws) {
794
+ this.connectedClients.add(ws);
795
+ }
796
+ removeClientWs(ws) {
797
+ this.connectedClients.delete(ws);
798
+ }
799
+ getAllClientWs() {
800
+ const clients = [];
801
+ for (const ws of this.connectedClients) {
802
+ if (ws.readyState === WebSocket.OPEN) {
803
+ clients.push(ws);
804
+ }
805
+ }
806
+ return clients;
807
+ }
808
+ // 获取单个 proxy 的详细状态信息
809
+ getProxyDetail(proxyId) {
810
+ const state = this.proxyStates.get(proxyId);
811
+ if (!state) return void 0;
812
+ return {
813
+ proxyId,
814
+ ...state.name !== void 0 ? { name: state.name } : {},
815
+ online: state.connectionState === "online",
816
+ connectionState: state.connectionState,
817
+ sessions: Array.from(state.sessions),
818
+ disconnectedAt: state.disconnectedAt
819
+ };
933
820
  }
934
- mkdirSync(logDir, { recursive: true });
935
- const runId = PROCESS_LOG_RUN_ID;
936
- const filePath = join2(logDir, `${name}-${runId}.log`);
937
- linkLatestLog(logDir, name, filePath, runId);
938
- pruneOldLogs(logDir, name, filePath, retention);
939
- const streams = [{ stream: pino.destination(filePath) }];
940
- if (stdout) {
941
- streams.unshift({ stream: process.stdout });
821
+ // 获取所有客户端绑定的详细信息
822
+ getClientDetails() {
823
+ const details = [];
824
+ for (const [clientId, binding] of this.clientBindings) {
825
+ details.push({
826
+ clientId,
827
+ proxyId: binding.proxyId,
828
+ online: binding.ws !== null && binding.ws !== void 0 && binding.ws.readyState === WebSocket.OPEN,
829
+ connectionState: binding.connectionState
830
+ });
831
+ }
832
+ return details;
942
833
  }
943
- return pino({ level }, pino.multistream(streams));
834
+ };
835
+
836
+ // src/health.ts
837
+ import { Router } from "express";
838
+
839
+ // src/version.ts
840
+ import { readFileSync } from "fs";
841
+ import { dirname, join } from "path";
842
+ import { fileURLToPath } from "url";
843
+ function readRelayVersion() {
844
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
845
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
846
+ return pkg.version ?? "unknown";
944
847
  }
945
- function createLogger(options) {
946
- let real = null;
947
- const ensure = () => {
948
- if (!real)
949
- real = buildPinoLogger(options);
950
- return real;
951
- };
952
- return new Proxy(/* @__PURE__ */ Object.create(null), {
953
- get(_target, prop) {
954
- const target = ensure();
955
- const value = Reflect.get(target, prop, target);
956
- return typeof value === "function" ? value.bind(target) : value;
957
- },
958
- set(_target, prop, value) {
959
- return Reflect.set(ensure(), prop, value);
960
- },
961
- has(_target, prop) {
962
- return Reflect.has(ensure(), prop);
963
- },
964
- ownKeys() {
965
- return Reflect.ownKeys(ensure());
966
- },
967
- getOwnPropertyDescriptor(_target, prop) {
968
- return Reflect.getOwnPropertyDescriptor(ensure(), prop);
848
+ var RELAY_VERSION = readRelayVersion();
849
+
850
+ // src/health.ts
851
+ function bearerToken(authHeader) {
852
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader ?? "");
853
+ return match?.[1] ?? null;
854
+ }
855
+ function healthRouter(registry, options = {}) {
856
+ const router = Router();
857
+ const proxyTokenRequired = options.proxyTokenRequired ?? false;
858
+ const clientTokenRequired = options.clientTokenRequired ?? false;
859
+ router.get("/health", (_req, res) => {
860
+ res.json({
861
+ status: "ok",
862
+ version: RELAY_VERSION,
863
+ uptime: process.uptime(),
864
+ auth: {
865
+ proxyTokenRequired,
866
+ clientTokenRequired
867
+ }
868
+ });
869
+ });
870
+ router.get("/api/auth/client", (req, res) => {
871
+ if (!clientTokenRequired) {
872
+ res.status(204).end();
873
+ return;
874
+ }
875
+ const token = bearerToken(req.get("authorization"));
876
+ if (options.validateClientToken?.(token)) {
877
+ res.status(204).end();
878
+ return;
879
+ }
880
+ res.status(401).json({ error: "invalid_client_token" });
881
+ });
882
+ router.get("/api/admin/client-token", (req, res) => {
883
+ if (!proxyTokenRequired) {
884
+ res.status(401).json({ error: "proxy_token_required" });
885
+ return;
886
+ }
887
+ const token = bearerToken(req.get("authorization"));
888
+ if (!options.validateProxyToken?.(token)) {
889
+ res.status(401).json({ error: "invalid_proxy_token" });
890
+ return;
891
+ }
892
+ const clientToken = options.getClientToken?.() ?? null;
893
+ if (!clientToken) {
894
+ res.status(204).end();
895
+ return;
969
896
  }
897
+ res.json({ clientToken });
898
+ });
899
+ function requireProxyTokenIfConfigured(req, res) {
900
+ if (!proxyTokenRequired) return true;
901
+ const token = bearerToken(req.get("authorization"));
902
+ if (options.validateProxyToken?.(token)) return true;
903
+ res.status(401).json({ error: "invalid_proxy_token" });
904
+ return false;
905
+ }
906
+ router.get("/status", (_req, res) => {
907
+ res.json({
908
+ version: RELAY_VERSION,
909
+ proxyCount: registry.listProxies().length,
910
+ clientCount: registry.countClients(),
911
+ uptime: process.uptime()
912
+ });
913
+ });
914
+ router.get("/api/status", (req, res) => {
915
+ if (!requireProxyTokenIfConfigured(req, res)) return;
916
+ res.json({
917
+ version: RELAY_VERSION,
918
+ proxyCount: registry.listProxies().length,
919
+ clientCount: registry.countClients(),
920
+ uptime: process.uptime(),
921
+ bindings: registry.getClientDetails()
922
+ });
923
+ });
924
+ router.get("/api/proxies", (req, res) => {
925
+ if (!requireProxyTokenIfConfigured(req, res)) return;
926
+ const proxyIds = registry.listProxies();
927
+ const details = proxyIds.map((id) => registry.getProxyDetail(id)).filter((d) => d !== void 0);
928
+ res.json(details);
929
+ });
930
+ router.get("/api/clients", (req, res) => {
931
+ if (!requireProxyTokenIfConfigured(req, res)) return;
932
+ res.json(registry.getClientDetails());
970
933
  });
934
+ return router;
971
935
  }
972
936
 
937
+ // src/handlers/proxy.ts
938
+ import { WebSocket as WebSocket3 } from "ws";
939
+
973
940
  // src/router.ts
974
941
  import { WebSocket as WebSocket2 } from "ws";
975
942
  function parseMessage(data) {
@@ -1000,8 +967,9 @@ function routeProxyMessage(raw, proxyId, registry, logger, chaos) {
1000
967
  return;
1001
968
  }
1002
969
  const { message } = result;
1003
- const { sessionId } = message;
1004
- registry.addSessionToProxy(proxyId, sessionId);
970
+ if ("sessionId" in message) {
971
+ registry.addSessionToProxy(proxyId, message.sessionId);
972
+ }
1005
973
  const clients = registry.getClientsForProxy(proxyId);
1006
974
  for (const clientWs of clients) {
1007
975
  if (clientWs.readyState === WebSocket2.OPEN) {
@@ -1037,6 +1005,7 @@ function routeClientMessage(raw, proxyId, clientWs, registry, logger, chaos) {
1037
1005
 
1038
1006
  // src/handlers/proxy.ts
1039
1007
  var MAX_BINARY_FRAME_SIZE = 10 * 1024 * 1024;
1008
+ var MAX_JSON_MESSAGE_SIZE = 1 * 1024 * 1024;
1040
1009
  function notifyClientsProxyOffline(proxyId, registry, logger, chaos) {
1041
1010
  const clients = registry.getClientsForProxy(proxyId);
1042
1011
  const msg = JSON.stringify({ type: "proxy_offline", proxyId });
@@ -1067,6 +1036,15 @@ function broadcastProxyList(registry, chaos) {
1067
1036
  else clientWs.send(msg);
1068
1037
  }
1069
1038
  }
1039
+ function rejectNotRegistered(ws) {
1040
+ ws.send(
1041
+ JSON.stringify({
1042
+ type: "relay_error",
1043
+ code: RelayErrorCode.NOT_REGISTERED,
1044
+ message: "Proxy must register before sending messages"
1045
+ })
1046
+ );
1047
+ }
1070
1048
  function handleProxyConnection(ws, registry, logger, chaos) {
1071
1049
  const proxyWs = ws;
1072
1050
  proxyWs.isAlive = true;
@@ -1099,6 +1077,13 @@ function handleProxyConnection(ws, registry, logger, chaos) {
1099
1077
  }
1100
1078
  return;
1101
1079
  }
1080
+ if (data.length > MAX_JSON_MESSAGE_SIZE) {
1081
+ logger.warn(
1082
+ { size: data.length, proxyId: proxyWs.proxyId },
1083
+ "JSON message rejected: exceeds max size"
1084
+ );
1085
+ return;
1086
+ }
1102
1087
  const raw = data.toString();
1103
1088
  const result = parseMessage(raw);
1104
1089
  if (result.kind === "control" && result.message.type === "proxy_register") {
@@ -1107,7 +1092,7 @@ function handleProxyConnection(ws, registry, logger, chaos) {
1107
1092
  proxyWs.proxyId = proxyId;
1108
1093
  logger.info({ proxyId, status }, "Proxy registered");
1109
1094
  proxyWs.send(
1110
- JSON.stringify({
1095
+ serializeControl({
1111
1096
  type: "proxy_register_response",
1112
1097
  status
1113
1098
  })
@@ -1133,25 +1118,17 @@ function handleProxyConnection(ws, registry, logger, chaos) {
1133
1118
  }
1134
1119
  if (result.kind === "control" && result.message.type === "session_sync") {
1135
1120
  if (!proxyWs.proxyId) return;
1136
- const sessions = result.message.sessions;
1137
- if (Array.isArray(sessions)) {
1138
- for (const s of sessions) {
1139
- registry.addSessionToProxy(proxyWs.proxyId, s.id);
1140
- }
1141
- logger.info({ proxyId: proxyWs.proxyId, count: sessions.length }, "Session sync received");
1121
+ const { sessions } = result.message;
1122
+ for (const s of sessions) {
1123
+ registry.addSessionToProxy(proxyWs.proxyId, s.id);
1142
1124
  }
1125
+ logger.info({ proxyId: proxyWs.proxyId, count: sessions.length }, "Session sync received");
1143
1126
  return;
1144
1127
  }
1145
1128
  if (result.kind === "control") {
1146
1129
  if (isProxyToClientRelayControlType(result.message.type)) {
1147
1130
  if (!proxyWs.proxyId) {
1148
- proxyWs.send(
1149
- JSON.stringify({
1150
- type: "relay_error",
1151
- code: RelayErrorCode.NOT_REGISTERED,
1152
- message: "Proxy must register before sending messages"
1153
- })
1154
- );
1131
+ rejectNotRegistered(proxyWs);
1155
1132
  return;
1156
1133
  }
1157
1134
  const clients = registry.getClientsForProxy(proxyWs.proxyId);
@@ -1178,13 +1155,7 @@ function handleProxyConnection(ws, registry, logger, chaos) {
1178
1155
  }
1179
1156
  if (result.kind === "envelope") {
1180
1157
  if (!proxyWs.proxyId) {
1181
- proxyWs.send(
1182
- JSON.stringify({
1183
- type: "relay_error",
1184
- code: RelayErrorCode.NOT_REGISTERED,
1185
- message: "Proxy must register before sending messages"
1186
- })
1187
- );
1158
+ rejectNotRegistered(proxyWs);
1188
1159
  return;
1189
1160
  }
1190
1161
  routeProxyMessage(raw, proxyWs.proxyId, registry, logger, chaos);
@@ -1202,19 +1173,31 @@ function handleProxyConnection(ws, registry, logger, chaos) {
1202
1173
  return;
1203
1174
  }
1204
1175
  });
1205
- proxyWs.on("close", () => {
1206
- if (proxyWs.proxyId) {
1207
- notifyClientsProxyOffline(proxyWs.proxyId, registry, logger, chaos);
1208
- try {
1209
- registry.transitionProxy(proxyWs.proxyId, "online", "offline");
1210
- } catch {
1211
- }
1176
+ proxyWs.on("close", (code, reason) => {
1177
+ if (!proxyWs.proxyId) return;
1178
+ const closeMeta = { code, reason: reason.toString() || void 0 };
1179
+ const current = registry.getProxy(proxyWs.proxyId);
1180
+ if (current && current !== proxyWs) {
1212
1181
  logger.info(
1213
- { proxyId: proxyWs.proxyId },
1214
- "Proxy disconnected, state preserved for reconnect"
1182
+ { proxyId: proxyWs.proxyId, ...closeMeta },
1183
+ "Old proxy ws closed after being superseded by reconnect, skipping offline transition"
1215
1184
  );
1216
- broadcastProxyList(registry, chaos);
1185
+ return;
1217
1186
  }
1187
+ notifyClientsProxyOffline(proxyWs.proxyId, registry, logger, chaos);
1188
+ try {
1189
+ registry.transitionProxy(proxyWs.proxyId, "online", "offline");
1190
+ } catch (err) {
1191
+ logger.debug(
1192
+ { proxyId: proxyWs.proxyId, err: String(err) },
1193
+ "transitionProxy on close skipped"
1194
+ );
1195
+ }
1196
+ logger.info(
1197
+ { proxyId: proxyWs.proxyId, ...closeMeta },
1198
+ "Proxy disconnected, state preserved for reconnect"
1199
+ );
1200
+ broadcastProxyList(registry, chaos);
1218
1201
  });
1219
1202
  proxyWs.on("error", (err) => {
1220
1203
  logger.error({ err, proxyId: proxyWs.proxyId }, "Proxy WebSocket error");
@@ -1224,6 +1207,7 @@ function handleProxyConnection(ws, registry, logger, chaos) {
1224
1207
  // src/handlers/client.ts
1225
1208
  import { WebSocket as WebSocket4 } from "ws";
1226
1209
  import { nanoid } from "nanoid";
1210
+ var MAX_JSON_MESSAGE_SIZE2 = 1 * 1024 * 1024;
1227
1211
  function handleClientRegister(clientId, clientWs, registry, logger) {
1228
1212
  clientWs.clientId = clientId;
1229
1213
  const binding = registry.getClientBinding(clientId);
@@ -1260,6 +1244,26 @@ function handleClientRegister(clientId, clientWs, registry, logger) {
1260
1244
  );
1261
1245
  logger.info({ clientId, proxyId, status: "restored" }, "Client registered");
1262
1246
  }
1247
+ function rejectNotBound(ws) {
1248
+ ws.send(
1249
+ JSON.stringify({
1250
+ type: "relay_error",
1251
+ code: RelayErrorCode.NOT_BOUND,
1252
+ message: "Client is not bound to any proxy"
1253
+ })
1254
+ );
1255
+ }
1256
+ function rejectProxySelect(ws, requestId, proxyId) {
1257
+ ws.send(
1258
+ JSON.stringify({
1259
+ type: "proxy_select_response",
1260
+ requestId,
1261
+ success: false,
1262
+ errorCode: ControlErrorCode.PROXY_OFFLINE,
1263
+ error: `Proxy not online: ${proxyId}`
1264
+ })
1265
+ );
1266
+ }
1263
1267
  function handleClientConnection(ws, registry, logger, chaos) {
1264
1268
  const clientWs = ws;
1265
1269
  clientWs.isAlive = true;
@@ -1271,6 +1275,13 @@ function handleClientConnection(ws, registry, logger, chaos) {
1271
1275
  if (isBinary) {
1272
1276
  return;
1273
1277
  }
1278
+ if (data.length > MAX_JSON_MESSAGE_SIZE2) {
1279
+ logger.warn(
1280
+ { size: data.length, clientId: clientWs.clientId },
1281
+ "JSON message rejected: exceeds max size"
1282
+ );
1283
+ return;
1284
+ }
1274
1285
  const raw = data.toString();
1275
1286
  const result = parseMessage(raw);
1276
1287
  if (result.kind === "control") {
@@ -1304,15 +1315,9 @@ function handleClientConnection(ws, registry, logger, chaos) {
1304
1315
  return;
1305
1316
  }
1306
1317
  if (isClientToProxyRelayControlType(msg.type)) {
1307
- const targetProxyId = ("proxyId" in msg ? msg.proxyId : void 0) || clientWs.boundProxyId;
1318
+ const targetProxyId = clientWs.boundProxyId;
1308
1319
  if (!targetProxyId) {
1309
- clientWs.send(
1310
- JSON.stringify({
1311
- type: "relay_error",
1312
- code: RelayErrorCode.NOT_BOUND,
1313
- message: "Client is not bound to any proxy"
1314
- })
1315
- );
1320
+ rejectNotBound(clientWs);
1316
1321
  return;
1317
1322
  }
1318
1323
  const proxyWs = registry.getProxy(targetProxyId);
@@ -1332,15 +1337,7 @@ function handleClientConnection(ws, registry, logger, chaos) {
1332
1337
  }
1333
1338
  if (msg.type === "proxy_select") {
1334
1339
  if (!registry.isProxyOnline(msg.proxyId)) {
1335
- clientWs.send(
1336
- JSON.stringify({
1337
- type: "proxy_select_response",
1338
- requestId: msg.requestId,
1339
- success: false,
1340
- errorCode: ControlErrorCode.PROXY_OFFLINE,
1341
- error: `Proxy not online: ${msg.proxyId}`
1342
- })
1343
- );
1340
+ rejectProxySelect(clientWs, msg.requestId, msg.proxyId);
1344
1341
  return;
1345
1342
  }
1346
1343
  if (!clientWs.clientId) {
@@ -1348,15 +1345,7 @@ function handleClientConnection(ws, registry, logger, chaos) {
1348
1345
  }
1349
1346
  const bound = registry.bindClientById(clientWs.clientId, msg.proxyId, clientWs);
1350
1347
  if (!bound) {
1351
- clientWs.send(
1352
- JSON.stringify({
1353
- type: "proxy_select_response",
1354
- requestId: msg.requestId,
1355
- success: false,
1356
- errorCode: ControlErrorCode.PROXY_OFFLINE,
1357
- error: `Proxy not online: ${msg.proxyId}`
1358
- })
1359
- );
1348
+ rejectProxySelect(clientWs, msg.requestId, msg.proxyId);
1360
1349
  return;
1361
1350
  }
1362
1351
  clientWs.boundProxyId = msg.proxyId;
@@ -1388,13 +1377,7 @@ function handleClientConnection(ws, registry, logger, chaos) {
1388
1377
  }
1389
1378
  if (result.kind === "envelope") {
1390
1379
  if (!clientWs.boundProxyId) {
1391
- clientWs.send(
1392
- JSON.stringify({
1393
- type: "relay_error",
1394
- code: RelayErrorCode.NOT_BOUND,
1395
- message: "Client is not bound to any proxy"
1396
- })
1397
- );
1380
+ rejectNotBound(clientWs);
1398
1381
  return;
1399
1382
  }
1400
1383
  routeClientMessage(raw, clientWs.boundProxyId, clientWs, registry, logger, chaos);
@@ -1411,6 +1394,9 @@ function handleClientConnection(ws, registry, logger, chaos) {
1411
1394
  });
1412
1395
  clientWs.on("close", () => {
1413
1396
  registry.removeClientWs(clientWs);
1397
+ if (clientWs.clientId) {
1398
+ registry.unbindClientById(clientWs.clientId);
1399
+ }
1414
1400
  logger.info({ clientId: clientWs.clientId }, "Client disconnected");
1415
1401
  });
1416
1402
  clientWs.on("error", (err) => {
@@ -1488,6 +1474,11 @@ function createRelayServer(options) {
1488
1474
  const { heartbeatInterval = 3e4, logger, dataDir, proxyToken, clientToken, chaos } = options;
1489
1475
  const proxyTokenRequired = typeof proxyToken === "string" && proxyToken.length > 0;
1490
1476
  const clientTokenRequired = typeof clientToken === "string" && clientToken.length > 0;
1477
+ const allowedOriginsSet = options.allowedOrigins && options.allowedOrigins.length > 0 ? new Set(options.allowedOrigins) : null;
1478
+ const checkOrigin = (origin) => {
1479
+ if (!allowedOriginsSet) return true;
1480
+ return typeof origin === "string" && allowedOriginsSet.has(origin);
1481
+ };
1491
1482
  if (!proxyTokenRequired) {
1492
1483
  logger.warn(
1493
1484
  "proxy auth token not set, /proxy endpoint is open \u2014 ok for dev, not for public relay"
@@ -1512,7 +1503,7 @@ function createRelayServer(options) {
1512
1503
  );
1513
1504
  }
1514
1505
  const app = express();
1515
- const fontsDir = dataDir ? `${dataDir}/fonts` : `${homedir2()}/.dev-anywhere/relay-data/fonts`;
1506
+ const fontsDir = dataDir ? `${dataDir}/fonts` : `${homedir()}/.dev-anywhere/relay-data/fonts`;
1516
1507
  const fontAssetDir = options.fontAssetDir ?? PACKAGED_FONTS_DIR;
1517
1508
  app.use(
1518
1509
  "/fonts",
@@ -1549,6 +1540,16 @@ function createRelayServer(options) {
1549
1540
  httpServer.on("upgrade", (request, socket, head) => {
1550
1541
  const url = new URL(request.url ?? "/", "http://localhost");
1551
1542
  const { pathname } = url;
1543
+ const origin = request.headers.origin;
1544
+ if (!checkOrigin(origin)) {
1545
+ logger.warn(
1546
+ { ip: request.socket.remoteAddress, origin: origin ?? "(missing)", pathname },
1547
+ "rejected upgrade: origin not in allowedOrigins"
1548
+ );
1549
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1550
+ socket.destroy();
1551
+ return;
1552
+ }
1552
1553
  if (pathname === "/proxy") {
1553
1554
  if (proxyTokenRequired) {
1554
1555
  const token = url.searchParams.get("token");
@@ -1627,9 +1628,8 @@ function createRelayServer(options) {
1627
1628
  }
1628
1629
 
1629
1630
  export {
1630
- createLogger,
1631
1631
  RELAY_VERSION,
1632
1632
  parseRelayChaosFromEnv,
1633
1633
  createRelayServer
1634
1634
  };
1635
- //# sourceMappingURL=chunk-ZFCNDSFL.js.map
1635
+ //# sourceMappingURL=chunk-SM2GRUCV.js.map