@walkhi/code-relax 0.1.0-beta.10 → 0.1.0-beta.12

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 (88) hide show
  1. package/README.md +44 -54
  2. package/dist/bin/codex-remote.mjs +34 -60
  3. package/dist/bin/migrate-relay-db.mjs +104 -0
  4. package/dist/bin/self-relay-server.mjs +17 -10
  5. package/dist/shared/app-server-events.cjs +15 -3
  6. package/dist/shared/file-mentions.cjs +35 -0
  7. package/dist/shared/server-requests.cjs +86 -58
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +6 -11
  10. package/dist/src/file-upload-session.mjs +124 -0
  11. package/dist/src/lan-socket-server.mjs +51 -8
  12. package/dist/src/managed-app-server.mjs +21 -10
  13. package/dist/src/onboarding.mjs +3 -3
  14. package/dist/src/platform/windows/codex-runtime.mjs +138 -4
  15. package/dist/src/platform/windows/desktop-host.mjs +1 -2
  16. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
  17. package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
  18. package/dist/src/platform/windows/resident-start.js +8 -0
  19. package/dist/src/platform/windows/resident-startup.ps1 +6 -3
  20. package/dist/src/platform/windows/service-host.mjs +0 -9
  21. package/dist/src/self-relay/admin-page.mjs +1 -1
  22. package/dist/src/self-relay/admin-state.mjs +8 -6
  23. package/dist/src/self-relay/connector.mjs +84 -12
  24. package/dist/src/self-relay/email-accounts.mjs +125 -0
  25. package/dist/src/self-relay/lifecycle.mjs +52 -100
  26. package/dist/src/self-relay/mysql-state.mjs +104 -0
  27. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  28. package/dist/src/self-relay/server.mjs +155 -38
  29. package/dist/src/server.mjs +736 -610
  30. package/dist/src/service-doctor.mjs +1 -1
  31. package/dist/src/service-install.mjs +1 -4
  32. package/dist/src/shared-app-server.mjs +274 -272
  33. package/dist/src/shared-catalog.mjs +1 -11
  34. package/dist/src/shared-recovery.mjs +5 -5
  35. package/dist/src/skill-install.mjs +0 -13
  36. package/package.json +2 -1
  37. package/dist/src/debug-ui.mjs +0 -131
  38. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  39. package/dist/web/activity-view.js +0 -283
  40. package/dist/web/approval-ui.js +0 -188
  41. package/dist/web/capabilities.js +0 -50
  42. package/dist/web/chat-transport.js +0 -196
  43. package/dist/web/chat.css +0 -778
  44. package/dist/web/chat.js +0 -3815
  45. package/dist/web/client-platform.js +0 -11
  46. package/dist/web/community.css +0 -82
  47. package/dist/web/community.html +0 -36
  48. package/dist/web/composer-controller.js +0 -97
  49. package/dist/web/conversation-controller.js +0 -99
  50. package/dist/web/disclosure-state-controller.js +0 -95
  51. package/dist/web/draft-controller.js +0 -103
  52. package/dist/web/harmony-platform.js +0 -52
  53. package/dist/web/highlight-powershell.min.js +0 -39
  54. package/dist/web/highlight.min.js +0 -1262
  55. package/dist/web/history-cache.js +0 -240
  56. package/dist/web/history-controller.js +0 -167
  57. package/dist/web/index.html +0 -288
  58. package/dist/web/lan-session.js +0 -122
  59. package/dist/web/link-action-controller.js +0 -212
  60. package/dist/web/message-send-controller.js +0 -177
  61. package/dist/web/message-view.js +0 -98
  62. package/dist/web/p2p-data-channel.js +0 -50
  63. package/dist/web/p2p-diagnostics.js +0 -28
  64. package/dist/web/p2p-probe.js +0 -123
  65. package/dist/web/page-resume.js +0 -28
  66. package/dist/web/pending-message-store.js +0 -108
  67. package/dist/web/queue-controller.js +0 -82
  68. package/dist/web/resources.json +0 -1
  69. package/dist/web/self-relay-session.js +0 -129
  70. package/dist/web/server-requests.js +0 -67
  71. package/dist/web/station-connection-controller.js +0 -75
  72. package/dist/web/task-list-view.js +0 -289
  73. package/dist/web/thread-attention-controller.js +0 -124
  74. package/dist/web/thread-context-controller.js +0 -30
  75. package/dist/web/thread-list-controller.js +0 -61
  76. package/dist/web/thread-list-sync.js +0 -86
  77. package/dist/web/thread-title-controller.js +0 -57
  78. package/dist/web/timeline-formatters.js +0 -249
  79. package/dist/web/timeline-reducer.js +0 -77
  80. package/dist/web/timeline-renderer.js +0 -161
  81. package/dist/web/timeline-scroll-controller.js +0 -50
  82. package/dist/web/timeline-sync.js +0 -67
  83. package/dist/web/usage-controller.js +0 -258
  84. package/dist/web/vendor/README.md +0 -14
  85. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  86. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  87. package/dist/web/vendor/highlight.min.js +0 -1262
  88. package/dist/web/vendor/lucide.LICENSE.txt +0 -17
