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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +53 -64
  2. package/dist/bin/codex-remote.mjs +90 -71
  3. package/dist/bin/self-relay-admin.mjs +8 -0
  4. package/dist/bin/self-relay-demo.mjs +1 -1
  5. package/dist/bin/self-relay-server.mjs +5 -2
  6. package/dist/shared/app-server-events.cjs +64 -24
  7. package/dist/shared/file-mentions.cjs +35 -0
  8. package/dist/shared/p2p-data-channel.cjs +50 -0
  9. package/dist/skills/code-relax/SKILL.md +1 -1
  10. package/dist/src/desktop-launcher.mjs +16 -21
  11. package/dist/src/file-upload-session.mjs +124 -0
  12. package/dist/src/lan-socket-server.mjs +59 -10
  13. package/dist/src/managed-app-server.mjs +36 -14
  14. package/dist/src/message-images.mjs +0 -18
  15. package/dist/src/onboarding.mjs +6 -8
  16. package/dist/src/platform/README.md +1 -1
  17. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  18. package/dist/src/platform/windows/background-process.mjs +7 -2
  19. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  20. package/dist/src/platform/windows/desktop-host.mjs +17 -3
  21. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  22. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
  23. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  24. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  25. package/dist/src/platform/windows/service-host.mjs +4 -16
  26. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  27. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  28. package/dist/src/runtime-discovery.mjs +43 -0
  29. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  30. package/dist/src/self-relay/admin-page.mjs +71 -0
  31. package/dist/src/self-relay/admin-server.mjs +61 -0
  32. package/dist/src/self-relay/admin-state.mjs +122 -0
  33. package/dist/src/self-relay/client.mjs +2 -0
  34. package/dist/src/self-relay/connector.mjs +107 -20
  35. package/dist/src/self-relay/demo.mjs +4 -5
  36. package/dist/src/self-relay/lifecycle.mjs +58 -70
  37. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  38. package/dist/src/self-relay/server.mjs +224 -29
  39. package/dist/src/server.mjs +616 -1299
  40. package/dist/src/service-doctor.mjs +14 -26
  41. package/dist/src/service-install.mjs +1 -4
  42. package/dist/src/service-lifecycle.mjs +12 -12
  43. package/dist/src/shared-app-server.mjs +206 -46
  44. package/dist/src/shared-catalog.mjs +5 -25
  45. package/dist/src/shared-recovery.mjs +11 -9
  46. package/dist/src/shared-thread-stream.mjs +1 -1
  47. package/dist/src/skill-install.mjs +0 -13
  48. package/package.json +16 -18
  49. package/tools/postinstall.mjs +140 -3
  50. package/dist/src/app-server-client.mjs +0 -468
  51. package/dist/src/app-server-tasks.mjs +0 -355
  52. package/dist/src/debug-ui.mjs +0 -131
  53. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  54. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  55. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  56. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  57. package/dist/src/thread-catalog.mjs +0 -42
  58. package/dist/tools/find-desktop-pipe.mjs +0 -10
  59. package/dist/web/approval-ui.js +0 -188
  60. package/dist/web/capabilities.js +0 -57
  61. package/dist/web/chat-transport.js +0 -196
  62. package/dist/web/chat.css +0 -736
  63. package/dist/web/chat.js +0 -5149
  64. package/dist/web/client-platform.js +0 -11
  65. package/dist/web/harmony-platform.js +0 -51
  66. package/dist/web/highlight-powershell.min.js +0 -39
  67. package/dist/web/highlight.min.js +0 -1262
  68. package/dist/web/history-cache.js +0 -146
  69. package/dist/web/index.html +0 -222
  70. package/dist/web/lan-session.js +0 -122
  71. package/dist/web/p2p-diagnostics.js +0 -28
  72. package/dist/web/p2p-probe.js +0 -83
  73. package/dist/web/resources.json +0 -1
  74. package/dist/web/self-relay-session.js +0 -119
  75. package/dist/web/server-requests.js +0 -67
  76. package/dist/web/timeline-sync.js +0 -67
  77. package/dist/web/vendor/README.md +0 -14
  78. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  79. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  80. package/dist/web/vendor/highlight.min.js +0 -1262
