@walkhi/code-relax 0.1.0-beta.2 → 0.1.0-beta.21

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 (120) hide show
  1. package/README.md +55 -71
  2. package/dist/bin/codex-remote.mjs +150 -126
  3. package/dist/bin/manage-relay-admin.mjs +20 -0
  4. package/dist/bin/migrate-relay-db.mjs +107 -0
  5. package/dist/bin/self-relay-admin.mjs +8 -0
  6. package/dist/bin/self-relay-demo.mjs +2 -2
  7. package/dist/bin/self-relay-server.mjs +18 -10
  8. package/dist/shared/app-server-events.cjs +42 -15
  9. package/dist/shared/file-mentions.cjs +35 -0
  10. package/dist/shared/server-requests.cjs +86 -58
  11. package/dist/skills/code-relax/SKILL.md +7 -6
  12. package/dist/src/desktop-launcher.mjs +16 -21
  13. package/dist/src/file-upload-session.mjs +124 -0
  14. package/dist/src/lan-socket-server.mjs +52 -9
  15. package/dist/src/managed-app-server.mjs +44 -13
  16. package/dist/src/message-images.mjs +0 -18
  17. package/dist/src/onboarding.mjs +9 -11
  18. package/dist/src/platform/README.md +1 -1
  19. package/dist/src/platform/windows/background-process.mjs +1 -1
  20. package/dist/src/platform/windows/codex-runtime.mjs +251 -0
  21. package/dist/src/platform/windows/desktop-host.mjs +17 -3
  22. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  23. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
  24. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
  25. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  26. package/dist/src/platform/windows/resident-start.js +20 -0
  27. package/dist/src/platform/windows/resident-startup.ps1 +76 -20
  28. package/dist/src/platform/windows/service-host.mjs +4 -16
  29. package/dist/src/platform/windows/start-hidden-console.ps1 +10 -5
  30. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  31. package/dist/src/platform/windows/user-environment.mjs +1 -1
  32. package/dist/src/runtime-discovery.mjs +43 -0
  33. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  34. package/dist/src/self-relay/admin-page.mjs +79 -0
  35. package/dist/src/self-relay/admin-server.mjs +66 -0
  36. package/dist/src/self-relay/admin-state.mjs +82 -19
  37. package/dist/src/self-relay/admin-style.mjs +9 -0
  38. package/dist/src/self-relay/admin-usage-page.mjs +28 -0
  39. package/dist/src/self-relay/connector.mjs +243 -30
  40. package/dist/src/self-relay/demo.mjs +3 -4
  41. package/dist/src/self-relay/email-accounts.mjs +141 -0
  42. package/dist/src/self-relay/lifecycle.mjs +99 -142
  43. package/dist/src/self-relay/mysql-state.mjs +104 -0
  44. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  45. package/dist/src/self-relay/server.mjs +335 -59
  46. package/dist/src/self-relay/usage-diagnostics.mjs +46 -0
  47. package/dist/src/self-relay/usage-meter.mjs +125 -0
  48. package/dist/src/server.mjs +731 -1439
  49. package/dist/src/service-control.mjs +4 -4
  50. package/dist/src/service-doctor.mjs +27 -29
  51. package/dist/src/service-health.mjs +2 -2
  52. package/dist/src/service-install.mjs +1 -4
  53. package/dist/src/service-lifecycle.mjs +19 -19
  54. package/dist/src/shared-app-server.mjs +277 -270
  55. package/dist/src/shared-catalog.mjs +1 -11
  56. package/dist/src/shared-recovery.mjs +11 -9
  57. package/dist/src/shared-thread-stream.mjs +1 -1
  58. package/dist/src/skill-install.mjs +0 -13
  59. package/package.json +17 -18
  60. package/tools/postinstall.mjs +69 -15
  61. package/dist/src/app-server-client.mjs +0 -468
  62. package/dist/src/app-server-tasks.mjs +0 -358
  63. package/dist/src/debug-ui.mjs +0 -131
  64. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  65. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  66. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  67. package/dist/src/platform/windows/launch-worker.ps1 +0 -20
  68. package/dist/src/thread-catalog.mjs +0 -47
  69. package/dist/tools/find-desktop-pipe.mjs +0 -10
  70. package/dist/web/activity-view.js +0 -283
  71. package/dist/web/approval-ui.js +0 -188
  72. package/dist/web/capabilities.js +0 -57
  73. package/dist/web/chat-transport.js +0 -202
  74. package/dist/web/chat.css +0 -782
  75. package/dist/web/chat.js +0 -3830
  76. package/dist/web/client-platform.js +0 -11
  77. package/dist/web/community-view.js +0 -20
  78. package/dist/web/community.css +0 -77
  79. package/dist/web/community.html +0 -37
  80. package/dist/web/composer-controller.js +0 -101
  81. package/dist/web/conversation-controller.js +0 -99
  82. package/dist/web/disclosure-state-controller.js +0 -95
  83. package/dist/web/draft-controller.js +0 -103
  84. package/dist/web/harmony-platform.js +0 -52
  85. package/dist/web/highlight-powershell.min.js +0 -39
  86. package/dist/web/highlight.min.js +0 -1262
  87. package/dist/web/history-cache.js +0 -240
  88. package/dist/web/history-controller.js +0 -167
  89. package/dist/web/index.html +0 -325
  90. package/dist/web/lan-session.js +0 -122
  91. package/dist/web/link-action-controller.js +0 -212
  92. package/dist/web/message-send-controller.js +0 -177
  93. package/dist/web/message-view.js +0 -98
  94. package/dist/web/p2p-data-channel.js +0 -50
  95. package/dist/web/p2p-diagnostics.js +0 -28
  96. package/dist/web/p2p-probe.js +0 -123
  97. package/dist/web/page-resume.js +0 -28
  98. package/dist/web/pending-message-store.js +0 -108
  99. package/dist/web/queue-controller.js +0 -82
  100. package/dist/web/resources.json +0 -1
  101. package/dist/web/self-relay-session.js +0 -129
  102. package/dist/web/server-requests.js +0 -67
  103. package/dist/web/station-connection-controller.js +0 -75
  104. package/dist/web/task-list-view.js +0 -296
  105. package/dist/web/thread-attention-controller.js +0 -124
  106. package/dist/web/thread-context-controller.js +0 -30
  107. package/dist/web/thread-list-controller.js +0 -61
  108. package/dist/web/thread-list-sync.js +0 -86
  109. package/dist/web/thread-title-controller.js +0 -57
  110. package/dist/web/timeline-formatters.js +0 -249
  111. package/dist/web/timeline-reducer.js +0 -81
  112. package/dist/web/timeline-renderer.js +0 -161
  113. package/dist/web/timeline-scroll-controller.js +0 -50
  114. package/dist/web/timeline-sync.js +0 -67
  115. package/dist/web/usage-controller.js +0 -258
  116. package/dist/web/vendor/README.md +0 -14
  117. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  118. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  119. package/dist/web/vendor/highlight.min.js +0 -1262
  120. package/dist/web/vendor/lucide.LICENSE.txt +0 -17
