@walkhi/code-relax 0.1.0-beta.51 → 0.1.0-beta.59

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.
@@ -19,6 +19,8 @@ import { accountConnections } from './account-connections.mjs';
19
19
  import { usageMeter, usageReport } from './usage-meter.mjs';
20
20
  import { relayDiagnostics } from './diagnostics.mjs';
21
21
  import { connectionInfo, deviceInfo } from './device-info.mjs';
22
+ import { listWorkstations, workstationDetail, workstationSummary, resolveWorkstationAccounts, resolveWorkstationConnections } from './admin-workstations.mjs';
23
+ import { phoneConnectionEvents, validPhoneConnection } from './phone-connections.mjs';
22
24
 
23
25
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
24
26
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
@@ -57,6 +59,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
57
59
  const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
58
60
  if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
59
61
  const store = database?.devices || deviceStore(stateFile);
62
+ const phoneEvents = phoneConnectionEvents(database?.pool);
60
63
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
61
64
  const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
62
65
  let activeRelaySessions = 0;
@@ -295,6 +298,21 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
295
298
  const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
296
299
  timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
297
300
  if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
301
+ if (request.method === 'GET' && target.pathname === '/admin/api/workstations') {
302
+ const workstations = await resolveWorkstationAccounts(listWorkstations(rooms, { details: true }), database?.pool);
303
+ await resolveWorkstationConnections(workstations, phoneEvents);
304
+ adminReply(response, 200, { sampledAt: new Date().toISOString(), workstations: workstations.map(workstationSummary) }); return;
305
+ }
306
+ if (request.method === 'GET' && target.pathname.startsWith('/admin/api/workstations/')) {
307
+ const id = target.pathname.slice('/admin/api/workstations/'.length);
308
+ const room = validId(id) && rooms.get(id);
309
+ if (!room) { adminReply(response, 404, { error: '未找到工作站' }); return; }
310
+ const [workstation] = await resolveWorkstationAccounts([
311
+ workstationDetail(room, debugAllowlist.devices.some(entry => entry.deviceId === room.id))], database?.pool);
312
+ await resolveWorkstationConnections([workstation], phoneEvents);
313
+ workstation.connectionEvents = await phoneEvents.list(room.id);
314
+ adminReply(response, 200, { sampledAt: new Date().toISOString(), workstation }); return;
315
+ }
298
316
  if (request.method === 'GET' && target.pathname === '/admin/api/status') {
299
317
  const state = adminState.read();
300
318
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
@@ -402,10 +420,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
402
420
  if (wss.clients.size >= maxConnections || ++admissions > 240) {
403
421
  socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
404
422
  }
405
- wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
423
+ wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request));
406
424
  });