@@ -0,0 +1,35 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ else root.CodexFileMentions = api;
5
+ })(globalThis, function () {
6
+ const heading = '# Files mentioned by the user:';
7
+ const request = '## My request:';
8
+
9
+ function parse(value) {
10
+ const source = typeof value === 'string' ? value : '';
11
+ if (!source.startsWith(heading)) return { text: source, files: [] };
12
+ const marker = source.indexOf(`\n${request}`, heading.length);
13
+ if (marker < 0) return { text: source, files: [] };
14
+ const files = [];
15
+ for (const line of source.slice(heading.length, marker).split(/\r?\n/)) {
16
+ const match = /^## (.+): ((?:[A-Za-z]:[\\/]|\/)[^\r\n]+)$/.exec(line);
17
+ if (!match) continue;
18
+ let name = match[1];
19
+ if (name.startsWith('"')) {
20
+ try { name = JSON.parse(name); } catch { continue; }
21
+ }
22
+ if (typeof name !== 'string' || !name) continue;
23
+ files.push({ type: 'file', name, path: match[2] });
24
+ }
25
+ if (!files.length) return { text: source, files: [] };
26
+ return { text: source.slice(marker + request.length + 1).trim(), files };
27
+ }
28
+
29
+ function wrap(prompt, files) {
30
+ if (!files?.length) return prompt;
31
+ return `${heading}\n${files.map(file => `\n## ${JSON.stringify(file.name)}: ${file.path}\n`).join('')}\n${request}\n${prompt || '请查看上述文件。'}`;
32
+ }
33
+
34
+ return Object.freeze({ parse, wrap });
35
+ });
@@ -0,0 +1,50 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodeRelaxP2pDataFramer = factory();
4
+ })(globalThis, () => {
5
+ const CHUNK_CHARS = 12000;
6
+ const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
7
+ const MAX_PARTS = 1024;
8
+ const byteLength = value => new TextEncoder().encode(value).length;
9
+
10
+ return class {
11
+ constructor() { this.inbound = undefined; }
12
+ create(payload, id) {
13
+ if (typeof payload !== 'string' || !/^[a-f0-9]{32}-[0-9]{1,10}$/.test(id)) throw new Error('Invalid P2P payload');
14
+ const bytes = byteLength(payload);
15
+ if (bytes > MAX_MESSAGE_BYTES) throw new Error('Code Relax 直连消息超过 8 MiB');
16
+ const total = Math.max(1, Math.ceil(payload.length / CHUNK_CHARS));
17
+ if (total > MAX_PARTS) throw new Error('Code Relax 直连消息分片过多');
18
+ return { id, payload, bytes, index: 0, total };
19
+ }
20
+ frame(item) {
21
+ if (!item || item.index >= item.total) return undefined;
22
+ const start = item.index * CHUNK_CHARS;
23
+ return JSON.stringify({ type: 'data', id: item.id, index: item.index, total: item.total,
24
+ payload: item.payload.slice(start, start + CHUNK_CHARS) });
25
+ }
26
+ commit(item) { item.index++; return item.index >= item.total; }
27
+ accept(message) {
28
+ if (!message || message.type !== 'data') return { handled: false };
29
+ if (!/^[a-f0-9]{32}-[0-9]{1,10}$/.test(message.id || '') || !Number.isSafeInteger(message.index) ||
30
+ !Number.isSafeInteger(message.total) || message.total < 1 || message.total > MAX_PARTS ||
31
+ message.index < 0 || message.index >= message.total || typeof message.payload !== 'string' ||
32
+ message.payload.length > CHUNK_CHARS) throw new Error('Invalid P2P data frame');
33
+ if (!this.inbound) {
34
+ if (message.index !== 0) throw new Error('Invalid P2P data order');
35
+ this.inbound = { id: message.id, total: message.total, next: 0, bytes: 0, parts: [] };
36
+ }
37
+ const current = this.inbound;
38
+ if (current.id !== message.id || current.total !== message.total || current.next !== message.index) {
39
+ throw new Error('Invalid P2P data order');
40
+ }
41
+ current.bytes += byteLength(message.payload);
42
+ if (current.bytes > MAX_MESSAGE_BYTES) throw new Error('P2P data message too large');
43
+ current.parts.push(message.payload); current.next++;
44
+ if (current.next !== current.total) return { handled: true, complete: false };
45
+ this.inbound = undefined;
46
+ return { handled: true, complete: true, payload: current.parts.join('') };
47
+ }
48
+ reset() { this.inbound = undefined; }
49
+ };
50
+ });
@@ -5,7 +5,7 @@ description: 启动、检查或停止 Code Relax 工作站服务,生成并展
5
5
 
6
6
  # Code Relax
7
7
 
8
- - Bridge 可独立于 Desktop 启动;在 Desktop 任务中执行时保留注入环境,用于基础控制绑定上下文。启动工作站服务用 `relax start --json`;完整控制用 `relax shared-start --json`。已配置 Code Relax 中继时,启动命令同时恢复中继连接器和已有设备授权。
8
+ - `relax start --json` 启动 Bridge 和受管 Codex 内核,无需打开 Desktop;支持纯 CLI 安装。Desktop 正常打开后接入已配置内核;需要重新接入时使用 App 的“重启 Codex Desktop”或经确认的 `relax shared-recover --confirm`。已配置 Code Relax 中继时,启动命令同时恢复中继连接器和已有设备授权。
9
9
  - npm 全局安装会自动执行服务部署和 Skill 安装;只有修复安装时才单独运行 `relax setup --json` 或 `relax skill-install --json`。
10
10
  - `setup` 默认启用当前用户登录启动工作站服务,不自动打开 Codex;用 `autostart-status / autostart-disable / autostart-enable` 管理。恢复完整控制会保留 Bridge 和中继,但仍须明确确认可能中断全部完整控制任务。
11
11
  - 用户要求启动 Code Relax 时,执行一次 `relax start --json`。用户要求二维码或当前启动结果包含二维码时,展示 CLI 返回的 `qrPath` 和 `displayText`;没有设备授权时可用 `relax pair --json` 生成新的配对二维码。
