@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.11

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 (80) hide show
  1. package/README.md +53 -64
  2. package/dist/bin/codex-remote.mjs +90 -71
  3. package/dist/bin/self-relay-admin.mjs +8 -0
  4. package/dist/bin/self-relay-demo.mjs +1 -1
  5. package/dist/bin/self-relay-server.mjs +5 -2
  6. package/dist/shared/app-server-events.cjs +64 -24
  7. package/dist/shared/file-mentions.cjs +35 -0
  8. package/dist/shared/p2p-data-channel.cjs +50 -0
  9. package/dist/skills/code-relax/SKILL.md +1 -1
  10. package/dist/src/desktop-launcher.mjs +16 -21
  11. package/dist/src/file-upload-session.mjs +124 -0
  12. package/dist/src/lan-socket-server.mjs +59 -10
  13. package/dist/src/managed-app-server.mjs +36 -14
  14. package/dist/src/message-images.mjs +0 -18
  15. package/dist/src/onboarding.mjs +6 -8
  16. package/dist/src/platform/README.md +1 -1
  17. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  18. package/dist/src/platform/windows/background-process.mjs +7 -2
  19. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  20. package/dist/src/platform/windows/desktop-host.mjs +17 -3
  21. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  22. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
  23. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  24. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  25. package/dist/src/platform/windows/service-host.mjs +4 -16
  26. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  27. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  28. package/dist/src/runtime-discovery.mjs +43 -0
  29. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  30. package/dist/src/self-relay/admin-page.mjs +71 -0
  31. package/dist/src/self-relay/admin-server.mjs +61 -0
  32. package/dist/src/self-relay/admin-state.mjs +122 -0
  33. package/dist/src/self-relay/client.mjs +2 -0
  34. package/dist/src/self-relay/connector.mjs +107 -20
  35. package/dist/src/self-relay/demo.mjs +4 -5
  36. package/dist/src/self-relay/lifecycle.mjs +58 -70
  37. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  38. package/dist/src/self-relay/server.mjs +224 -29
  39. package/dist/src/server.mjs +616 -1299
  40. package/dist/src/service-doctor.mjs +14 -26
  41. package/dist/src/service-install.mjs +1 -4
  42. package/dist/src/service-lifecycle.mjs +12 -12
  43. package/dist/src/shared-app-server.mjs +206 -46
  44. package/dist/src/shared-catalog.mjs +5 -25
  45. package/dist/src/shared-recovery.mjs +11 -9
  46. package/dist/src/shared-thread-stream.mjs +1 -1
  47. package/dist/src/skill-install.mjs +0 -13
  48. package/package.json +16 -18
  49. package/tools/postinstall.mjs +140 -3
  50. package/dist/src/app-server-client.mjs +0 -468
  51. package/dist/src/app-server-tasks.mjs +0 -355
  52. package/dist/src/debug-ui.mjs +0 -131
  53. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  54. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  55. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  56. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  57. package/dist/src/thread-catalog.mjs +0 -42
  58. package/dist/tools/find-desktop-pipe.mjs +0 -10
  59. package/dist/web/approval-ui.js +0 -188
  60. package/dist/web/capabilities.js +0 -57
  61. package/dist/web/chat-transport.js +0 -196
  62. package/dist/web/chat.css +0 -736
  63. package/dist/web/chat.js +0 -5149
  64. package/dist/web/client-platform.js +0 -11
  65. package/dist/web/harmony-platform.js +0 -51
  66. package/dist/web/highlight-powershell.min.js +0 -39
  67. package/dist/web/highlight.min.js +0 -1262
  68. package/dist/web/history-cache.js +0 -146
  69. package/dist/web/index.html +0 -222
  70. package/dist/web/lan-session.js +0 -122
  71. package/dist/web/p2p-diagnostics.js +0 -28
  72. package/dist/web/p2p-probe.js +0 -83
  73. package/dist/web/resources.json +0 -1
  74. package/dist/web/self-relay-session.js +0 -119
  75. package/dist/web/server-requests.js +0 -67
  76. package/dist/web/timeline-sync.js +0 -67
  77. package/dist/web/vendor/README.md +0 -14
  78. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  79. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  80. package/dist/web/vendor/highlight.min.js +0 -1262