@@ -1,121 +1,75 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { execFile } from 'node:child_process';
4
- import { setTimeout as delay } from 'node:timers/promises';
5
- import { startBackground, processAlive } from '../background-process.mjs';
6
- import { promisify } from 'node:util';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
7
5
  import { randomBytes, randomUUID } from 'node:crypto';
8
- import { fileURLToPath } from 'node:url';
9
6
  import { hostname } from 'node:os';
10
7
  import QRCode from 'qrcode';
11
8
  import { credentialStore } from '../platform/windows/credential-store.mjs';
12
9
  import { readRecord, writeRecord } from '../service-lifecycle.mjs';
13
10
  import { serviceHealth } from '../service-health.mjs';
14
- import { openControl, controlRequest } from '../service-control.mjs';
11
+ import { controlRequest } from '../service-control.mjs';
15
12
  import { connectBridgeRelay } from './connector.mjs';
16
13
  import { validProbeIceServers } from './p2p-probe.mjs';
17
14
  import { relayAddress, validSecret } from './wire.mjs';
18
15
  import { saveLanAuthorizations } from './lan-authorizations.mjs';
19
- import { lanNetworks } from '../platform/windows/network-info.mjs';
20
-
21
- const exec = promisify(execFile);
22
- const script = fileURLToPath(import.meta.url);
23
- const recordPath = root => path.join(root, 'self-relay-session.json');
24
-
25
- async function loadConfig(stateRoot) {
26
- const config = readRecord(path.join(stateRoot, 'self-relay-config.json')) || {};
16
+ import { lanNetworks } from '../platform/windows/network-info.mjs';
17
+
18
+ const exec = promisify(execFile);
19
+ const officialConfig = { url: 'wss://api.uulife.site/relay' };
20
+
21
+ async function loadConfig(stateRoot) {
22
+ const configPath = path.join(stateRoot, 'self-relay-config.json');
23
+ const stored = readRecord(configPath) || {};
24
+ const config = { ...officialConfig, ...stored };
25
+ if (!stored.url) writeRecord(configPath, config);
27
26
  if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
28
27
  if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
29
28
  if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
30
29
  const url = process.env.RELAY_URL || config.url;
31
- if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
30
+ if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
32
31
  relayAddress(url);
33
32
  const resolved = relayAddress(url);
34
33
  const pairingUrl = config.pairingUrl || `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`;
35
34
  const pairing = new URL(pairingUrl);
36
35
  if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
37
36
  (pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
38
- return { ...config, url: resolved, pairingUrl: pairing.href };
39
- }
40
- async function registrationKey(config) {
41
- const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
42
- let hostKey;
43
- if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
44
- else {
45
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
46
- !/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
47
- try {
48
- const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
49
- `sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
50
- hostKey = result.stdout.trim();
51
- } catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
52
- }
53
- if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
54
- return hostKey;
55
- }
56
-
57
- export async function selfRelay(command, stateRoot, requiredMode = '') {
58
- const previous = readRecord(recordPath(stateRoot));
59
- let current;
60
- if (previous) {
61
- try { current = await controlRequest(previous); }
62
- catch (error) { if (error.code !== 'ECONNREFUSED') throw error; }
63
- }
64
- if (command === 'relay-status' && current) return controlRequest(previous, 'POST', 'inspect');
65
- if (command === 'relay-status') return current || { running: false, displayText: '私有中继连接器未运行。' };
66
- if (command === 'relay-stop') {
67
- if (current) await controlRequest(previous, 'POST');
68
- const deadline = Date.now() + 10000;
69
- while (current && processAlive(previous.pid)) {
70
- if (Date.now() >= deadline) throw new Error('私有中继未在 10 秒内退出,未继续恢复服务。');
71
- await delay(100);
72
- }
73
- return { stopped: true, displayText: '私有中继连接器已停止;Bridge 和 Codex 保持运行。' };
74
- }
75
- if (current) {
76
- if (requiredMode) {
77
- const bridge = await serviceHealth(readRecord(path.join(stateRoot, 'bridge-session.json')));
78
- if (bridge?.transportMode !== requiredMode) throw new Error('当前 Bridge 未启用完整控制;请先执行 shared-start。');
79
- }
80
- if (command === 'relay-revoke') return controlRequest(previous, 'POST', 'revoke');
81
- if (command === 'relay-pair' || (current.relayConnected && !current.paired && !current.qrPath)) return controlRequest(previous, 'POST', 'pair');
82
- if (command === 'relay-start' && current.relayConnected) return controlRequest(previous, 'POST', 'refresh-lan');
83
- return current;
84
- }
85
- if (command === 'relay-revoke') throw new Error('请先启动连接器,再撤销手机授权。');
86
- fs.mkdirSync(stateRoot, { recursive: true });
87
- const instanceId = randomUUID();
88
- const log = path.join(stateRoot, 'self-relay.log');
89
- const readyPath = path.join(stateRoot, `relay-ready-${instanceId}.json`);
90
- const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, instanceId,
91
- command === 'relay-pair' ? 'pair' : '', requiredMode], process.env, [log, log]);
92
- try {
93
- const deadline = Date.now() + 25000;
94
- while (true) {
95
- const result = readRecord(readyPath);
96
- if (result) {
97
- if (result.pid !== pid || result.instanceId !== instanceId) throw new Error('私有中继启动实例不匹配。');
98
- if (result.error) throw new Error(result.error);
99
- return result.status;
100
- }
101
- if (!processAlive(pid)) throw new Error('私有中继启动失败,请查看 self-relay.log。');
102
- if (Date.now() >= deadline) throw new Error('私有中继启动超时。');
103
- await delay(100);
104
- }
105
- } catch (error) { if (processAlive(pid)) process.kill(pid); throw error; }
106
- finally { fs.rmSync(readyPath, { force: true }); }
107
- }
108
-
109
- async function worker(stateRoot, instanceId, refresh = false, requiredMode = '') {
37
+ return { ...config, url: resolved, pairingUrl: pairing.href };
38
+ }
39
+ async function registrationKey(config) {
40
+ if (config.url === officialConfig.url) return undefined;
41
+ const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
42
+ let hostKey;
43
+ if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
44
+ else {
45
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
46
+ !/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
47
+ try {
48
+ const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
49
+ `sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
50
+ hostKey = result.stdout.trim();
51
+ } catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
52
+ }
53
+ if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
54
+ return hostKey;
55
+ }
56
+ export async function selfRelay(command, stateRoot) {
57
+ const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
58
+ if (!session) {
59
+ if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
60
+ if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
61
+ throw new Error('桌面服务尚未运行,请先执行 relax start。');
62
+ }
63
+ return controlRequest(session, 'POST', command);
64
+ }
65
+
66
+ async function createRelayRuntime(stateRoot) {
110
67
  const config = await loadConfig(stateRoot);
111
68
  const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
112
69
  let health = await serviceHealth(session());
113
- if (health?.status !== 'ok') throw new Error('Bridge 尚未运行,请先执行 relax start --json。');
114
- if (requiredMode && health.transportMode !== requiredMode) throw new Error('完整控制的 Code Relax 中继需要完整控制 Bridge,请先执行 relax shared-start --json。');
115
- const legacyPath = path.join(stateRoot, 'self-relay-device.json');
116
- const legacy = readRecord(legacyPath);
117
- const stored = await credentialStore('load');
118
- let identities = stored.record || legacy || { version: 1, servers: {} };
70
+ if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
71
+ const stored = await credentialStore('load');
72
+ const identities = stored.record || { version: 1, servers: {} };
119
73
  const validate = value => {
120
74
  if (value?.version !== 1 || !value.servers || typeof value.servers !== 'object' || Array.isArray(value.servers)) throw new Error('本机设备身份损坏,未自动覆盖');
121
75
  for (const [url, item] of Object.entries(value.servers)) {
@@ -124,28 +78,18 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
124
78
  }
125
79
  };
126
80
  validate(identities);
127
- if (legacy) {
128
- validate(legacy);
129
- if (stored.record && JSON.stringify(stored.record) !== JSON.stringify(legacy)) throw new Error('新旧设备身份冲突,未覆盖任何凭据');
130
- if (!stored.record) {
131
- const verified = await credentialStore('save', identities);
132
- if (JSON.stringify(verified.record) !== JSON.stringify(identities)) throw new Error('凭据迁移校验失败');
133
- }
134
- fs.rmSync(legacyPath);
135
- }
136
81
  let identity = identities.servers[config.url];
137
- if (!identity) {
138
- identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
139
- identities.servers[config.url] = identity; await credentialStore('save', identities);
140
- }
141
- const hostKey = identity.registered ? undefined : await registrationKey(config);
142
- let bridgeError = '';
82
+ if (!identity) {
83
+ identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
84
+ identities.servers[config.url] = identity; await credentialStore('save', identities);
85
+ }
86
+ const hostKey = identity.registered ? undefined : await registrationKey(config);
87
+ let bridgeError = '';
143
88
  async function refreshHealth() {
144
- try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; bridgeError = ''; }
145
- catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; }
89
+ try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
90
+ catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
146
91
  }
147
- const secret = randomBytes(32).toString('base64url');
148
- let connector, control, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
92
+ let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
149
93
  let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
150
94
  const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
151
95
  async function currentLanCandidates() {
@@ -159,22 +103,20 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
159
103
  const status = () => {
160
104
  const available = online && !paired && qrPath && Date.now() < expiresAt;
161
105
  const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
162
- : paired ? '设备已授权,手机可直接重连;网络断开不需要重新扫码。'
106
+ : paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
163
107
  : available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
164
108
  : '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
165
109
  return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
166
110
  relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
167
- bridgeReady: health.status === 'ok' && Boolean(health.sharedAppServerConnected || health.desktopConnected), bridgeError,
111
+ bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
168
112
  expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
169
113
  ...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
170
- displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? '完整控制' : '基础控制'}\n${next}\n停止连接器保留授权:relax relay-stop --json。撤销全部移动设备:relax relay-revoke --json。` };
114
+ displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}` };
171
115
  };