@@ -1,14 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
- import { readRecord, writeRecord } from './service-lifecycle.mjs';
4
+ import { readRecord } from './service-lifecycle.mjs';
5
5
  import { ManagedAppServer, managedAppServerUrl as sharedUrl, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
6
6
  export { portAvailable };
7
- export async function launchDesktop(mode, { stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
8
- if (!['shared', 'ordinary'].includes(mode)) throw new Error('无效的 Desktop 启动模式。');
7
+ export async function launchDesktop({ stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
9
8
  fs.mkdirSync(stateRoot, { recursive: true });
10
9
  const recovery = readRecord(path.join(stateRoot, 'shared-recovery.lock'));
11
- if (recovery && recovery.operationId !== recoveryId) throw new Error('完整控制正在启动,请先查看 shared-recovery-status。');
10
+ if (recovery && recovery.operationId !== recoveryId) throw new Error('Codex 内核正在启动,请先查看 shared-recovery-status。');
12
11
  const lock = path.join(stateRoot, 'desktop-launch.lock');
13
12
  let descriptor;
14
13
  let managed;
@@ -18,34 +17,30 @@ export async function launchDesktop(mode, { stateRoot, host, environment = proce
18
17
  throw error;
19
18
  }
20
19
  try {
21
- const info = await host.inspect();
20
+ const info = await host.inspect();
21
+ if (!info.desktop) throw new Error('未安装 Codex Desktop,请使用 relax start 启动工作站。');
22
22
  const connected = await host.sharedServiceUrl();
23
23
  if (info.running) {
24
- if ((mode === 'shared') === Boolean(connected)) {
25
- writeRecord(path.join(stateRoot, 'execution-mode.json'), { mode });
26
- return { reused: true, mode };
27
- }
28
- throw new Error('请先保存任务并完全退出 Codex Desktop,再切换模式;不会自动结束正在运行的任务。');
24
+ if (connected) return { reused: true };
25
+ throw new Error('请先保存任务并完全退出 Codex Desktop,再接入 Codex 内核;不会自动结束正在运行的任务。');
29
26
  }
30
27
  const childEnv = { ...environment };
31
28
  for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
32
- if (mode === 'shared') {
33
- progress('starting-app-server');
34
- managed = new ManagedAppServer({ stateRoot, host, environment, available, wait, timeout });
35
- if (!await managed.start()) throw new Error(managed.snapshot().error || '受管 app-server 未就绪。');
36
- childEnv.CODEX_APP_SERVER_WS_URL = sharedUrl;
37
- }
38
- // Ordinary Desktop must not terminate a shared server that may still be executing tasks.
29
+ progress('starting-app-server');
30
+ managed = new ManagedAppServer({ stateRoot, host, environment, available, wait, timeout });
31
+ if (!await managed.start()) throw new Error(managed.snapshot().error || '受管 app-server 未就绪。');
32
+ const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
33
+ if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
34
+ childEnv.CODEX_APP_SERVER_WS_URL = sharedUrl;
39
35
  progress('starting-desktop');
40
36
  await host.start(info.desktop, [], childEnv, []);
41
37
  const deadline = Date.now() + timeout;
42
38
  while (true) {
43
- const ready = mode === 'shared' ? await host.sharedServiceUrl() : (await host.inspect()).running;
39
+ const ready = await host.sharedServiceUrl();
44
40
  if (ready) break;
45
- if (Date.now() >= deadline) throw new Error('Desktop 尚未按所选模式就绪,未启动 Bridge。请查看 Desktop 窗口后重试。');
41
+ if (Date.now() >= deadline) throw new Error('Desktop 尚未接入 Codex 内核。请查看 Desktop 窗口后重试。');
46
42
  await wait(500);
47
43
  }
48
- writeRecord(path.join(stateRoot, 'execution-mode.json'), { mode });
49
- return { reused: false, mode };
44
+ return { reused: false };
50
45
  } finally { managed?.close(); fs.closeSync(descriptor); fs.rmSync(lock, { force: true }); }
51
46
  }
@@ -0,0 +1,124 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const MAX_FILE_BYTES = 20 * 1024 * 1024;
6
+ export const MAX_FILE_COUNT = 4;
7
+ export const MAX_TOTAL_BYTES = 40 * 1024 * 1024;
8
+ export const FILE_CHUNK_BYTES = 256 * 1024;
9
+
10
+ function validName(value) {
11
+ return typeof value === 'string' && value.length > 0 && value.length <= 180 &&
12
+ value !== '.' && value !== '..' && !/[\\/:*?"<>|\x00-\x1f]/.test(value) && !/[. ]$/.test(value) &&
13
+ !/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(value);
14
+ }
15
+
16
+ async function uniqueDestination(directory, name, partial) {
17
+ const dot = name.lastIndexOf('.');
18
+ const stem = dot > 0 ? name.slice(0, dot) : name;
19
+ const extension = dot > 0 ? name.slice(dot) : '';
20
+ for (let index = 0; ; index++) {
21
+ const suffix = index ? ` (${index})` : '';
22
+ const candidate = index ? `${stem.slice(0, 180 - extension.length - suffix.length)}${suffix}${extension}` : name;
23
+ if (!candidate || candidate.length > 180) throw new Error('同名文件过多,文件名无法继续编号');
24
+ const destination = path.join(directory, candidate);
25
+ try {
26
+ await fs.link(partial, destination);
27
+ await fs.unlink(partial);
28
+ return { name: candidate, path: destination };
29
+ } catch (error) {
30
+ if (error.code !== 'EEXIST') throw error;
31
+ }
32
+ }
33
+ }
34
+
35
+ export function createFileUploadSession(root) {
36
+ let upload;
37
+ const completed = new Map();
38
+ let completedBytes = 0;
39
+ const directory = path.join(root, 'incoming');
40
+ const clearActive = async () => {
41
+ const current = upload;
42
+ upload = undefined;
43
+ if (!current) return;
44
+ await current.handle.close().catch(() => {});
45
+ await fs.unlink(current.partial).catch(() => {});
46
+ };
47
+ const clear = async () => {
48
+ await clearActive();
49
+ for (const file of completed.values()) await fs.unlink(file.path).catch(() => {});
50
+ completed.clear(); completedBytes = 0;
51
+ };
52
+ return {
53
+ async receive(message) {
54
+ if (!message || typeof message !== 'object') throw new Error('无效的文件上传请求');
55
+ if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
56
+ if (message.type === 'commit') {
57
+ if (upload) throw new Error('文件尚未上传完成');
58
+ completed.clear(); completedBytes = 0;
59
+ return { type: 'committed' };
60
+ }
61
+ if (message.type === 'remove') {
62
+ if (upload) throw new Error('请等待当前文件上传完成');
63
+ const file = completed.get(message.id);
64
+ if (!file) throw new Error('文件上传会话已失效');
65
+ await fs.unlink(file.path);
66
+ completed.delete(message.id); completedBytes -= file.size;
67
+ return { type: 'removed', id: message.id };
68
+ }
69
+ if (message.type === 'start') {
70
+ if (upload) throw new Error('已有文件正在上传');
71
+ if (!validName(message.name)) throw new Error('文件名无效');
72
+ if (!Number.isSafeInteger(message.size) || message.size < 1 || message.size > MAX_FILE_BYTES) {
73
+ throw new Error('单个文件不得超过 20 MiB');
74
+ }
75
+ if (completed.size >= MAX_FILE_COUNT || completedBytes + message.size > MAX_TOTAL_BYTES) {
76
+ throw new Error('每条消息最多 4 个文件,合计不超过 40 MiB');
77
+ }
78
+ if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
79
+ const id = crypto.randomUUID();
80
+ await fs.mkdir(directory, { recursive: true });
81
+ const partial = path.join(directory, `.upload-${id}`);
82
+ const handle = await fs.open(partial, 'wx');
83
+ upload = { id, partial, handle, name: message.name, size: message.size,
84
+ expected: message.sha256.toLowerCase(), hash: crypto.createHash('sha256'), offset: 0 };
85
+ return { type: 'started', id, offset: 0, chunkBytes: FILE_CHUNK_BYTES };
86
+ }
87
+ const current = upload;
88
+ if (!current || message.id !== current.id) throw new Error('文件上传会话已失效');
89
+ if (message.type === 'chunk') {
90
+ if (!Number.isSafeInteger(message.offset) || message.offset !== current.offset) {
91
+ return { type: 'offset', id: current.id, offset: current.offset };
92
+ }
93
+ if (typeof message.data !== 'string' || message.data.length > Math.ceil(FILE_CHUNK_BYTES / 3) * 4 + 4 ||
94
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(message.data)) {
95
+ throw new Error('文件分片格式无效');
96
+ }
97
+ const bytes = Buffer.from(message.data, 'base64');
98
+ if (!bytes.length || bytes.length > FILE_CHUNK_BYTES || current.offset + bytes.length > current.size) {
99
+ throw new Error('文件分片大小无效');
100
+ }
101
+ await current.handle.writeFile(bytes);
102
+ current.hash.update(bytes);
103
+ current.offset += bytes.length;
104
+ return { type: 'offset', id: current.id, offset: current.offset };
105
+ }
106
+ if (message.type === 'finish') {
107
+ if (current.offset !== current.size || current.hash.digest('hex') !== current.expected) {
108
+ await clear(); throw new Error('文件大小或 SHA-256 校验失败');
109
+ }
110
+ await current.handle.sync();
111
+ await current.handle.close();
112
+ upload = undefined;
113
+ let saved;
114
+ try { saved = await uniqueDestination(directory, current.name, current.partial); }
115
+ catch (error) { await fs.unlink(current.partial).catch(() => {}); throw error; }
116
+ const result = { type: 'completed', id: current.id, ...saved, size: current.size };
117
+ completed.set(current.id, result); completedBytes += current.size;
118
+ return result;
119
+ }
120
+ throw new Error('不支持的文件上传操作');
121
+ },
122
+ close: clear,
123
+ };
124
+ }
@@ -1,24 +1,70 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import crypto from 'node:crypto';
3
- import { WebSocketServer, WebSocket } from 'ws';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
4
5
 
5
6
  // One authenticated connection carries requests and explicitly owned subscriptions.
6
7
  // The dispatcher is shared business routing, not an HTTP loopback proxy.
7
- export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false }) {
8
+ export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, uploadsRoot }) {
8
9
  // Allow four individually valid images to reach the aggregate-size validator,
9
10
  // so an oversized composition returns an error instead of dropping the connection.
10
11
  const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
11
- server.on('upgrade', (request, socket, head) => {
12
+ const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
13
+ const activeUploads = new Set();
14
+ server.on('upgrade', (request, socket, head) => {
12
15
  const origin = request.headers.origin;
13
- let allowed = request.url === '/api/socket';
16
+ const upload = request.url === '/api/file-transfer' && Boolean(uploadsRoot);
17
+ let allowed = request.url === '/api/socket' || upload;
14
18
  // Packaged ArkWeb pages have an opaque ("null") origin; device authentication remains required.
15
19
  if (origin && origin !== 'null') {
16
- try { allowed &&= new URL(origin).host === request.headers.host && ['http:', 'https:'].includes(new URL(origin).protocol); }
17
- catch { allowed = false; }
20
+ try {
21
+ const originUrl = new URL(origin);
22
+ const requestUrl = new URL(`http://${request.headers.host}`);
23
+ const nativeClient = request.headers['x-code-relax-native'] === '1';
24
+ allowed &&= ['http:', 'https:'].includes(originUrl.protocol) &&
25
+ (originUrl.host === request.headers.host || (nativeClient && originUrl.hostname === requestUrl.hostname));
26
+ }
27
+ catch { allowed = false; }
18
28
  }
19
29
  if (!allowed) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
20
- wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
21
- });
30
+ const target = upload ? uploadWss : wss;
31
+ target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
32
+ });
33
+ uploadWss.on('connection', ws => {
34
+ let authenticated = false, identity = '', upload;
35
+ let pending = Promise.resolve();
36
+ const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
37
+ const reply = value => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
38
+ ws.on('error', () => {});
39
+ ws.on('close', () => {
40
+ clearTimeout(authTimer);
41
+ void pending.finally(async () => {
42
+ await upload?.close();
43
+ if (identity) activeUploads.delete(identity);
44
+ });
45
+ });
46
+ ws.on('message', (bytes, binary) => {
47
+ pending = pending.then(async () => {
48
+ if (binary || bytes.length > 512 * 1024) throw new Error('无效的上传分片');
49
+ const message = JSON.parse(bytes.toString());
50
+ if (!authenticated) {
51
+ if (message?.type === 'device-auth' && typeof message.clientId === 'string' &&
52
+ authorizeDevice(message.clientId, message.credential) === true) identity = `device:${message.clientId}`;
53
+ else if (token && message?.type === 'auth' && typeof message.token === 'string') {
54
+ const supplied = Buffer.from(message.token), expected = Buffer.from(token || '');
55
+ if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) identity = 'local-token';
56
+ }
57
+ if (!identity || activeUploads.has(identity) || activeUploads.size >= 2) throw new Error('上传身份无效或工作站已有上传任务');
58
+ activeUploads.add(identity); authenticated = true; clearTimeout(authTimer);
59
+ upload = createFileUploadSession(uploadsRoot);
60
+ reply({ type: 'ready', maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
61
+ maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES });
62
+ return;
63
+ }
64
+ reply(await upload.receive(message));
65
+ }).catch(error => { reply({ type: 'error', error: error.message }); ws.close(1008, 'Upload rejected'); });
66
+ });
67
+ });
22
68
  wss.on('connection', ws => {
23
69
  let authenticated = false, alive = true;
24
70
  const active = new Map();
@@ -99,5 +145,8 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
99
145
  catch (error) { reply.json(500, { error: error.message }); }
100
146
  });
101
147
  });
102
- return { close() { for (const ws of wss.clients) ws.terminate(); wss.close(); } };
103
- }
148
+ return { close() {
149
+ for (const ws of wss.clients) ws.terminate(); wss.close();
150
+ for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
151
+ } };
152
+ }
@@ -5,9 +5,11 @@ import { EventEmitter } from 'node:events';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { readRecord, writeRecord } from './service-lifecycle.mjs';
7
7
  import { SharedAppServer } from './shared-app-server.mjs';