@@ -3,6 +3,8 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
5
  import { execFile } from 'node:child_process';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { fileURLToPath } from 'node:url';
6
8
  import { promisify } from 'node:util';
7
9
  import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
8
10
 
@@ -11,7 +13,130 @@ const requiredMethods = ['thread/start', 'thread/resume', 'thread/list', 'thread
11
13
  'thread/items/list', 'turn/start', 'turn/steer', 'turn/interrupt', 'model/list', 'project/list',
12
14
  'thread/settings/update', 'configRequirements/read', 'thread/queue/list', 'thread/queue/add',
13
15
  'thread/queue/delete', 'thread/queue/start', 'thread/read', 'thread/unsubscribe', 'config/read', 'account/rateLimits/read'];
14
- const fingerprint = file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; };
16
+ export const codeModeHostPath = executable => path.join(path.dirname(executable), 'codex-code-mode-host.exe');
17
+ export const runtimeFilesAvailable = executable => fs.existsSync(executable) && fs.existsSync(codeModeHostPath(executable));
18
+ const fingerprint = executable => [executable, codeModeHostPath(executable)]
19
+ .map(file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; }).join('|');
20
+ const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
21
+ const desktopRuntimeScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'desktop-runtime.ps1');
22
+
23
+ async function fileHash(file) {
24
+ const hash = createHash('sha256');
25
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
26
+ return hash.digest('hex');
27
+ }
28
+
29
+ async function installedDesktopRuntime() {
30
+ const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
31
+ '-File', desktopRuntimeScript], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
32
+ return JSON.parse(stdout.trim());
33
+ }
34
+
35
+ async function matchesInstalledDesktop(executable, installed) {
36
+ if (!installed || !runtimeFilesAvailable(executable) || !runtimeFilesAvailable(installed.executable)) return false;
37
+ const files = [[executable, installed.executable], [codeModeHostPath(executable), installed.codeModeHost]];
38
+ for (const [candidate, bundled] of files) {
39
+ if (fs.statSync(candidate).size !== fs.statSync(bundled).size || await fileHash(candidate) !== await fileHash(bundled)) return false;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ function runtimeFiles(directory, relative = '') {
45
+ const files = [];
46
+ for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true })) {
47
+ const name = path.join(relative, entry.name);
48
+ if (entry.isDirectory()) files.push(...runtimeFiles(directory, name));
49
+ else if (entry.isFile()) files.push(name);
50
+ else throw new Error(`Codex 内核目录包含不支持的文件类型:${name}`);
51
+ }
52
+ return files.sort();
53
+ }
54
+
55
+ function runtimeDirectories(files) {
56
+ const directories = new Set();
57
+ for (const file of files) {
58
+ let directory = path.dirname(file);
59
+ while (directory !== '.') { directories.add(directory); directory = path.dirname(directory); }
60
+ }
61
+ return [...directories].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
62
+ }
63
+
64
+ async function retainDesktopRuntime(executable, installed, stateRoot) {
65
+ if (!/^[\w.-]+$/.test(installed.package)) throw new Error('Codex Desktop 安装包名称无效。');
66
+ const source = path.dirname(executable);
67
+ const files = runtimeFiles(source);
68
+ if (files.includes('runtime.json')) throw new Error('Codex 内核目录与保留清单文件名冲突。');
69
+ const directories = runtimeDirectories(files);
70
+ const root = path.join(stateRoot, 'desktop-runtimes');
71
+ const target = path.join(root, installed.package);
72
+ const retained = path.join(target, 'codex.exe');
73
+ if (fs.existsSync(target)) {
74
+ const manifest = readRecord(path.join(target, 'runtime.json'));
75
+ if (manifest?.package !== installed.package || JSON.stringify(manifest.files) !== JSON.stringify(files)
76
+ || JSON.stringify(runtimeFiles(target)) !== JSON.stringify([...files, 'runtime.json'].sort())
77
+ || !await matchesInstalledDesktop(retained, installed)) {
78
+ throw new Error(`已保留的 Codex 内核文件不完整:${target}`);
79
+ }
80
+ return retained;
81
+ }
82
+ fs.mkdirSync(root, { recursive: true });
83
+ const staging = path.join(root, `.${installed.package}.${randomUUID()}.tmp`);
84
+ fs.mkdirSync(staging);
85
+ const created = [];
86
+ try {
87
+ for (const directory of directories) fs.mkdirSync(path.join(staging, directory));
88
+ for (const name of files) {
89
+ const from = path.join(source, name), to = path.join(staging, name);
90
+ created.push(to);
91
+ try { fs.linkSync(from, to); }
92
+ catch (error) {
93
+ if (error.code !== 'EXDEV') throw error;
94
+ fs.copyFileSync(from, to);
95
+ }
96
+ }
97
+ if (!await matchesInstalledDesktop(path.join(staging, 'codex.exe'), installed)) {
98
+ throw new Error('保留的 Codex 内核与当前安装包不一致。');
99
+ }
100
+ const manifestPath = path.join(staging, 'runtime.json');
101
+ writeRecord(manifestPath, { package: installed.package, files });
102
+ created.push(manifestPath);
103
+ fs.renameSync(staging, target);
104
+ return retained;
105
+ } catch (error) {
106
+ for (const file of created) fs.rmSync(file, { force: true });
107
+ for (const directory of directories.reverse()) {
108
+ const nested = path.join(staging, directory);
109
+ if (fs.existsSync(nested)) fs.rmdirSync(nested);
110
+ }
111
+ fs.rmdirSync(staging);
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ export function pruneRetainedDesktopRuntimes(stateRoot, keepExecutable) {
117
+ const root = path.join(stateRoot, 'desktop-runtimes');
118
+ if (!fs.existsSync(root)) return;
119
+ const keep = path.resolve(path.dirname(keepExecutable)).toLowerCase();
120
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
121
+ if (!entry.isDirectory() || !/^OpenAI\.Codex_[\w.-]+$/.test(entry.name)) continue;
122
+ const directory = path.join(root, entry.name);
123
+ if (path.resolve(directory).toLowerCase() === keep || fs.lstatSync(directory).isSymbolicLink()) continue;
124
+ const manifest = readRecord(path.join(directory, 'runtime.json'));
125
+ const files = manifest?.files;
126
+ if (manifest?.package !== entry.name || !Array.isArray(files) || !files.includes('codex.exe')
127
+ || !files.includes('codex-code-mode-host.exe') || new Set(files).size !== files.length
128
+ || !files.every(name => typeof name === 'string' && name !== 'runtime.json'
129
+ && path.resolve(directory, name).toLowerCase().startsWith(`${path.resolve(directory).toLowerCase()}${path.sep}`))) continue;
130
+ const expected = [...files, 'runtime.json'].sort();
131
+ if (JSON.stringify(runtimeFiles(directory)) !== JSON.stringify(expected)) continue;
132
+ if (!fs.lstatSync(path.join(directory, 'runtime.json')).isFile()
133
+ || !files.every(name => fs.lstatSync(path.join(directory, name)).isFile())) continue;
134
+ for (const name of files) fs.unlinkSync(path.join(directory, name));
135
+ fs.unlinkSync(path.join(directory, 'runtime.json'));
136
+ for (const name of runtimeDirectories(files).reverse()) fs.rmdirSync(path.join(directory, name));
137
+ fs.rmdirSync(directory);
138
+ }
139
+ }
15
140
 
16
141
  // Resolve native binaries, never execute a shell shim from a background task.
17
142
  export function discoverCodex(environment = process.env) {
@@ -92,11 +217,18 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
92
217
  environment = { ...environment,
93
218
  CODEX_HOME: environment.CODEX_HOME || saved?.codexHome || path.join(os.homedir(), '.codex'),
94
219
  CODE_RELAX_CODEX_PATH: environment.CODE_RELAX_CODEX_PATH || (saved?.source === 'explicit' ? saved.executable : '') };
95
- if (!refresh && saved?.executable && fs.existsSync(saved.executable)
220
+ if (!refresh && saved?.executable && runtimeFilesAvailable(saved.executable)
96
221
  && saved.fingerprint === fingerprint(saved.executable)) return saved;
97
222
  const candidates = [], rejected = [];
223
+ let installed = null;
224
+ try { installed = await installedDesktopRuntime(); }
225
+ catch (error) { rejected.push(`无法读取当前 Codex Desktop 安装包:${error.message}`); }
98
226
  for (const candidate of discoverCodex(environment)) {
99
227
  try {
228
+ if (!runtimeFilesAvailable(candidate.executable)) throw new Error('缺少同目录的 codex-code-mode-host.exe');
229
+ if (candidate.source === 'desktop' && !await matchesInstalledDesktop(candidate.executable, installed)) {
230
+ throw new Error('不是当前 Codex Desktop 安装包配套的 CLI');
231
+ }
100
232
  const { stdout } = await exec(candidate.executable, ['--version'], { windowsHide: true, timeout: 10000 });
101
233
  const version = /^codex-cli (\d+\.\d+\.\d+(?:-[\w.]+)?)/m.exec(stdout)?.[1];
102
234
  if (!version) throw new Error('无法识别版本');
@@ -106,8 +238,10 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
106
238
  candidates.sort((a, b) => Number(b.source === 'desktop') - Number(a.source === 'desktop') || compareVersions(b.version, a.version));
107
239
  for (const candidate of candidates) {
108
240
  try {
109
- await verifyProtocol(candidate.executable, stateRoot);
110
- const result = { ...candidate, fingerprint: fingerprint(candidate.executable),
241
+ const executable = candidate.source === 'desktop'
242
+ ? await retainDesktopRuntime(candidate.executable, installed, stateRoot) : candidate.executable;
243
+ await verifyProtocol(executable, stateRoot);
244
+ const result = { ...candidate, executable, fingerprint: fingerprint(executable),
111
245
  codexHome: path.resolve(environment.CODEX_HOME || path.join(os.homedir(), '.codex')) };
112
246
  writeRecord(recordFile, result);
113
247
  return result;
@@ -37,10 +37,9 @@ export const desktopHost = {
37
37
  };
38
38
  export const resolveStateRoot = stateRoot => runScript('resolve-state-root.ps1', ['-StateRoot', stateRoot]);
39
39
 
40
- export async function desktopShortcuts({ serviceRoot, nodeExecutable }, remove = false, destination) {
40
+ export async function removeDesktopShortcut({ serviceRoot, nodeExecutable }, destination) {
41
41
  const entry = path.join(serviceRoot, 'dist/bin/codex-remote.mjs');
42
42
  const args = ['-NodePath', nodeExecutable, '-Entry', entry];
43
- if (remove) args.push('-Remove');
44
43
  if (destination) args.push('-Destination', destination);
45
44
  return JSON.parse(await runScript('desktop-shortcuts.ps1', args));
46
45
  }
@@ -0,0 +1,10 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
3
+ $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
4
+ if (-not $package) { 'null'; exit }
5
+ $resources = Join-Path $package.InstallLocation 'app\resources'
6
+ @{
7
+ package = $package.PackageFullName
8
+ executable = Join-Path $resources 'codex.exe'
9
+ codeModeHost = Join-Path $resources 'codex-code-mode-host.exe'
10
+ } | ConvertTo-Json -Compress
@@ -1,9 +1,10 @@
1
- param([string]$NodePath, [string]$Entry, [switch]$Remove, [string]$Destination)
1
+ param([string]$NodePath, [string]$Entry, [string]$Destination)
2
2
  $ErrorActionPreference = 'Stop'
3
3
  [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
4
  $shell = New-Object -ComObject WScript.Shell
5
5
  if (-not $Destination) { $Destination = $shell.SpecialFolders.Item('Desktop') }
6
- $launcher = Join-Path $PSScriptRoot 'desktop-shortcut-run.ps1'
6
+ # Only identify and remove the retired shortcut; never create a new launcher.
7
+ $launcher = Join-Path $PSScriptRoot 'desktop-shortcut-run.ps1'
7
8
  $items = @(@{Name='Code Relax - Codex 内核'; Command='shared-start'})
8
9
  $results = @()
9
10
  foreach ($item in $items) {
@@ -11,18 +12,10 @@ foreach ($item in $items) {
11
12
  $arguments = '-NoProfile -NoExit -ExecutionPolicy Bypass -File "' + $launcher + '" -NodePath "' + $NodePath + '" -Entry "' + $Entry + '" -Action ' + $item.Command
12
13
  if (Test-Path -LiteralPath $target) {
13
14
  $existing = $shell.CreateShortcut($target)
14
- if ($existing.Arguments -ne $arguments) { throw ('快捷方式不是本安装创建的,未覆盖或删除:' + $target) }
15
- }
16
- if ($Remove) {
17
- if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target }
18
- } else {
19
- $link = $shell.CreateShortcut($target)
20
- $link.TargetPath = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
21
- $link.Arguments = $arguments
22
- $link.WorkingDirectory = Split-Path $Entry -Parent
23
- $link.Description = $item.Name
24
- $link.Save()
15
+ $powershell = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
16
+ if ($existing.Arguments -ne $arguments -or $existing.TargetPath -ne $powershell) { continue }
25
17
  }
18
+ if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target }
26
19
  $results += $target
27
20
  }
28
21
  ConvertTo-Json -InputObject $results -Compress
@@ -0,0 +1,8 @@
1
+ // Windows Script Host has no console. Keep the login task alive until the
2
+ // short-lived CLI has handed the resident service to the background broker.
3
+ if (WScript.Arguments.length !== 2) WScript.Quit(2);
4
+ var shell = new ActiveXObject('WScript.Shell');
5
+ var node = WScript.Arguments.Item(0);
6
+ var entry = WScript.Arguments.Item(1);
7
+ var command = '"' + node + '" "' + entry + '" start --json';
8
+ WScript.Quit(shell.Run(command, 0, true));
@@ -5,7 +5,9 @@ $ErrorActionPreference = 'Stop'
5
5
  $taskName = 'Code Relax Bridge'
6
6
  $taskSource = '@walkhi/code-relax'
7
7
  $taskPath = '\' + $taskName
8
- $arguments = '"' + $Entry + '" start --json'
8
+ $launcher = Join-Path $PSScriptRoot 'resident-start.js'
9
+ $wscript = Join-Path $env:WINDIR 'System32\wscript.exe'
10
+ $arguments = '//B //Nologo "' + $launcher + '" "' + $NodePath + '" "' + $Entry + '"'
9
11
  $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
10
12
 
11
13
  $scheduler = New-Object -ComObject 'Schedule.Service'
@@ -30,7 +32,8 @@ if ($Action -ne 'status' -and $legacyExists) {
30
32
  }
31
33
 
32
34
  if ($Action -eq 'enable') {
33
- if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry)) { throw 'Installed runtime is missing.' }
35
+ if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry) -or
36
+ -not (Test-Path -LiteralPath $launcher) -or -not (Test-Path -LiteralPath $wscript)) { throw 'Installed runtime is missing.' }
34
37
  $definition = $scheduler.NewTask(0)
35
38
  $definition.RegistrationInfo.Description = 'Start the Code Relax Bridge when this user signs in.'
36
39
  $definition.RegistrationInfo.Source = $taskSource
@@ -48,7 +51,7 @@ if ($Action -eq 'enable') {
48
51
  $trigger.UserId = $currentUser
49
52
  $trigger.Enabled = $true
50
53
  $exec = $definition.Actions.Create(0) # TASK_ACTION_EXEC
51
- $exec.Path = $NodePath
54
+ $exec.Path = $wscript
52
55
  $exec.Arguments = $arguments
53
56
  $exec.WorkingDirectory = $env:USERPROFILE
54
57
  [void]$root.RegisterTaskDefinition($taskName, $definition, 6, $null, $null, 3, $null) # TASK_CREATE_OR_UPDATE
@@ -20,21 +20,12 @@ async function detect(script) {
20
20
  export const sharedServiceUrl = () => detect('get-shared-service-url.ps1');
21
21
  export const lanAddress = async () => (await lanNetworks())[0]?.address || '';
22
22
  export { lanNetworks };
23
- export function migrateState(stateRoot) {
24
- fs.mkdirSync(stateRoot, { recursive: true });
25
- for (const name of ['bridge-config.json', 'bridge-session.json']) {
26
- const target = path.join(stateRoot, name);
27
- const old = path.join(bridgeRoot, '../.codex/bridge', name);
28
- if (!fs.existsSync(target) && fs.existsSync(old)) fs.copyFileSync(old, target, fs.constants.COPYFILE_EXCL);
29
- }
30
- }
31
23
  export async function bridgeEnvironment() {
32
24
  const environment = { ...process.env };
33
25
  const installation = readRecord(path.join(runtimePaths(environment).serviceRoot, 'installation.json'));
34
26
  const executable = installation?.nodeExecutable;
35
27
  if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新安装 Code Relax。');
36
28
  environment.CODEX_MCP_NODE_PATH = executable;
37
- environment.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS = JSON.stringify(installation.legacyUploadsRoots || []);
38
29
  delete environment.CODEX_APP_TOOLS_PIPE_PATH;
39
30
  return { executable, environment };
40
31
  }
@@ -47,7 +47,7 @@ const gb=value=>value==null?'—':number(Number(value)/1073741824,2)+' GB';
47
47
  el('trafficUsage').textContent=trafficFresh?'已用 '+gb(cloud.trafficUsedBytes)+' / '+gb(cloud.trafficTotalBytes):'流量暂不可用';
48
48
  el('trafficNote').textContent=trafficFresh?'更新时间 '+new Date(cloud.trafficSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'暂不可用';
49
49
  renderTrafficTimeline(cloud,trafficFresh);
50
- el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'活动手机'))}
50
+ el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'活动移动端'))}
51
51
  let editorItem=null,editorMode='new',writing=false;
52
52
  function openEditor(item=null,duplicate=false){editorItem=duplicate?null:item;editorMode=duplicate?'new':item?.status==='draft'?'draft':item?'view':'new';el('editorHeading').textContent=duplicate?'复制为新公告':editorMode==='draft'?'编辑草稿':editorMode==='view'?'查看公告':'新建公告';el('title').value=item?.title||'';el('message').value=item?.message||'';el('title').disabled=editorMode==='view';el('message').disabled=editorMode==='view';el('editorActions').hidden=editorMode==='view';el('editorNote').textContent=editorMode==='view'?'已发布内容只读;可复制为新公告再修改。':'发布后会立即替换当前公告并通知在线设备。';el('editorError').hidden=true;el('announcementEditor').showModal();if(editorMode!=='view')el('title').focus()}
53
53
  function action(label,className,handler,disabled=false){const button=node('button',className,label);button.type='button';button.disabled=disabled;button.addEventListener('click',handler);return button}
@@ -2,19 +2,20 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
- export function relayAdminState(file, initialMaxRelaySessions) {
6
- let value = { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
5
+ export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
6
+ let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
7
7
  if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
8
8
 
9
- function save(next) {
10
- value = validate(next);
11
- if (!file) return value;
9
+ async function save(next) {
10
+ const validated = validate(next);
11
+ if (persist) await persist(validated);
12
+ if (!file) { value = validated; return value; }
12
13
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
13
14
  const temporary = `${file}.${randomUUID()}.tmp`;
14
15
  let descriptor;
15
16
  try {
16
17
  descriptor = fs.openSync(temporary, 'wx', 0o600);
17
- fs.writeFileSync(descriptor, JSON.stringify(value));
18
+ fs.writeFileSync(descriptor, JSON.stringify(validated));
18
19
  fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
19
20
  fs.renameSync(temporary, file);
20
21
  if (process.platform !== 'win32') {
@@ -25,6 +26,7 @@ export function relayAdminState(file, initialMaxRelaySessions) {
25
26
  if (descriptor !== undefined) fs.closeSync(descriptor);
26
27
  fs.rmSync(temporary, { force: true });
27
28
  }
29
+ value = validated;
28
30
  return value;
29
31
  }
30
32
 
@@ -15,19 +15,65 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
15
15
  serverNoContextTakeover: true, clientNoContextTakeover: true,
16
16
  threshold: 1024, zlibDeflateOptions: { level: 3 },
17
17
  } });
18
- const locals = new Map();
19
- let heartbeat, operation, lastClose;
18
+ const locals = new Map();
19
+ const fileLocals = new Map();
20
+ let fileRemote, fileRetryTimer, heartbeat, operation, lastClose, closing = false;
20
21
  const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => remote.terminate(), 75000); };
21
22
  remote.on('open', alive); remote.on('ping', alive); remote.on('message', alive);
22
23
  function dropLocal(session) {
23
24
  const current = locals.get(session);
24
25
  if (!current) return;
25
26
  locals.delete(session); clearTimeout(current.timer); current.socket?.terminate();
26
- current.probe?.then(probe => probe.close());
27
- }
28
- function dropLocals() {
29
- for (const session of [...locals.keys()]) dropLocal(session);
27
+ current.probe?.then(probe => probe.close());
28
+ current.diagnosticProbe?.then(probe => probe.close());
30
29
  }
30
+ function dropLocals() {
31
+ for (const session of [...locals.keys()]) dropLocal(session);
32
+ for (const socket of fileLocals.values()) socket.terminate();
33
+ fileLocals.clear(); clearTimeout(fileRetryTimer); fileRemote?.terminate(); fileRemote = undefined;
34
+ }
35
+ function startFileReceiver() {
36
+ if (!identity || closing || remote.readyState !== WebSocket.OPEN) return;
37
+ const receiver = fileRemote = new WebSocket(relayUrl, { maxPayload: 512 * 1024, perMessageDeflate: false });
38
+ receiver.on('open', () => send(receiver, { type: 'file-host', deviceId: identity.id, deviceSecret: identity.secret }));
39
+ receiver.on('error', error => events.emit('diagnostic', `File receiver: ${error.message}`));
40
+ receiver.on('close', () => {
41
+ for (const socket of fileLocals.values()) socket.terminate();
42
+ fileLocals.clear();
43
+ if (fileRemote === receiver) {
44
+ fileRemote = undefined;
45
+ if (!closing && remote.readyState === WebSocket.OPEN) fileRetryTimer = setTimeout(startFileReceiver, 3000);
46
+ }
47
+ });
48
+ receiver.on('message', (bytes, binary) => {
49
+ try {
50
+ const message = decode(bytes, binary);
51
+ if (message.type === 'file-host-ready') return;
52
+ if (message.type === 'file-open') {
53
+ const connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken };
54
+ const url = new URL(connection.url); url.pathname = '/api/file-transfer';
55
+ loopbackUrl(url.href, '/api/file-transfer');
56
+ const socket = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
57
+ fileLocals.set(message.session, socket);
58
+ socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
59
+ socket.on('error', error => events.emit('diagnostic', `Local file receiver: ${error.message}`));
60
+ socket.on('close', () => {
61
+ if (fileLocals.get(message.session) !== socket) return;
62
+ fileLocals.delete(message.session);
63
+ send(receiver, { type: 'file-close', session: message.session });
64
+ });
65
+ socket.on('message', (data, isBinary) => {
66
+ if (isBinary || fileLocals.get(message.session) !== socket) return socket.terminate();
67
+ send(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
68
+ });
69
+ } else if (message.type === 'file-frame') {
70
+ const socket = fileLocals.get(message.session);
71
+ if (socket?.readyState === WebSocket.OPEN) socket.send(message.payload);
72
+ } else if (message.type === 'file-close') fileLocals.get(message.session)?.terminate();
73
+ else throw new Error('Invalid file receiver frame');
74
+ } catch (error) { events.emit('diagnostic', error.message); receiver.close(1008, 'Invalid file frame'); }
75
+ });
76
+ }
31
77
  const ready = new Promise((resolve, reject) => {
32
78
  let registered = false;
33
79
  const timer = setTimeout(() => { reject(new Error('Relay registration timeout')); remote.terminate(); }, 5000);
@@ -44,7 +90,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
44
90
  remote.on('message', (bytes, binary) => {
45
91
  try {
46
92
  const message = decode(bytes, binary);
47
- if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); resolve(message); return; }
93
+ if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); startFileReceiver(); resolve(message); return; }
48
94
  if (!registered) throw new Error('Relay not registered');
49
95
  if (message.type === 'pair-code' || message.type === 'revoked' || message.type === 'status') {
50
96
  if (operation && message.commandId === operation.id && message.type === operation.responseType) {
@@ -102,10 +148,36 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
102
148
  }
103
149
  } catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
104
150
  });
105
- } else if (message.type === 'p2p-probe' && locals.has(message.session)) {
106
- const current = locals.get(message.session);
107
- if (!current.ready || !current.p2pProbe || !validProbeSignal(message)) throw new Error('Invalid probe signal');
108
- if (message.kind === 'start' && current.probeId !== message.probeId) {
151
+ } else if (message.type === 'p2p-probe' && locals.has(message.session)) {
152
+ const current = locals.get(message.session);
153
+ if (!current.ready || !current.p2pProbe || !validProbeSignal(message)) throw new Error('Invalid probe signal');
154
+ if (message.purpose === 'diagnostic') {
155
+ if (message.kind === 'start' && current.diagnosticProbeId !== message.probeId) {
156
+ void current.diagnosticProbe?.then(probe => probe.close());
157
+ current.diagnosticProbeId = message.probeId;
158
+ const probeId = message.probeId;
159
+ current.diagnosticProbe = createP2pProbe({ iceServers,
160
+ signal: signal => {
161
+ if (locals.get(message.session) === current && current.diagnosticProbeId === probeId) {
162
+ send(remote, { type: 'p2p-probe', purpose: 'diagnostic', session: message.session,
163
+ version: 1, probeId, ...signal });
164
+ }
165
+ },
166
+ report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
167
+ }).catch(() => {
168
+ if (locals.get(message.session) === current && current.diagnosticProbeId === probeId) {
169
+ send(remote, { type: 'p2p-probe', purpose: 'diagnostic', session: message.session,
170
+ version: 1, probeId, kind: 'close' });
171
+ }
172
+ return { receive() {}, close() {} };
173
+ });
174
+ } else if (current.diagnosticProbe && current.diagnosticProbeId === message.probeId) {
175
+ void current.diagnosticProbe.then(probe => probe.receive(message));
176
+ }
177
+ return;
178
+ }
179
+ if (message.purpose) throw new Error('Invalid probe purpose');
180
+ if (message.kind === 'start' && current.probeId !== message.probeId) {
109
181
  void current.probe?.then(probe => probe.close());
110
182
  current.probeId = message.probeId;
111
183
  current.routeSignals = Promise.resolve();
@@ -157,6 +229,6 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
157
229
  updateLanCandidates(candidates) {
158
230
  if (!send(remote, { type: 'lan-candidates', candidates })) throw new Error('中继未连接');
159
231
  },
160
- close() { clearTimeout(heartbeat); dropLocals(); remote.terminate(); }
232
+ close() { closing = true; clearTimeout(heartbeat); dropLocals(); remote.terminate(); }
161
233
  };
162
234
  }
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import { createHash, createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { tencentAccountProvider } from '../../relay-admin/tencent-account-provider.mjs';
4
+
5
+ const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
6
+ const isoTime = value => value.replace(' ', 'T') + 'Z';
7
+ const error = (status, message) => Object.assign(new Error(message), { status });
8
+ const tokenHash = token => createHash('sha256').update(token).digest('hex');
9
+ const normalizeEmail = value => {
10
+ if (typeof value !== 'string') throw error(400, 'Invalid email address');
11
+ const email = value.trim().toLowerCase();
12
+ if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(email)) {
13
+ throw error(400, 'Invalid email address');
14
+ }
15
+ return email;
16
+ };
17
+
18
+ export function openEmailAccounts(pool, configFile, providerOverride) {
19
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
20
+ if (![config.secretId, config.secretKey, config.captchaAppSecretKey, config.fromEmailAddress, config.sesRegion]
21
+ .every(value => typeof value === 'string' && value.length > 0) ||
22
+ !Number.isSafeInteger(config.captchaAppId) || config.captchaAppId < 1 ||
23
+ !Number.isSafeInteger(config.emailTemplateId) || config.emailTemplateId < 1 ||
24
+ typeof config.codePepper !== 'string' || config.codePepper.length < 32) {
25
+ throw new Error('Incomplete relay account configuration');
26
+ }
27
+ const provider = providerOverride || tencentAccountProvider(config);
28
+ const codeHash = (email, code) => createHmac('sha256', config.codePepper).update(`${email}\0${code}`).digest('hex');
29
+ const scopeHash = value => createHmac('sha256', config.codePepper).update(value).digest('hex');
30
+
31
+ async function requestCode({ email: rawEmail, ticket, randstr }, ip) {
32
+ const email = normalizeEmail(rawEmail);
33
+ if (typeof ticket !== 'string' || !ticket || ticket.length > 2048 ||
34
+ typeof randstr !== 'string' || !randstr || randstr.length > 128) throw error(400, 'Invalid human verification');
35
+ if (!ip || ip.length > 64) throw error(400, 'Invalid client address');
36
+ // A cheap pre-check precedes the billable human verification.
37
+ const [recent] = await pool.execute('SELECT sent_at FROM email_login_challenges WHERE email = ? AND sent_at > UTC_TIMESTAMP(3) - INTERVAL 60 SECOND', [email]);
38
+ if (recent.length) throw error(429, 'Please wait before requesting another code');
39
+ const [ipLimit] = await pool.execute(`SELECT request_count FROM email_send_limits
40
+ WHERE scope_hash=? AND window_start>=UTC_TIMESTAMP() - INTERVAL 1 HOUR ORDER BY window_start DESC LIMIT 1`, [scopeHash(`ip:${ip}`)]);
41
+ if (ipLimit[0]?.request_count >= 20) throw error(429, 'Too many email requests');
42
+ if (!await provider.verifyHuman(ticket, randstr, ip)) throw error(403, 'Human verification failed');
43
+ const code = String(randomInt(0, 1000000)).padStart(6, '0');
44
+ const hash = codeHash(email, code);
45
+ const windowStart = sqlTime(new Date(Math.floor(Date.now() / 3600000) * 3600000).toISOString()).slice(0, 19);
46
+ const db = await pool.getConnection();
47
+ try {
48
+ await db.beginTransaction();
49
+ for (const [scope, limit] of [[`email:${email}`, 5], [`ip:${ip}`, 20]]) {
50
+ const key = scopeHash(scope);
51
+ await db.execute('INSERT INTO email_send_limits (scope_hash,window_start,request_count) VALUES (?,?,1) ON DUPLICATE KEY UPDATE request_count=request_count+1', [key, windowStart]);
52
+ const [rows] = await db.execute('SELECT request_count FROM email_send_limits WHERE scope_hash=? AND window_start=? FOR UPDATE', [key, windowStart]);
53
+ if (rows[0].request_count > limit) throw error(429, 'Too many email requests');
54
+ }
55
+ const [existing] = await db.execute('SELECT sent_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
56
+ if (existing.length && Date.now() - new Date(`${existing[0].sent_at.replace(' ', 'T')}Z`).getTime() < 60000) {
57
+ throw error(429, 'Please wait before requesting another code');
58
+ }
59
+ await db.execute(`INSERT INTO email_login_challenges (email,code_hash,expires_at,attempts,sent_at)
60
+ VALUES (?, ?, UTC_TIMESTAMP(3) + INTERVAL 10 MINUTE, 0, UTC_TIMESTAMP(3))
61
+ ON DUPLICATE KEY UPDATE code_hash=VALUES(code_hash),expires_at=VALUES(expires_at),attempts=0,sent_at=VALUES(sent_at)`, [email, hash]);
62
+ await db.commit();
63
+ } catch (failure) { await db.rollback(); throw failure; }
64
+ finally { db.release(); }
65
+ try { await provider.sendLoginCode(email, code); }
66
+ catch (failure) {
67
+ await pool.execute('DELETE FROM email_login_challenges WHERE email=? AND code_hash=?', [email, hash]);
68
+ throw error(503, 'Email delivery unavailable');
69
+ }
70
+ return { sent: true, expiresInSeconds: 600 };
71
+ }
72
+
73
+ async function verifyCode({ email: rawEmail, code }) {
74
+ const email = normalizeEmail(rawEmail);
75
+ if (typeof code !== 'string' || !/^\d{6}$/.test(code)) throw error(400, 'Invalid code');
76
+ const db = await pool.getConnection();
77
+ try {
78
+ await db.beginTransaction();
79
+ const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
80
+ const challenge = challenges[0];
81
+ if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
82
+ const supplied = Buffer.from(codeHash(email, code), 'hex');
83
+ if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
84
+ if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
85
+ else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
86
+ await db.commit();
87
+ throw error(401, 'Code expired or incorrect');
88
+ }
89
+ await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
90
+ const [identities] = await db.execute(`SELECT u.id,u.status FROM user_login_identities i
91
+ JOIN users u ON u.id=i.user_id WHERE i.kind='email' AND i.normalized_value=?`, [email]);
92
+ let userId = identities[0]?.id;
93
+ if (identities[0]?.status === 'disabled') throw error(403, 'Account unavailable');
94
+ if (!userId) {
95
+ userId = randomUUID();
96
+ await db.execute('INSERT INTO users (id,status,created_at,updated_at) VALUES (?,\'active\',UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))', [userId]);
97
+ await db.execute(`INSERT INTO user_login_identities (user_id,kind,normalized_value,verified_at,created_at)
98
+ VALUES (?,'email',?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))`, [userId, email]);
99
+ }
100
+ const token = randomBytes(32).toString('base64url');
101
+ await db.execute(`INSERT INTO user_sessions (token_hash,user_id,created_at,expires_at)
102
+ VALUES (?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3) + INTERVAL 30 DAY)`, [tokenHash(token), userId]);
103
+ await db.commit();
104
+ return { token, user: { id: userId, email }, expiresInSeconds: 30 * 86400 };
105
+ } catch (failure) { await db.rollback(); throw failure; }
106
+ finally { db.release(); }
107
+ }
108
+
109
+ async function current(token) {
110
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw error(401, 'Unauthorized');
111
+ const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,s.expires_at FROM user_sessions s
112
+ JOIN users u ON u.id=s.user_id JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
113
+ WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(3) AND u.status='active'`, [tokenHash(token)]);
114
+ if (!rows.length) throw error(401, 'Unauthorized');
115
+ return { id: rows[0].id, email: rows[0].email, expiresAt: isoTime(rows[0].expires_at) };
116
+ }
117
+
118
+ async function logout(token) {
119
+ await current(token);
120
+ await pool.execute('UPDATE user_sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE token_hash=? AND revoked_at IS NULL', [tokenHash(token)]);
121
+ return { ok: true };
122
+ }
123
+
124
+ return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, logout };
125
+ }