@@ -1,17 +1,20 @@
1
1
  import http from 'node:http';
2
2
  import { deviceStore, validId } from './device-store.mjs';
3
- import { randomUUID } from 'node:crypto';
3
+ import { randomUUID } from 'node:crypto';
4
4
  import { WebSocketServer } from 'ws';
5
5
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
6
6
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
7
7
  import { validProbeSignal } from './p2p-probe.mjs';
8
-
8
+ import { relayAdminState } from './admin-state.mjs';
9
+ import { adminPage } from './admin-page.mjs';
10
+
9
11
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
10
12
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
11
13
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
12
- publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
14
+ maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
13
15
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
14
16
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
17
+ if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
15
18
  if (publicRelayUrl) relayPublicAddress(publicRelayUrl);
16
19
  // Harmony's native WebSocket supplies the endpoint Origin automatically.
17
20
  // libwebsockets can use http even for WSS; trust only the configured endpoint.
@@ -20,7 +23,9 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
20
23
  const hostHash = digest(hostKey), rooms = new Map();
21
24
  const store = deviceStore(stateFile);
22
25
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
23
- for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
26
+ const adminState = relayAdminState(adminStateFile, maxRelaySessions);
27
+ let activeRelaySessions = 0;
28
+ for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
24
29
  function commit(room, changes) {
25
30
  const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
26
31
  if (room.durable) {
@@ -46,6 +51,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
46
51
  }
47
52
  const server = http.createServer((request, response) => {
48
53
  const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
54
+ if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
55
+ void handleAdmin(request, response, target); return;
56
+ }
57
+ if (request.method === 'GET' && target.pathname === '/announcement') {
58
+ const body = JSON.stringify({ announcement: adminState.read().announcement });
59
+ response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
60
+ }
49
61
  if (request.method !== 'GET' || !['/pair', '/pair/resolve'].includes(target.pathname)) {
50
62
  response.writeHead(404, { 'Cache-Control': 'no-store' }); response.end(); return;
51
63
  }
@@ -57,7 +69,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
57
69
  response.writeHead(410, pairingHeaders(json ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8', body)); response.end(body); return;
58
70
  }
59
71
  if (target.pathname === '/pair/resolve' || /application\/json/i.test(request.headers.accept || '')) {
60
- const body = JSON.stringify({ version: 3, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
72
+ const body = JSON.stringify({ version: 6, relayUrl: relayUrl(request), room: room.id, expires: room.expires });
61
73
  response.writeHead(200, pairingHeaders('application/json; charset=utf-8', body)); response.end(body); return;
62
74
  }
63
75
  const secure = request.socket.encrypted || request.headers['x-forwarded-proto'] === 'https';
@@ -65,6 +77,84 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
65
77
  const body = pairingPage({ deepLink: `codex-remote://pair?url=${encodeURIComponent(current.href)}`, expires: room.expires, appDownloadUrl });
66
78
  response.writeHead(200, pairingHeaders('text/html; charset=utf-8', body)); response.end(body);
67
79
  });
80
+ function adminReply(response, status, body, type = 'application/json; charset=utf-8') {
81
+ const value = typeof body === 'string' ? body : JSON.stringify(body);
82
+ response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store',
83
+ 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
84
+ response.end(value);
85
+ }
86
+ async function handleAdmin(request, response, target) {
87
+ if (request.method === 'GET' && target.pathname === '/admin') {
88
+ adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
89
+ }
90
+ if (request.method === 'GET' && target.pathname === '/admin/api/status') {
91
+ const state = adminState.read();
92
+ adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
93
+ activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
94
+ websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
95
+ activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
96
+ processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
97
+ announcement: state.announcement, announcements: state.announcements }); return;
98
+ }
99
+ if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
100
+ request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
101
+ try {
102
+ const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
103
+ if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
104
+ const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
105
+ log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
106
+ } else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
107
+ const state = adminState.createAnnouncement(body.title, body.message, body.publish === true);
108
+ if (body.publish === true) {
109
+ broadcastAnnouncement(state.announcement);
110
+ log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
111
+ } else log({ event: 'admin-announcement-draft-created', announcementId: state.announcements[0].id });
112
+ } else if (target.pathname.startsWith('/admin/api/announcements/')) {
113
+ const parts = target.pathname.slice('/admin/api/announcements/'.length).split('/');
114
+ const id = parts[0];
115
+ if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
116
+ if (parts.length === 1 && request.method === 'PUT') {
117
+ adminState.updateDraft(id, body.title, body.message);
118
+ log({ event: 'admin-announcement-draft-updated', announcementId: id });
119
+ } else if (parts.length === 1 && request.method === 'DELETE') {
120
+ adminState.deleteAnnouncement(id);
121
+ log({ event: 'admin-announcement-deleted', announcementId: id });
122
+ } else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
123
+ const state = adminState.publishDraft(id, body.title, body.message);
124
+ broadcastAnnouncement(state.announcement);
125
+ log({ event: 'admin-announcement-published', announcementId: id });
126
+ } else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
127
+ const state = adminState.withdraw(id);
128
+ broadcastAnnouncement(state.announcement);
129
+ log({ event: 'admin-announcement-withdrawn', announcementId: id });
130
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
131
+ } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
132
+ adminReply(response, 200, { ok: true });
133
+ } catch (error) { adminReply(response, 400, { error: error.message }); }
134
+ }
135
+ function broadcastAnnouncement(announcement) {
136
+ for (const room of rooms.values()) {
137
+ for (const observer of room.observers.values()) send(observer.ws, { type: 'announcement', announcement });
138
+ for (const connection of room.connections.values()) send(connection.ws, { type: 'announcement', announcement });
139
+ }
140
+ }
141
+ function releaseRelay(connection) {
142
+ if (!connection?.relayAdmitted) return;
143
+ connection.relayAdmitted = false; activeRelaySessions = Math.max(0, activeRelaySessions - 1);
144
+ }
145
+ function admitRelay(connection, room) {
146
+ if (connection.relayAdmitted || connection.relayExempt) return true;
147
+ if (connection.capacityExempt) {
148
+ connection.relayExempt = true;
149
+ log({ event: 'relay-capacity-exempt', device: room.id }); return true;
150
+ }
151
+ const limit = adminState.read().maxRelaySessions;
152
+ if (activeRelaySessions >= limit) {
153
+ log({ event: 'relay-capacity-rejected', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return false;
154
+ }
155
+ connection.relayAdmitted = true; activeRelaySessions++;
156
+ log({ event: 'relay-capacity-admitted', device: room.id, activeRelaySessions, maxRelaySessions: limit }); return true;
157
+ }
68
158
  const wss = new WebSocketServer({ noServer: true, maxPayload, perMessageDeflate: {
69
159
  serverNoContextTakeover: true, clientNoContextTakeover: true,
70
160
  threshold: 1024, concurrencyLimit: 4, zlibDeflateOptions: { level: 3 },
@@ -81,32 +171,76 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
81
171
  }
82
172
  wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
83
173
  });
84
- wss.on('connection', ws => {
174
+ wss.on('connection', ws => {
85
175
  let role, room, session, clientId, alive = true;
86
176
  const authTimer = setTimeout(() => ws.close(1008, 'Authentication timeout'), 5000);
87
177
  ws.on('pong', () => { alive = true; });
88
178
  const heartbeat = setInterval(() => { if (!alive) ws.terminate(); else { alive = false; ws.ping(); } }, 30000);
89
- const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
179
+ const fail = (code = 4401, reason = 'Device authorization rejected') => ws.close(code, reason);
180
+ const queueClientMessage = (connection, message, pause = 0) => {
181
+ const bytes = Buffer.byteLength(JSON.stringify(message));
182
+ connection.queuedBytes = (connection.queuedBytes || 0) + bytes;
183
+ if (connection.queuedBytes > 40 * 1024 * 1024) {
184
+ connection.ws.close(1013, 'Receiver too slow'); return;
185
+ }
186
+ connection.outbound = (connection.outbound || Promise.resolve()).then(async () => {
187
+ if (pause > 0) await new Promise(resolve => setTimeout(resolve, pause));
188
+ connection.queuedBytes -= bytes;
189
+ send(connection.ws, message);
190
+ }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
191
+ };
90
192
  ws.on('error', () => {}); // close owns routing cleanup.
91
193
  ws.on('close', (code, reason) => {
92
194
  log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
93
195
  clearTimeout(authTimer); clearInterval(heartbeat);
94
196
  if (role === 'host' && room?.host === ws) {
95
197
  room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
198
+ room.uploadHost?.close(4410, 'Computer offline');
199
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
96
200
  for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
97
201
  if (!room.durable) rooms.delete(room.id);
98
202
  for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
99
203
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
204
+ releaseRelay(room.connections.get(clientId));
100
205
  room.connections.delete(clientId);
101
206
  if (room.host) send(room.host, { type: 'detach', session });
102
207
  } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
103
208
  room.observers.delete(clientId);
104
- }
209
+ } else if (role === 'file-host' && room?.uploadHost === ws) {
210
+ room.uploadHost = undefined;
211
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
212
+ } else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
213
+ room.uploadClients.delete(clientId);
214
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
215
+ }
105
216
  });
106
217
  ws.on('message', (bytes, binary) => {
107
218
  try {
108
- const message = decode(bytes, binary);
109
- if (!role) {
219
+ const message = decode(bytes, binary);
220
+ if (!role) {
221
+ if (message.type === 'file-host') {
222
+ room = rooms.get(message.deviceId);
223
+ if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
224
+ role = 'file-host'; clearTimeout(authTimer);
225
+ room.uploadClients ||= new Map();
226
+ room.uploadHost?.close(4409, 'File receiver replaced');
227
+ room.uploadHost = ws;
228
+ send(ws, { type: 'file-host-ready' }); return;
229
+ }
230
+ if (message.type === 'file-client') {
231
+ room = rooms.get(message.room);
232
+ const client = room?.clients.find(item => item.id === message.clientId);
233
+ if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
234
+ !matches(message.credential, client.hash)) return fail();
235
+ room.uploadClients ||= new Map();
236
+ if (room.uploadClients.has(message.clientId) ||
237
+ [...rooms.values()].reduce((sum, item) => sum + (item.uploadClients?.size || 0), 0) >= 2) {
238
+ return fail(4429, 'Too many file uploads');
239
+ }
240
+ role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
241
+ room.uploadClients.set(clientId, { ws, session });
242
+ send(room.uploadHost, { type: 'file-open', session }); return;
243
+ }
110
244
  if (message.type === 'host') {
111
245
  const durable = message.deviceId !== undefined;
112
246
  if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
@@ -117,20 +251,23 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
117
251
  hostName = normalizeHostName(message.hostName);
118
252
  }
119
253
  catch { return fail(1008, 'Invalid LAN candidates'); }
120
- room = durable ? rooms.get(message.deviceId) : undefined;
121
- if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
122
- if (!room) {
123
- if (!matches(message.key, hostHash)) return fail();
124
- if (rooms.size >= 256) return fail(4429, 'Device limit reached');
254
+ room = durable ? rooms.get(message.deviceId) : undefined;
255
+ if (room && (room.status !== 'active' || !matches(message.deviceSecret, room.hostHash))) return fail();
256
+ if (!room) {
257
+ // Durable computers prove future ownership with their generated device secret.
258
+ // The legacy deployment key remains accepted, but is no longer required for first registration.
259
+ if (!durable && !matches(message.key, hostHash)) return fail();
260
+ if (rooms.size >= 256) return fail(4429, 'Device limit reached');
125
261
  const now = new Date().toISOString();
126
262
  room = { id: durable ? message.deviceId : randomUUID(), durable,
127
- revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
263
+ revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
128
264
  ...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
129
265
  commit(room, {}); rooms.set(room.id, room);
130
266
  }
131
267
  const oldHost = room.host;
132
268
  room.observers ||= new Map();
133
269
  room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
270
+ if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
134
271
  room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
135
272
  role = 'host'; clearTimeout(authTimer);
136
273
  if (oldHost && oldHost !== ws) {
@@ -143,7 +280,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
143
280
  send(observer.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
144
281
  }
145
282
  const invite = room.clients.length ? {} : invitation(room);
146
- send(ws, { type: 'registered', protocolVersion: 3, room: room.id, paired: room.clients.length > 0,
283
+ send(ws, { type: 'registered', protocolVersion: 7, room: room.id, paired: room.clients.length > 0,
147
284
  clientCount: room.clients.length, clients: room.clients, revision: room.revision, ...invite }); return;
148
285
  }
149
286
  room = rooms.get(message.room);
@@ -177,6 +314,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
177
314
  room.observers.set(clientId, { ws });
178
315
  send(ws, { type: 'observing', online: Boolean(room.host) });
179
316
  send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
317
+ const announcement = adminState.read().announcement;
318
+ if (announcement) send(ws, { type: 'announcement', announcement });
180
319
  if (room.hostName) send(ws, { type: 'computer-info', hostName: room.hostName });
181
320
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
182
321
  if (room.officialComputer) send(ws, { type: 'official-computer', computer: room.officialComputer });
@@ -186,14 +325,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
186
325
  const oldConnection = room.connections.get(clientId);
187
326
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
188
327
  if (oldConnection?.ws && oldConnection.ws !== ws) {
328
+ releaseRelay(oldConnection);
189
329
  send(room.host, { type: 'detach', session: oldConnection.session });
190
330
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
191
331
  }
192
332
  room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
193
- repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0 });
194
- send(ws, { type: 'paired', protocolVersion: 3, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
333
+ repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
334
+ relayAdmitted: false, relayExempt: false,
335
+ capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
336
+ send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
195
337
  send(ws, { type: 'computer-info', hostName: room.hostName });
196
338
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
339
+ const announcement = adminState.read().announcement;
340
+ if (announcement) send(ws, { type: 'announcement', announcement });
197
341
  send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
198
342
  }
199
343
  if (role === 'host') {
@@ -214,10 +358,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
214
358
  commit(room, { clients: [], revision: room.revision + 1 });
215
359
  room.pairingHash = undefined;
216
360
  for (const connection of room.connections.values()) {
361
+ releaseRelay(connection);
217
362
  send(ws, { type: 'detach', session: connection.session });
218
363
  connection.ws.close(4401, 'Device authorization revoked');
219
364
  }
220
365
  room.connections.clear();
366
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
367
+ room.uploadClients?.clear();
221
368
  for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
222
369
  room.observers.clear();
223
370
  send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
@@ -225,16 +372,30 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
225
372
  }
226
373
  const connection = [...room.connections.values()].find(item => item.session === message.session);
227
374
  if (!connection) return; // Late frames from an ended session.
228
- if (message.type === 'data' && typeof message.payload === 'string') send(connection.ws, { type: 'data', payload: message.payload });
229
- else if (message.type === 'ready') send(connection.ws, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
375
+ if (message.type === 'data' && typeof message.payload === 'string') queueClientMessage(connection, { type: 'data', payload: message.payload });
376
+ else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
230
377
  p2pProbeRepeat: connection.p2pProbe && connection.repeat });
231
378
  else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
232
- if (connection.probeId !== message.probeId) return;
233
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
379
+ const diagnostic = message.purpose === 'diagnostic';
380
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
381
+ if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
382
+ if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
234
383
  send(connection.ws, { ...message, session: undefined });
235
384
  }
236
385
  else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
237
386
  else fail();
387
+ } else if (role === 'file-host' && room.uploadHost === ws) {
388
+ const client = [...room.uploadClients.values()].find(item => item.session === message.session);
389
+ if (!client) return;
390
+ if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
391
+ if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
392
+ send(client.ws, { type: 'file-frame', payload: message.payload });
393
+ } else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
394
+ else fail(1008, 'Invalid file frame');
395
+ } else if (role === 'file-client' && room.uploadClients?.get(clientId)?.ws === ws) {
396
+ if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
397
+ if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
398
+ send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
238
399
  } else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
239
400
  const computer = normalizeOfficialComputer(message);
240
401
  commit(room, { officialComputer: computer });
@@ -242,16 +403,29 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
242
403
  } else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
243
404
  const connection = room.connections.get(clientId);
244
405
  if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
406
+ const diagnostic = message.purpose === 'diagnostic';
407
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
408
+ const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
409
+ const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
410
+ const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
245
411
  if (message.kind === 'start') {
246
- if (connection.probeId && (!connection.repeat || Date.now() - connection.probeStarted < 2000)) {
247
- send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId, kind: 'close' }); return;
412
+ if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
413
+ send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
414
+ ...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
248
415
  }
249
- connection.probeId = message.probeId; connection.signals = 0; connection.probeStarted = Date.now();
416
+ connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
250
417
  }
251
- if (connection.probeId !== message.probeId) return;
252
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
418
+ if (connection[idField] !== message.probeId) return;
419
+ if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
253
420
  send(room.host, { ...message, session });
421
+ if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
422
+ } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
423
+ releaseRelay(room.connections.get(clientId));
254
424
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
425
+ const connection = room.connections.get(clientId);
426
+ if (!controlPayload(message.payload) && !admitRelay(connection, room)) {
427
+ ws.close(4428, 'Server busy'); return;
428
+ }
255
429
  send(room.host, { type: 'data', session, payload: message.payload });
256
430
  } else fail();
257
431
  } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
@@ -261,7 +435,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
261
435
  listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
262
436
  return {
263
437
  url: listenUrl, hostKey,
264
- async close() {
438
+ async close() {
265
439
  for (const ws of wss.clients) ws.terminate();
266
440
  await new Promise(resolve => wss.close(resolve));
267
441
  await new Promise(resolve => server.close(resolve));
@@ -338,3 +512,24 @@ function plainLabel(value) {
338
512
  function plainText(value, limit, empty) {
339
513
  return typeof value === 'string' && (empty || value.trim().length > 0) && value.trim().length <= limit && !/[\u0000-\u001f\u007f]/.test(value);
340
514
  }
515
+
516
+ function controlPayload(value) {
517
+ try {
518
+ const message = JSON.parse(value);
519
+ if (message.type === 'unsubscribe') return true;
520
+ return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
521
+ (message.path === '/api/health' || message.path.startsWith('/api/shared-recovery'));
522
+ } catch { return false; }
523
+ }
524
+
525
+ function readBody(request, limit) {
526
+ return new Promise((resolve, reject) => {
527
+ let value = '';
528
+ request.setEncoding('utf8');
529
+ request.on('data', chunk => {
530
+ value += chunk;
531
+ if (Buffer.byteLength(value) > limit) { reject(new Error('Admin request too large')); request.destroy(); }
532
+ });
533
+ request.on('end', () => resolve(value)); request.on('error', reject);
534
+ });
535
+ }