8
+ import { resolveCodexRuntime } from './platform/windows/codex-runtime.mjs';
8
9
 
9
10
  export const managedAppServerUrl = 'ws://127.0.0.1:45839';
10
11
  export const managedAppServerReadyUrl = 'http://127.0.0.1:45839/readyz';
12
+ export const managedAppServerLauncherRevision = 2;
11
13
  const retryDelays = [1_000, 2_000, 5_000, 10_000, 20_000];
12
14
 
13
15
  export function managedPortAvailable() {
@@ -30,7 +32,6 @@ export class ManagedAppServer extends EventEmitter {
30
32
  this.timeout = timeout;
31
33
  this.now = now;
32
34
  this.recordPath = path.join(stateRoot, 'managed-app-server.json');
33
- this.legacyRecordPath = path.join(stateRoot, 'shared-desktop.json');
34
35
  this.client = client;
35
36
  this.state = { state: 'absent', ready: false, error: null };
36
37
  this.restartStatus = null;
@@ -43,7 +44,11 @@ export class ManagedAppServer extends EventEmitter {
43
44
  });
44
45
  }
45
46
 
46
- snapshot() { return { ...this.state, ...(this.restartStatus ? { restart: { ...this.restartStatus } } : {}) }; }
47
+ snapshot() {
48
+ return { ...this.state,
49
+ version: this.state.ready && this.client.ready ? this.client.serverVersion || null : null,
50
+ ...(this.restartStatus ? { restart: { ...this.restartStatus } } : {}) };
51
+ }
47
52
 