407
- wss.on('connection', ws => {
425
+ wss.on('connection', (ws, request) => {
426
+ const address = request.headers['x-real-ip'];
427
+ ws.adminInfo = { connectedAt: new Date().toISOString(),
428
+ address: typeof address === 'string' && isIP(address) ? address : request.socket.remoteAddress };
408
429
  let role, room, session, clientId, alive = true;
430
+ const phoneEventIds = new Set();
409
431
  let accountConnection;
410
432
  function invalidateAccountConnection() {
411
433
  if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
@@ -533,6 +555,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
533
555
  const user = await accounts.current(message.accountToken);
534
556
  if (!accountConnection.activate(user.expiresAt)) return;
535
557
  userId = user.id;
558
+ ws.adminInfo.account = { id: user.id, email: user.email };
536
559
  }
537
560
  catch { return fail(4431, 'Account session invalid'); }
538
561
  if (ws.readyState !== ws.OPEN) return;
@@ -591,6 +614,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
591
614
  room.usageTasks?.clear(); room.usageArms?.clear();
592
615
  }
593
616
  room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
617
+ room.adminPhoneConnections = undefined;
618
+ room.adminLastOnlineAt = ws.adminInfo.connectedAt; room.adminLastHostName = hostName;
594
619
  room.usageTasks ||= new Map(); room.usageArms ||= new Map();
595
620
  if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
596
621
  room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
@@ -682,7 +707,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
682
707
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
683
708
  const announcement = adminState.read().announcement;
684
709
  if (announcement) send(ws, { type: 'announcement', announcement });
685
- send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
710
+ send(room.host, { type: 'attach', session, clientId, chat: Boolean(userId),
711
+ p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
686
712
  }
687
713
  if (role === 'device-manager') {
688
714
  if (!room.clients.some(item => item.id === clientId)) return fail(4401, 'Device authorization revoked');
@@ -690,12 +716,44 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
690
716
  await manageDevices(room, ws, message, clientId); return;
691
717
  }
692
718
  if (role === 'host') {
719
+ if (['phone-connection', 'phone-connections'].includes(message.type)) {
720
+ if (room.host !== ws) return;
721
+ if (message.type === 'phone-connections') {
722
+ if (!Array.isArray(message.active) || message.active.length > 128 ||
723
+ message.active.some(item => !validPhoneConnection(item))) return fail(1008, 'Invalid phone connection snapshot');
724
+ room.adminPhoneConnections = new Map(message.active.filter(item => room.clients.some(client => client.id === item.clientId))
725
+ .map(item => [item.connectionId, { connectionId: item.connectionId, clientId: item.clientId, mode: item.mode }]));
726
+ return;
727
+ }
728
+ const event = message.event;
729
+ if (!validPhoneConnection(event)) return fail(1008, 'Invalid phone connection event');
730
+ // Revocation can overtake the workstation's final close event.
731
+ if (!room.clients.some(client => client.id === event.clientId)) {
732
+ send(ws, { type: 'phone-connection-ack', id: event.id }); return;
733
+ }
734
+ try { await phoneEvents.record(room.id, event); }
735
+ catch (error) { log({ event: 'phone-connection-error', message: error.message }); return; }
736
+ if (room.host !== ws) return;
737
+ if (phoneEventIds.has(event.id)) { send(ws, { type: 'phone-connection-ack', id: event.id }); return; }
738
+ phoneEventIds.add(event.id);
739
+ if (phoneEventIds.size > 256) phoneEventIds.delete(phoneEventIds.values().next().value);
740
+ room.adminPhoneConnections ||= new Map();
741
+ if (event.state === 'connected') room.adminPhoneConnections.set(event.connectionId,
742
+ { connectionId: event.connectionId, clientId: event.clientId, mode: event.mode });
743
+ else room.adminPhoneConnections.delete(event.connectionId);
744
+ send(ws, { type: 'phone-connection-ack', id: event.id }); return;
745
+ }
693
746
  if (room.host !== ws) return;
694
747
  if (['devices-list', 'device-revoke'].includes(message.type)) { await manageDevices(room, ws, message); return; }
695
748
  if (message.type === 'device-connected') {
696
749
  const at = new Date(Math.min(Date.parse(message.at), Date.now())).toISOString();
697
750
  if (!validId(message.clientId)) return fail(1008, 'Invalid mobile identity');
698
751
  await recordConnection(room, message.clientId, at, deviceInfo(message));
752
+ room.adminLanConnections ||= new Map();
753
+ if (room.clients.some(client => client.id === message.clientId) &&
754
+ (!room.adminLanConnections.has(message.clientId) || room.adminLanConnections.get(message.clientId) < at)) {
755
+ room.adminLanConnections.set(message.clientId, at);
756
+ }
699
757
  return;
700
758
  }
701
759
  if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
@@ -874,12 +932,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
874
932
  if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
875
933
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
876
934
  releaseRelay(room.connections.get(clientId));
935
+ room.connections.get(clientId).adminRoute = 'direct';
877
936
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
878
937
  const connection = room.connections.get(clientId);
879
938
  if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
880
939
  if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
881
940
  ws.close(4428, 'Server busy'); return;
882
941
  }
942
+ if (connection.userId && !controlPayload(message.payload)) connection.adminRoute = 'relay';
883
943
  send(room.host, { type: 'data', session, payload: message.payload });
884
944
  } else fail();
885
945
  } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
  import vm from 'node:vm';
10
10
  import sharp from 'sharp';
11
11
  import { attachLanSocket } from './lan-socket-server.mjs';
12
+ import { connectionReporting } from './self-relay/connection-reporting.mjs';
12
13
  import fileMentions from '../shared/file-mentions.cjs';
13
14
  import fileCitations from '../shared/file-citations.cjs';
14
15
  import { validateInlineImage } from './message-images.mjs';
@@ -393,7 +394,10 @@ async function handleRequest(request, response) {
393
394
  return;
394
395
  }
395
396
  const localPath = url.searchParams.get('path') || '';
396
- if (localFileRequest && !/^[a-z]:[\\/]/i.test(localPath)) throw new RequestError(400, '无效的工作站文件路径。');
397
+ if (localFileRequest && (/[\x00-\x1f]/.test(localPath)
398
+ || !(process.platform === 'win32' ? /^[a-z]:[\\/]/i : /^\/(?!\/)/).test(localPath))) {
399
+ throw new RequestError(400, '无效的工作站文件路径。');
400
+ }
397
401
  const file = localFileRequest ? describeLocalFile(localPath) : fileCache.get(fileMatch[1]);
398
402
  if (!file) throw new RequestError(404, '电脑上的文件不存在或无法读取。');
399
403
  if (file.externalIdentity) await dotsImages.authorization(file.externalIdentity);
@@ -515,11 +519,16 @@ async function handleRequest(request, response) {
515
519
 
516
520
  if (request.method === 'GET' && url.pathname === '/api/file-local-copy') {
517
521
  const value = url.searchParams.get('source') || '';
518
- const id = /^([a-f0-9]{64})(?:\?recovery=[A-Za-z0-9_-]{1,4096})?$/.exec(value)?.[1];
519
- if (id && (fileCache.has(id) || attachmentCache.has(id) && !attachmentCache.get(id).dotsFile)) {
522
+ const match = /^([a-f0-9]{64})(?:\?recovery=([A-Za-z0-9_-]{1,4096}))?$/.exec(value);
523
+ if (!match) throw new RequestError(400, '无效的文件标识。');
524
+ const asset = attachmentCache.get(match[1])
525
+ || dotsImages.restore(match[1], match[2] || url.searchParams.get('recovery') || '');
526
+ // A missing process cache does not make a local image an external file.
527
+ // The actual image endpoint owns recovery and download authorization.
528
+ if (!asset?.dotsFile) {
520
529
  sendJson(response, 200, { external: false, localCopy: null }); return;
521
530
  }
522
- const asset = await externalDownloadAsset(value, url.searchParams.get('recovery'));
531
+ await dotsImages.authorization(asset.identity);
523
532
  const copy = await workstationDownloads.copy(asset.id);
524
533
  sendJson(response, 200, { external: true, localCopy: copy ? workstationCopyReference(copy, asset) : null,
525
534
  cachedContent: asset.buffer ? { remotePath: asset.id, name: asset.name, size: asset.buffer.length, mimeType: asset.mimeType } : null });
@@ -760,7 +769,9 @@ async function handleRequest(request, response) {
760
769
  }
761
770
  const { input } = appServerTurnArguments('', body);
762
771
  messageSubmissionStarted = true;
763
- const created = await appServerEvents.createThread(rpc, target, input, body, undefined, bindCreation);
772
+ const createRpc = (method, params) => method === 'thread/start'
773
+ ? execution.startThread(params) : rpc(method, params);
774
+ const created = await appServerEvents.createThread(createRpc, target, input, body, undefined, bindCreation);
764
775
  const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
765
776
  void generateAndSetThreadTitle({
766
777
  request: rpc,
@@ -1022,6 +1033,8 @@ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: hand
1022
1033
  uploadsRoot,
1023
1034
  authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
1024
1035
  onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
1036
+ onPhoneConnected: (...args) => connectionReporting(stateRoot).connected(...args),
1037
+ onPhoneDisconnected: id => connectionReporting(stateRoot).disconnected(id),
1025
1038
  onDeviceConnected: (clientId, info) => {
1026
1039
  try { recordLanConnection(stateRoot, clientId, info); }
1027
1040
  catch (error) { console.error('记录局域网设备连接失败:', error.message); }
@@ -5,6 +5,7 @@ import { startBackground } from './background-process.mjs';
5
5
  import { readRecord, writeRecord } from './service-lifecycle.mjs';
6
6
  import { runtimePaths } from './platform/runtime-paths.mjs';
7
7
  import { desktopHost } from './platform/desktop-host.mjs';
8
+ import releaseVersion from '../shared/release-version.cjs';
8
9
 
9
10
  const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
10
11
  const statusPath = root => path.join(root, 'service-update.json');
@@ -27,8 +28,10 @@ export async function checkServiceUpdate(root) {
27
28
  const result = await fetch('https://registry.npmjs.org/@walkhi%2fcode-relax/latest', { signal: AbortSignal.timeout(12000) });
28
29
  if (!result.ok) throw new Error(`查询桌面服务版本失败:HTTP ${result.status}`);
29
30
  const latest = await result.json();
30
- if (!versionPattern.test(latest.version || '')) throw new Error('npm 返回的版本号无效');
31
- return { ...serviceUpdateStatus(root), latestVersion: latest.version };
31
+ if (!versionPattern.test(latest.version || '') || releaseVersion.compare(latest.version, latest.version) !== 0) throw new Error('npm 返回的版本号无效');
32
+ const status = serviceUpdateStatus(root);
33
+ return { ...status, latestVersion: latest.version,
34
+ updateAvailable: releaseVersion.compare(latest.version, status.currentVersion) > 0 };
32
35
  }
33
36
  let scheduling = false;
34
37
  export async function requestServiceUpdate(root, body) {
@@ -50,7 +53,7 @@ export async function requestServiceUpdate(root, body) {
50
53
  const latest = await checkServiceUpdate(root);
51
54
  if (!latest.supported) throw new Error('当前安装缺少 npm 路径,请先安装支持手机更新的过渡版。');
52
55
  if (latest.latestVersion !== body.targetVersion) throw new Error('最新版本已变化,请重新检查后确认。');
53
- if (latest.currentVersion === body.targetVersion) throw new Error('桌面服务已是此版本。');
56
+ if (!latest.updateAvailable) throw new Error('没有高于当前安装版本的桌面服务更新。');
54
57
  const installed = installation();
55
58
  const directory = path.join(root, 'service-updates', body.operationId);
56
59
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
@@ -173,6 +173,23 @@ export class SharedAppServer extends EventEmitter {
173
173
  return events.readProjects((method, params) => this.request(method, params));
174
174
  }
175
175
 
176
+ startThread(target) {
177
+ return this.request('thread/start', target, result => {
178
+ // thread/start already subscribes this connection. Retain its authoritative
179
+ // empty snapshot before turn/start can emit events; immediately resuming
180
+ // would unnecessarily read history that may not have been persisted yet.
181
+ const thread = result.thread;
182
+ const record = { thread: { ...thread, turns: (thread.turns || []).map(turn => events.compactTurn(turn)) },
183
+ nextCursor: null, sequence: 0, recoverAfter: 0, changes: new Map(), changeBytes: 0,
184
+ tokens: new Map([[randomUUID(), { sequence: 0, expires: Date.now() + syncTokenTtlMs }]]) };
185
+ this.threads.set(thread.id, record);
186
+ this.contexts.set(thread.id, { model: result.model, thinking: result.reasoningEffort,
187
+ serviceTier: result.serviceTier ?? null, cwd: result.cwd, ...events.permissionState(result) });
188
+ this.scheduleCleanup(thread.id, this.slot(thread.id));
189
+ return result;
190
+ });
191
+ }
192
+
176
193
  async send(args, { assertCurrent = () => {} } = {}) {
177
194
  let submitted = false;
178
195
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.51",
3
+ "version": "0.1.0-beta.59",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  "tag": "beta"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.51",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.51",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.51"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.59",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.59",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.59"
47
47
  }
48
48
  }