172
116
  async function shutdown() {
173
117
  if (closing) return;
174
118
  closing = true; clearTimeout(retryTimer);
175
119
  connector?.events.removeAllListeners(); connector?.close();
176
- if (readRecord(recordPath(stateRoot))?.instanceId === instanceId) fs.rmSync(recordPath(stateRoot), { force: true });
177
- await control?.close();
178
120
  }
179
121
  async function saveInvitation(message) {
180
122
  if (!message.code) return;
@@ -199,7 +141,8 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
199
141
  await refreshHealth();
200
142
  const lanCandidates = await currentLanCandidates();
201
143
  connector?.events.removeAllListeners(); connector?.close();
202
- const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
144
+ const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
145
+ usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
203
146
  bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
204
147
  bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
205
148
  hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
@@ -257,25 +200,39 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
257
200
  audit('lan-candidates-updated', { count: candidates.length });
258
201
  return { ...status(), lanCandidateCount: candidates.length };
259
202
  }
260
- try {
261
- const freshStatus = async () => {
262
- if (online) {
263
- const remote = await connector.command('status'); paired = remote.paired;
264
- await refreshHealth();
265
- }
266
- return status();
267
- };
268
- control = await openControl({ instanceId, secret, shutdown, status, actions: { pair, revoke, inspect: freshStatus, 'refresh-lan': refreshLan } });
269
- writeRecord(recordPath(stateRoot), { pid: process.pid, instanceId, controlSecret: secret, controlPort: control.port });
270
- await connect();
271
- const result = refresh && online ? await pair() : status();
272
- writeRecord(path.join(stateRoot, `relay-ready-${instanceId}.json`), { instanceId, pid: process.pid, status: { ...result, pid: process.pid } });
273
- } catch (error) { await shutdown(); throw error; }
274
- }
275
-
276
- if (process.argv[1] && path.resolve(process.argv[1]) === script && process.argv[2] === '--worker') {
277
- worker(process.argv[3], process.argv[4], process.argv[5] === 'pair', process.argv[6]).catch(error => {
278
- writeRecord(path.join(process.argv[3], `relay-ready-${process.argv[4]}.json`), { instanceId: process.argv[4], pid: process.pid, error: error.message });
279
- console.error(error.message); process.exitCode = 1;
280
- });
281
- }
203
+ const inspect = async () => {
204
+ if (online) {
205
+ const remote = await connector.command('status'); paired = remote.paired;
206
+ await refreshHealth();
207
+ }
208
+ return status();
209
+ };
210
+ try { await connect(); return { status, inspect, pair, revoke, refreshLan, shutdown }; }
211
+ catch (error) { await shutdown(); throw error; }
212
+ }
213
+
214
+ export function createRelayManager(stateRoot) {
215
+ let current;
216
+ let operation = Promise.resolve();
217
+ const run = command => {
218
+ const next = operation.then(async () => {
219
+ if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
220
+ if (command === 'relay-stop') {
221
+ await current?.shutdown(); current = undefined;
222
+ return { stopped: true, displayText: '中继连接已停止;桌面服务和 Codex 内核保持运行。' };
223
+ }
224
+ if (!current) {
225
+ if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
226
+ current = await createRelayRuntime(stateRoot);
227
+ return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
228
+ }
229
+ if (command === 'relay-revoke') return current.revoke();
230
+ if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
231
+ if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
232
+ return current.status();
233
+ });
234
+ operation = next.catch(() => {});
235
+ return next;
236
+ };
237
+ return { run, close: () => run('relay-stop'), status: () => current?.status() || { running: false } };
238
+ }
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import mysql from 'mysql2/promise';
5
+ import { deviceStore } from './device-store.mjs';
6
+ import { relayAdminState } from './admin-state.mjs';
7
+
8
+ const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
9
+ const isoTime = value => value.replace(' ', 'T') + 'Z';
10
+
11
+ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
12
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
13
+ if (!config.database || !config.user || !config.password || !(config.socketPath || config.host)) {
14
+ throw new Error('Incomplete relay MySQL configuration');
15
+ }
16
+ const pool = mysql.createPool({ ...config, waitForConnections: true, connectionLimit: 4,
17
+ namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
18
+ try {
19
+ const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
20
+ const knownVersions = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
21
+ if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
22
+ for (const row of versions) {
23
+ if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
24
+ const source = fs.readFileSync(fileURLToPath(new URL(`../../database/${row.version}.sql`, import.meta.url)));
25
+ if (row.sha256 !== createHash('sha256').update(source).digest('hex')) throw new Error(`Unexpected relay database schema: ${row.version}`);
26
+ }
27
+ const [computers] = await pool.query('SELECT id, host_hash, status, revision, created_at, updated_at FROM computers');
28
+ const [clients] = await pool.query('SELECT computer_id, client_id, credential_hash, created_at, updated_at FROM mobile_authorizations ORDER BY created_at');
29
+ const [official] = await pool.query('SELECT computer_id, environment_id, name, hostname, updated_at FROM official_computers');
30
+ const records = new Map(computers.map(row => [row.id, { id: row.id, hostHash: row.host_hash, status: row.status,
31
+ revision: Number(row.revision), createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at), clients: [] }]));
32
+ for (const row of clients) {
33
+ const record = records.get(row.computer_id);
34
+ if (!record) throw new Error('Orphan mobile authorization');
35
+ record.clients.push({ id: row.client_id, hash: row.credential_hash,
36
+ createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at) });
37
+ }
38
+ for (const row of official) {
39
+ const record = records.get(row.computer_id);
40
+ if (!record) throw new Error('Orphan official computer');
41
+ record.officialComputer = { environmentId: row.environment_id, name: row.name,
42
+ hostname: row.hostname, updatedAt: isoTime(row.updated_at) };
43
+ }
44
+ const validator = deviceStore();
45
+ for (const record of records.values()) validator.save(record);
46
+
47
+ const [settings] = await pool.query('SELECT max_relay_sessions FROM relay_settings WHERE id = 1');
48
+ if (settings.length !== 1) throw new Error('Missing relay settings');
49
+ const [rows] = await pool.query('SELECT id, title, message, status, created_at, updated_at, published_at FROM announcements ORDER BY created_at DESC');
50
+ const announcements = rows.map(row => ({ id: row.id, title: row.title, message: row.message, status: row.status,
51
+ createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at),
52
+ publishedAt: row.published_at ? isoTime(row.published_at) : null }));
53
+ const active = announcements.find(item => item.status === 'active');
54
+ const initial = { version: 3, maxRelaySessions: settings[0].max_relay_sessions, announcements,
55
+ announcement: active ? { id: active.id, title: active.title, message: active.message, publishedAt: active.publishedAt } : null };
56
+ const admin = relayAdminState(undefined, initialMaxRelaySessions, { initial, persist: async next => {
57
+ const connection = await pool.getConnection();
58
+ try {
59
+ await connection.beginTransaction();
60
+ await connection.execute('UPDATE relay_settings SET max_relay_sessions = ?, updated_at = UTC_TIMESTAMP(3) WHERE id = 1', [next.maxRelaySessions]);
61
+ await connection.query('DELETE FROM announcements');
62
+ for (const item of next.announcements) await connection.execute(
63
+ 'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
64
+ [item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
65
+ item.publishedAt ? sqlTime(item.publishedAt) : null]);
66
+ await connection.commit();
67
+ } catch (error) { await connection.rollback(); throw error; }
68
+ finally { connection.release(); }
69
+ } });
70
+ return {
71
+ pool,
72
+ schemaVersions: versions.map(row => row.version),
73
+ devices: {
74
+ entries: () => [...records.values()],
75
+ async save(record) {
76
+ validator.save(record);
77
+ const connection = await pool.getConnection();
78
+ try {
79
+ await connection.beginTransaction();
80
+ await connection.execute(`INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at)
81
+ VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE host_hash=VALUES(host_hash),status=VALUES(status),
82
+ revision=VALUES(revision),updated_at=VALUES(updated_at)`,
83
+ [record.id, record.hostHash, record.status, record.revision, sqlTime(record.createdAt), sqlTime(record.updatedAt)]);
84
+ await connection.execute('DELETE FROM mobile_authorizations WHERE computer_id = ?', [record.id]);
85
+ for (const client of record.clients) await connection.execute(
86
+ 'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at) VALUES (?,?,?,?,?)',
87
+ [record.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt)]);
88
+ if (record.officialComputer) {
89
+ const item = record.officialComputer;
90
+ await connection.execute(`INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at)
91
+ VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE environment_id=VALUES(environment_id),name=VALUES(name),
92
+ hostname=VALUES(hostname),updated_at=VALUES(updated_at)`,
93
+ [record.id, item.environmentId, item.name, item.hostname, sqlTime(item.updatedAt)]);
94
+ }
95
+ await connection.commit(); records.set(record.id, record);
96
+ } catch (error) { await connection.rollback(); throw error; }
97
+ finally { connection.release(); }
98
+ },
99
+ },
100
+ admin,
101
+ close: () => pool.end(),
102
+ };
103
+ } catch (error) { await pool.end(); throw error; }
104
+ }
@@ -110,7 +110,7 @@ export function validProbeSignal(value) {
110
110
  if (!value || value.version !== 1 || !/^[\w-]{16,128}$/.test(value.probeId || '')) return false;
111
111
  if (['start', 'close'].includes(value.kind)) return true;
112
112
  if (['offer', 'answer'].includes(value.kind)) return typeof value.sdp === 'string' && value.sdp.length <= 65536 &&
113
- (value.direct === undefined || value.direct === 1) &&
113
+ (value.direct === undefined || value.direct === 0 || value.direct === 1) &&
114
114
  (value.iceServers === undefined || validProbeIceServers(value.iceServers));
115
115
  if (value.kind === 'candidate') return typeof value.candidate === 'string' && value.candidate.length <= 2048 &&
116
116
  typeof value.mid === 'string' && value.mid.length <= 64;