48
53
  async start() {
49
54
  this.stopped = false;
@@ -65,36 +70,45 @@ export class ManagedAppServer extends EventEmitter {
65
70
  if (!this.state.ready) this.publish({ state: 'starting', ready: false, error: null });
66
71
  try {
67
72
  fs.mkdirSync(this.stateRoot, { recursive: true });
68
- const info = await this.host.inspect();
69
- if (!info.cli) throw new Error('未找到 Desktop 管理的 codex.exe,请先安装并登录 Codex Desktop。');
70
73
  let record = null, actual = null;
71
- for (const candidate of [readRecord(this.recordPath), readRecord(this.legacyRecordPath)].filter(Boolean)) {
72
- const process = candidate.pid ? await this.host.process(candidate.pid) : null;
73
- if (sameManagedProcess(candidate, process)) { record = candidate; actual = process; break; }
74
- }
74
+ const candidate = readRecord(this.recordPath);
75
+ const process = candidate?.pid ? await this.host.process(candidate.pid) : null;
76
+ if (sameManagedProcess(candidate, process)) { record = candidate; actual = process; }
77
+ const runtime = actual
78
+ ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) || { executable: actual.executable }
79
+ : await resolveCodexRuntime(this.stateRoot, { environment: this.environment });
75
80
  if (!actual) {
76
81
  if (!await this.available()) throw new Error('45839 被未识别进程占用,未启动或结束任何进程。');
77
82
  const childEnvironment = { ...this.environment };
83
+ childEnvironment.CODEX_HOME = runtime.codexHome;
78
84
  for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
79
- const pid = await this.host.start(info.cli, ['app-server', '--listen', managedAppServerUrl], childEnvironment,
85
+ for (const key of Object.keys(childEnvironment)) {
86
+ if (/^npm_/iu.test(key) || key.toUpperCase() === 'INIT_CWD') delete childEnvironment[key];
87
+ }
88
+ const pid = await this.host.start(runtime.executable, ['app-server', '--listen', managedAppServerUrl], childEnvironment,
80
89
  [path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
81
90
  const started = await this.host.process(pid);
82
91
  if (!sameManagedProcess(started, started)) throw new Error('Codex 内核启动后进程身份无法确认。');
83
- record = actual = started;
92
+ actual = started;
93
+ record = { ...started, launcherRevision: managedAppServerLauncherRevision };
84
94
  writeRecord(this.recordPath, record);
85
95
  } else if (!readRecord(this.recordPath)) {
86
- writeRecord(this.recordPath, actual);
96
+ record = { ...actual, launcherRevision: Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0 };
97
+ writeRecord(this.recordPath, record);
87
98
  }
88
99
  await this.waitUntilReady(actual);
89
100
  await this.client.connect();
90
101
  if (!this.client.ready) throw new Error('Codex 内核协议初始化未完成。');
91
- const preferredExecutable = path.resolve(info.cli);
102
+ const preferredExecutable = path.resolve(runtime.executable);
92
103
  const updatePending = path.resolve(actual.executable).toLowerCase() !== preferredExecutable.toLowerCase();
104
+ const launcherRevision = Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0;
93
105
  this.retryAttempt = 0;
94
106
  this.retryAfter = 0;
95
107
  this.publish({ state: 'ready', ready: true, error: null, pid: actual.pid,
96
108
  created: actual.created, executable: actual.executable, preferredExecutable,
97
- updatePending, url: managedAppServerUrl });
109
+ updatePending, launcherRevision, selectedVersion: runtime.version, source: runtime.source,
110
+ launcherRestartRequired: launcherRevision !== managedAppServerLauncherRevision,
111
+ url: managedAppServerUrl });
98
112
  return true;
99
113
  } catch (error) {
100
114
  this.failed(error instanceof Error ? error.message : String(error));
@@ -133,12 +147,16 @@ export class ManagedAppServer extends EventEmitter {
133
147
  const previous = this.state;
134
148
  this.publish({ ...previous, state: 'restarting', ready: false, error: null });
135
149
  try {
136
- const record = readRecord(this.recordPath) || readRecord(this.legacyRecordPath);
150
+ const record = readRecord(this.recordPath);
137
151
  const actual = record?.pid ? await this.host.process(record.pid) : null;
138
152
  if (actual && !sameManagedProcess(record, actual)) {
139
153
  throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
140
154
  }
155
+ if (!actual && !await this.available()) throw new Error('45839 被未识别进程占用,未结束任何进程。');
141
156
  this.restartStage('stopping');
157
+ // Resolve the replacement before stopping the current working process.
158
+ const runtime = await resolveCodexRuntime(this.stateRoot, { refresh: true, environment: this.environment });
159
+ const desktop = await this.host.prepareKernelRestart?.();
142
160
  this.client.close();
143
161
  if (actual) {
144
162
  await this.host.stop(actual.pid);
@@ -152,6 +170,10 @@ export class ManagedAppServer extends EventEmitter {
152
170
  this.restartStage('starting');
153
171
  const ready = await this.ensureNow();
154
172
  if (!ready) throw new Error(this.state.error || 'Codex 内核未能重新启动。');
173
+ const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL: managedAppServerUrl };
174
+ delete desktopEnvironment.CODEX_APP_SERVER_FORCE_CLI;
175
+ delete desktopEnvironment.CODEX_APP_TOOLS_PIPE_PATH;
176
+ await this.host.resumeAfterKernelRestart?.(desktop, desktopEnvironment);
155
177
  this.restartStatus = { ...this.restartStatus, status: 'completed', stage: 'ready',
156
178
  completedAt: new Date(this.now()).toISOString() };
157
179
  this.emit('state', this.snapshot());
@@ -1,8 +1,3 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import crypto from 'node:crypto';
4
- import sharp from 'sharp';
5
-
6
1
  export function validateInlineImage(value) {
7
2
  const data = typeof value === 'string' ? value.slice(value.indexOf(',') + 1) : '';
8
3
  if (typeof value !== 'string' || value.length > Math.ceil(8 * 1024 * 1024 / 3) * 4 + 64 ||
@@ -12,16 +7,3 @@ export function validateInlineImage(value) {
12
7
  }
13
8
  return value;
14
9
  }
15
-
16
- // Only the Desktop tool compatibility path needs a file visible to the host.
17
- export async function persistLegacyImage(value, directory) {
18
- validateInlineImage(value);
19
- const input = Buffer.from(value.slice(value.indexOf(',') + 1), 'base64');
20
- const buffer = await sharp(input, { limitInputPixels: 40_000_000 }).rotate()
21
- .resize({ width: 2560, height: 2560, fit: 'inside', withoutEnlargement: true }).webp({ quality: 88 }).toBuffer();
22
- const id = crypto.createHash('sha256').update(buffer).digest('hex');
23
- await fs.mkdir(directory, { recursive: true });
24
- const file = path.join(directory, `${id}.webp`);
25
- await fs.writeFile(file, buffer);
26
- return file;
27
- }
@@ -1,17 +1,17 @@
1
1
  export const connectionModesText = [
2
2
  '可选连接模式:',
3
3
  '• 局域网:启动时会把当前 Wi-Fi/有线网络候选临时发给已配对设备;App 验证后保存,也可手动输入 IP。',
4
- '• 私有中继:同一设备授权可跨网络长期重连;基础控制/完整控制能力由电脑当前 Bridge 状态决定。',
4
+ '• 私有中继:同一设备授权可跨网络长期重连;Codex 内核就绪后才能进入聊天。',
5
5
  '• OpenAI 官方中继:在已配对电脑内使用官方账号和设备授权;认证仍直连 OpenAI。',
6
6
  ].join('\n');
7
7
 
8
8
  export function phonePairingSteps(transportMode, paired = false) {
9
- const mode = transportMode === 'lan-shared' ? '局域网 · 完整控制' : transportMode === 'lan-legacy' ? '局域网 · 基础控制' : '局域网(按电脑实际控制方式选择)';
9
+ const mode = transportMode === 'lan-shared' ? '局域网 · Codex 内核已就绪' : '局域网(Codex 内核未就绪)';
10
10
  return paired ? [
11
- `已有移动设备授权。手机可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
11
+ `已有移动设备授权。移动端可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
12
12
  '添加另一台设备时执行 relax pair;每台设备只需扫码配对一次。',
13
13
  ] : [
14
- '手机先安装 Code Relax。当前为鸿蒙 HarmonyOS 7 私测版,调试 HAP 需通过 DevEco/调试设备安装,尚无商店下载入口。',
14
+ '请先在移动端安装 Code Relax。当前为鸿蒙 HarmonyOS 7 私测版,调试 HAP 需通过 DevEco/调试设备安装,尚无商店下载入口。',
15
15
  '使用 App 扫描配对二维码;每台移动设备只需配对一次。',
16
16
  `配对后可直接使用私有中继;${mode} 会接收并验证当前网络候选,也可手动输入 IP。`,
17
17
  ];
@@ -19,13 +19,11 @@ export function phonePairingSteps(transportMode, paired = false) {
19
19
  export const desktopSetupSteps = [
20
20
  'Code Relax Skill 已随 npm 包自动安装。',
21
21
  '安装完成后 Bridge 和受管 app-server 已启动,并默认在当前用户登录时启动。',
22
- '如果安装时 Codex 仍在运行,手机完整控制仍可使用;可选择立即或稍后重启,让 Desktop 也接入同一受管服务。',
23
- '基础控制首次聊天需要在 Codex 对话启动一次 Code Relax 绑定上下文;完整控制不需要。',
22
+ 'CLI 用户无需安装 Desktop;已安装 Desktop 时,安装器负责将运行中的 Desktop 接入同一内核。',
24
23
  '尚无设备授权时执行 relax pair,打开并扫描配对二维码。',
25
- '若工作站提示 Desktop 尚未接入受管服务,可在手机确认重新打开 Desktop,无需电脑脚本或快捷方式。',
26
24
  '统一配对先建立电脑记录;进入该电脑后可另行完成 OpenAI 官方 Remote 配对。',
27
25
  ];
28
26
 
29
27
  export function pairingDisplayText(nextSteps, notice) {
30
- return ['Bridge 已就绪,请在手机上完成连接。', connectionModesText, ...nextSteps.map((step, index) => `${index + 1}. ${step}`), notice].join('\n\n');
28
+ return ['Bridge 已就绪,请在移动端完成连接。', connectionModesText, ...nextSteps.map((step, index) => `${index + 1}. ${step}`), notice].join('\n\n');
31
29
  }
@@ -3,7 +3,7 @@
3
3
  当前只实现 Windows Codex:
4
4
 
5
5
  - `windows/runtime-paths.mjs`:固定服务、用户状态、Codex 数据、附件和长期凭据路径。
6
- - `windows/service-host.mjs`、`desktop-tools.mjs`:网卡、Desktop 工具管道、共享连接检测和旧状态迁移。
6
+ - `windows/service-host.mjs`:网卡、Bridge 进程环境和旧状态迁移。
7
7
  - `windows/desktop-host.mjs`、PowerShell 脚本:普通/共享 Desktop、快捷方式和隐藏控制台启动。
8
8
  - `windows/credential-store.mjs/.ps1`:私有中继电脑凭据的 DPAPI 与 ACL。
9
9
 
@@ -48,8 +48,8 @@ public static class IsolatedProcess {
48
48
  if(handle==new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error());
49
49
  return handle;
50
50
  }
51
- public static int Start(string executable,string[] arguments,string stdout,string stderr) {
52
- IntPtr input=IntPtr.Zero,output=IntPtr.Zero,error=IntPtr.Zero,list=IntPtr.Zero,handles=IntPtr.Zero;
51
+ public static int Start(string executable,string[] arguments,string stdout,string stderr,string[] environment) {
52
+ IntPtr input=IntPtr.Zero,output=IntPtr.Zero,error=IntPtr.Zero,list=IntPtr.Zero,handles=IntPtr.Zero,environmentBlock=IntPtr.Zero;
53
53
  bool initialized=false;
54
54
  try {
55
55
  input=File("NUL",true); output=File(String.IsNullOrEmpty(stdout)?"NUL":stdout,false); error=File(String.IsNullOrEmpty(stderr)?"NUL":stderr,false);
@@ -63,12 +63,16 @@ public static class IsolatedProcess {
63
63
  startup.startup.flags=0x101; startup.startup.show=0;
64
64
  startup.startup.input=input; startup.startup.output=output; startup.startup.error=error; startup.attributes=list;
65
65
  var command=new StringBuilder(Quote(executable)); foreach(var argument in arguments) command.Append(' ').Append(Quote(argument));
66
+ Array.Sort(environment,StringComparer.OrdinalIgnoreCase);
67
+ var environmentBytes=Encoding.Unicode.GetBytes(String.Join("\0",environment)+"\0\0");
68
+ environmentBlock=Marshal.AllocHGlobal(environmentBytes.Length); Marshal.Copy(environmentBytes,0,environmentBlock,environmentBytes.Length);
66
69
  ProcessInfo info;
67
- if(!CreateProcessW(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x80010,IntPtr.Zero,null,ref startup,out info)) throw new Win32Exception(Marshal.GetLastWin32Error());
70
+ if(!CreateProcessW(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x80410,environmentBlock,null,ref startup,out info)) throw new Win32Exception(Marshal.GetLastWin32Error());
68
71
  CloseHandle(info.thread); CloseHandle(info.process); return info.pid;
69
72
  } finally {
70
73
  if(initialized) DeleteProcThreadAttributeList(list);
71
74
  if(list!=IntPtr.Zero) Marshal.FreeHGlobal(list); if(handles!=IntPtr.Zero) Marshal.FreeHGlobal(handles);
75
+ if(environmentBlock!=IntPtr.Zero) Marshal.FreeHGlobal(environmentBlock);
72
76
  if(input!=IntPtr.Zero) CloseHandle(input); if(output!=IntPtr.Zero) CloseHandle(output); if(error!=IntPtr.Zero) CloseHandle(error);
73
77
  }
74
78
  }
@@ -5,9 +5,14 @@ import path from 'node:path';
5
5
  export function startWindowsProcess(executable, args, environment, logs = []) {
6
6
  return new Promise((resolve, reject) => {
7
7
  const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
8
+ const helperEnvironment = {};
9
+ for (const name of ['SystemRoot', 'windir', 'LOCALAPPDATA', 'USERPROFILE', 'TEMP', 'TMP', 'PSModulePath']) {
10
+ const key = Object.keys(process.env).find(item => item.toLowerCase() === name.toLowerCase());
11
+ if (key) helperEnvironment[key] = process.env[key];
12
+ }
8
13
  const helper = spawn(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
9
14
  fileURLToPath(new URL('./start-hidden-console.ps1', import.meta.url))], {
10
- windowsHide: true, env: environment, stdio: ['pipe', 'pipe', 'pipe'],
15
+ windowsHide: true, env: helperEnvironment, stdio: ['pipe', 'pipe', 'pipe'],
11
16
  });
12
17
  let output = '', error = '';
13
18
  helper.stdout.setEncoding('utf8').on('data', chunk => { output += chunk; });
@@ -20,6 +25,6 @@ export function startWindowsProcess(executable, args, environment, logs = []) {
20
25
  if (!Number.isSafeInteger(pid) || pid <= 0) return reject(new Error('后台启动未返回有效 PID'));
21
26
  resolve(pid);
22
27
  });
23
- helper.stdin.end(JSON.stringify({ executable, args, logs }));
28
+ helper.stdin.end(JSON.stringify({ brokerExecutable: process.execPath, executable, args, logs, environment }));
24
29
  });
25
30
  }