@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.
- package/README.md +44 -54
- package/dist/bin/codex-remote.mjs +34 -60
- package/dist/bin/migrate-relay-db.mjs +104 -0
- package/dist/bin/self-relay-server.mjs +17 -10
- package/dist/shared/app-server-events.cjs +15 -3
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +6 -11
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +51 -8
- package/dist/src/managed-app-server.mjs +21 -10
- package/dist/src/onboarding.mjs +3 -3
- package/dist/src/platform/windows/codex-runtime.mjs +138 -4
- package/dist/src/platform/windows/desktop-host.mjs +1 -2
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
- package/dist/src/platform/windows/resident-start.js +8 -0
- package/dist/src/platform/windows/resident-startup.ps1 +6 -3
- package/dist/src/platform/windows/service-host.mjs +0 -9
- package/dist/src/self-relay/admin-page.mjs +1 -1
- package/dist/src/self-relay/admin-state.mjs +8 -6
- package/dist/src/self-relay/connector.mjs +84 -12
- package/dist/src/self-relay/email-accounts.mjs +125 -0
- package/dist/src/self-relay/lifecycle.mjs +52 -100
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/p2p-probe.mjs +1 -1
- package/dist/src/self-relay/server.mjs +155 -38
- package/dist/src/server.mjs +736 -610
- package/dist/src/service-doctor.mjs +1 -1
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/shared-app-server.mjs +274 -272
- package/dist/src/shared-catalog.mjs +1 -11
- package/dist/src/shared-recovery.mjs +5 -5
- package/dist/src/skill-install.mjs +0 -13
- package/package.json +2 -1
- package/dist/src/debug-ui.mjs +0 -131
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
- package/dist/web/activity-view.js +0 -283
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -50
- package/dist/web/chat-transport.js +0 -196
- package/dist/web/chat.css +0 -778
- package/dist/web/chat.js +0 -3815
- package/dist/web/client-platform.js +0 -11
- package/dist/web/community.css +0 -82
- package/dist/web/community.html +0 -36
- package/dist/web/composer-controller.js +0 -97
- package/dist/web/conversation-controller.js +0 -99
- package/dist/web/disclosure-state-controller.js +0 -95
- package/dist/web/draft-controller.js +0 -103
- package/dist/web/harmony-platform.js +0 -52
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -240
- package/dist/web/history-controller.js +0 -167
- package/dist/web/index.html +0 -288
- package/dist/web/lan-session.js +0 -122
- package/dist/web/link-action-controller.js +0 -212
- package/dist/web/message-send-controller.js +0 -177
- package/dist/web/message-view.js +0 -98
- package/dist/web/p2p-data-channel.js +0 -50
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -123
- package/dist/web/page-resume.js +0 -28
- package/dist/web/pending-message-store.js +0 -108
- package/dist/web/queue-controller.js +0 -82
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -129
- package/dist/web/server-requests.js +0 -67
- package/dist/web/station-connection-controller.js +0 -75
- package/dist/web/task-list-view.js +0 -289
- package/dist/web/thread-attention-controller.js +0 -124
- package/dist/web/thread-context-controller.js +0 -30
- package/dist/web/thread-list-controller.js +0 -61
- package/dist/web/thread-list-sync.js +0 -86
- package/dist/web/thread-title-controller.js +0 -57
- package/dist/web/timeline-formatters.js +0 -249
- package/dist/web/timeline-reducer.js +0 -77
- package/dist/web/timeline-renderer.js +0 -161
- package/dist/web/timeline-scroll-controller.js +0 -50
- package/dist/web/timeline-sync.js +0 -67
- package/dist/web/usage-controller.js +0 -258
- package/dist/web/vendor/README.md +0 -14
- package/dist/web/vendor/highlight-powershell.min.js +0 -39
- package/dist/web/vendor/highlight.LICENSE.txt +0 -29
- package/dist/web/vendor/highlight.min.js +0 -1262
- package/dist/web/vendor/lucide.LICENSE.txt +0 -17
|
@@ -1,67 +1,95 @@
|
|
|
1
|
-
(function (root, factory) {
|
|
2
|
-
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
|
-
else root.CodexServerRequests = factory();
|
|
4
|
-
})(globalThis, function () {
|
|
5
|
-
const command = 'item/commandExecution/requestApproval';
|
|
6
|
-
const file = 'item/fileChange/requestApproval';
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
|
+
else root.CodexServerRequests = factory();
|
|
4
|
+
})(globalThis, function () {
|
|
5
|
+
const command = 'item/commandExecution/requestApproval';
|
|
6
|
+
const file = 'item/fileChange/requestApproval';
|
|
7
7
|
const input = 'item/tool/requestUserInput';
|
|
8
|
+
const elicitation = 'mcpServer/elicitation/request';
|
|
8
9
|
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
10
|
+
function browserAccess(request) {
|
|
11
|
+
const p = request.params || {}, meta = p._meta || {};
|
|
12
|
+
if (request.method !== elicitation || p.mode !== 'form' || meta.tool_name !== 'access_browser_origin'
|
|
13
|
+
|| meta.codex_approval_kind !== 'mcp_tool_call' || meta.codex_request_type !== 'approval_request'
|
|
14
|
+
|| meta.codex_sensitive_action !== true || p.requestedSchema?.type !== 'object'
|
|
15
|
+
|| Object.keys(p.requestedSchema.properties || {}).length) return null;
|
|
16
|
+
try {
|
|
17
|
+
const origin = new URL(meta.origin);
|
|
18
|
+
if (!['http:', 'https:'].includes(origin.protocol) || origin.origin !== meta.origin) return null;
|
|
19
|
+
return { origin: meta.origin, persistent: meta.persist === 'always' };
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
9
22
|
function decisions(request) {
|
|
10
|
-
if (request.method === command) return request.params.availableDecisions ?? ['accept', 'acceptForSession', 'decline', 'cancel'];
|
|
23
|
+
if (request.method === command) return request.params.availableDecisions ?? ['accept', 'acceptForSession', 'decline', 'cancel'];
|
|
11
24
|
if (request.method === file) return ['accept', 'acceptForSession', 'decline', 'cancel'];
|
|
25
|
+
const browser = browserAccess(request);
|
|
26
|
+
if (browser) return browser.persistent ? ['allowOnce', 'allowSite', 'allowAllSites', 'decline'] : ['allowOnce', 'decline'];
|
|
12
27
|
return [];
|
|
13
|
-
}
|
|
14
|
-
function validate(request, result) {
|
|
15
|
-
if ([command, file].includes(request.method)) {
|
|
16
|
-
if (!result || Object.keys(result).length !== 1 || !decisions(request).some(value => equal(value, result.decision))) throw new Error('审批选项不属于当前请求');
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
28
|
+
}
|
|
29
|
+
function validate(request, result) {
|
|
30
|
+
if ([command, file].includes(request.method)) {
|
|
31
|
+
if (!result || Object.keys(result).length !== 1 || !decisions(request).some(value => equal(value, result.decision))) throw new Error('审批选项不属于当前请求');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
19
34
|
if (request.method === input) {
|
|
20
|
-
const questions = request.params.questions || [];
|
|
21
|
-
if (!result?.answers || Object.keys(result).length !== 1 || Object.keys(result.answers).length !== questions.length
|
|
22
|
-
|| questions.some(q => !Array.isArray(result.answers[q.id]?.answers) || result.answers[q.id].answers.some(a => typeof a !== 'string'))) throw new Error('回答格式无效');
|
|
35
|
+
const questions = request.params.questions || [];
|
|
36
|
+
if (!result?.answers || Object.keys(result).length !== 1 || Object.keys(result.answers).length !== questions.length
|
|
37
|
+
|| questions.some(q => !Array.isArray(result.answers[q.id]?.answers) || result.answers[q.id].answers.some(a => typeof a !== 'string'))) throw new Error('回答格式无效');
|
|
23
38
|
return;
|
|
24
39
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const request = this.requests.get(params.requestId);
|
|
38
|
-
if (request?.params.threadId === params.threadId) { this.requests.delete(params.requestId); this.changed(params.threadId); }
|
|
39
|
-
} else if (method === 'turn/completed' || method === 'thread/closed') {
|
|
40
|
-
for (const [id, r] of this.requests) if (r.params.threadId === params.threadId && (method === 'thread/closed' || r.params.turnId === params.turn?.id)) this.requests.delete(id);
|
|
41
|
-
this.changed(params.threadId);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
forget(threadId) {
|
|
45
|
-
for (const [id, request] of this.requests) if (request.params.threadId === threadId) this.requests.delete(id);
|
|
46
|
-
this.changed(threadId);
|
|
47
|
-
}
|
|
48
|
-
reset(epoch) {
|
|
49
|
-
const threads = new Set([...this.requests.values()].map(r => r.params.threadId));
|
|
50
|
-
this.requests.clear(); this.epoch = epoch; for (const id of threads) this.changed(id);
|
|
51
|
-
}
|
|
52
|
-
async respond(threadId, { id, epoch, result }, send) {
|
|
53
|
-
const request = this.requests.get(id);
|
|
54
|
-
if (!request || epoch !== this.epoch || request.params.threadId !== threadId) throw new Error('审批已处理或连接已变化,请等待同步');
|
|
55
|
-
if (request.status !== 'pending') throw new Error('决定已提交,正在等待工作站确认,请勿重复提交');
|
|
56
|
-
validate(request, result);
|
|
57
|
-
request.status = 'submitted'; this.changed(threadId);
|
|
58
|
-
try { await send({ id: request.id, result }); }
|
|
59
|
-
catch (error) {
|
|
60
|
-
if (this.requests.get(id) === request) { request.status = 'unknown'; this.changed(threadId); }
|
|
61
|
-
throw new Error('审批发送结果未确认,请检查工作站;不会自动重发。' + error.message);
|
|
62
|
-
}
|
|
63
|
-
return { submitted: true };
|
|
40
|
+
const browser = browserAccess(request);
|
|
41
|
+
if (browser) {
|
|
42
|
+
const allowed = [
|
|
43
|
+
{ action: 'accept', content: {}, _meta: null },
|
|
44
|
+
{ action: 'decline', content: null, _meta: null },
|
|
45
|
+
];
|
|
46
|
+
if (browser.persistent) allowed.push(
|
|
47
|
+
{ action: 'accept', content: {}, _meta: { persist: 'always' } },
|
|
48
|
+
{ action: 'accept', content: {}, _meta: { persist: 'always', browser_use_persistent_approval_scope: 'all-sites' } },
|
|
49
|
+
);
|
|
50
|
+
if (!allowed.some(choice => equal(choice, result))) throw new Error('网站访问决定不属于当前请求');
|
|
51
|
+
return;
|
|
64
52
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
throw new Error('此类请求需在工作站处理');
|
|
54
|
+
}
|
|
55
|
+
class Store {
|
|
56
|
+
constructor(epoch, changed = () => {}) { this.epoch = epoch; this.changed = changed; this.requests = new Map(); }
|
|
57
|
+
list(threadId) { return [...this.requests.values()].filter(r => r.params.threadId === threadId).map(r => ({ ...r })); }
|
|
58
|
+
receive(message) {
|
|
59
|
+
if (!['string', 'number'].includes(typeof message.id) || !message.params?.threadId || this.requests.has(message.id)) return;
|
|
60
|
+
const request = { id: message.id, method: message.method, params: message.params, epoch: this.epoch, status: 'pending' };
|
|
61
|
+
this.requests.set(message.id, request); this.changed(request.params.threadId);
|
|
62
|
+
}
|
|
63
|
+
notification(method, params) {
|
|
64
|
+
if (method === 'serverRequest/resolved') {
|
|
65
|
+
const request = this.requests.get(params.requestId);
|
|
66
|
+
if (request?.params.threadId === params.threadId) { this.requests.delete(params.requestId); this.changed(params.threadId); }
|
|
67
|
+
} else if (method === 'turn/completed' || method === 'thread/closed') {
|
|
68
|
+
for (const [id, r] of this.requests) if (r.params.threadId === params.threadId && (method === 'thread/closed' || r.params.turnId === params.turn?.id)) this.requests.delete(id);
|
|
69
|
+
this.changed(params.threadId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
forget(threadId) {
|
|
73
|
+
for (const [id, request] of this.requests) if (request.params.threadId === threadId) this.requests.delete(id);
|
|
74
|
+
this.changed(threadId);
|
|
75
|
+
}
|
|
76
|
+
reset(epoch) {
|
|
77
|
+
const threads = new Set([...this.requests.values()].map(r => r.params.threadId));
|
|
78
|
+
this.requests.clear(); this.epoch = epoch; for (const id of threads) this.changed(id);
|
|
79
|
+
}
|
|
80
|
+
async respond(threadId, { id, epoch, result }, send) {
|
|
81
|
+
const request = this.requests.get(id);
|
|
82
|
+
if (!request || epoch !== this.epoch || request.params.threadId !== threadId) throw new Error('审批已处理或连接已变化,请等待同步');
|
|
83
|
+
if (request.status !== 'pending') throw new Error('决定已提交,正在等待工作站确认,请勿重复提交');
|
|
84
|
+
validate(request, result);
|
|
85
|
+
request.status = 'submitted'; this.changed(threadId);
|
|
86
|
+
try { await send({ id: request.id, result }); }
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (this.requests.get(id) === request) { request.status = 'unknown'; this.changed(threadId); }
|
|
89
|
+
throw new Error('审批发送结果未确认,请检查工作站;不会自动重发。' + error.message);
|
|
90
|
+
}
|
|
91
|
+
return { submitted: true };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { Store, decisions, validate, command, file, input, elicitation, browserAccess };
|
|
95
|
+
});
|
|
@@ -5,7 +5,7 @@ description: 启动、检查或停止 Code Relax 工作站服务,生成并展
|
|
|
5
5
|
|
|
6
6
|
# Code Relax
|
|
7
7
|
|
|
8
|
-
- `relax start --json` 启动 Bridge 和受管 Codex 内核,无需打开 Desktop;支持纯 CLI
|
|
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,11 +1,10 @@
|
|
|
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
|
|
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(
|
|
8
|
-
if (mode !== 'shared') throw new Error('只支持接入 Code Relax 管理的 Codex 内核。');
|
|
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
10
|
if (recovery && recovery.operationId !== recoveryId) throw new Error('Codex 内核正在启动,请先查看 shared-recovery-status。');
|
|
@@ -22,11 +21,8 @@ export async function launchDesktop(mode, { stateRoot, host, environment = proce
|
|
|
22
21
|
if (!info.desktop) throw new Error('未安装 Codex Desktop,请使用 relax start 启动工作站。');
|
|
23
22
|
const connected = await host.sharedServiceUrl();
|
|
24
23
|
if (info.running) {
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
return { reused: true, mode };
|
|
28
|
-
}
|
|
29
|
-
throw new Error('请先保存任务并完全退出 Codex Desktop,再切换模式;不会自动结束正在运行的任务。');
|
|
24
|
+
if (connected) return { reused: true };
|
|
25
|
+
throw new Error('请先保存任务并完全退出 Codex Desktop,再接入 Codex 内核;不会自动结束正在运行的任务。');
|
|
30
26
|
}
|
|
31
27
|
const childEnv = { ...environment };
|
|
32
28
|
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
|
|
@@ -42,10 +38,9 @@ export async function launchDesktop(mode, { stateRoot, host, environment = proce
|
|
|
42
38
|
while (true) {
|
|
43
39
|
const ready = await host.sharedServiceUrl();
|
|
44
40
|
if (ready) break;
|
|
45
|
-
if (Date.now() >= deadline) throw new Error('Desktop
|
|
41
|
+
if (Date.now() >= deadline) throw new Error('Desktop 尚未接入 Codex 内核。请查看 Desktop 窗口后重试。');
|
|
46
42
|
await wait(500);
|
|
47
43
|
}
|
|
48
|
-
|
|
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,16 +1,20 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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
20
|
try {
|
|
@@ -23,8 +27,44 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
23
27
|
catch { allowed = false; }
|
|
24
28
|
}
|
|
25
29
|
if (!allowed) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
});
|
|
28
68
|
wss.on('connection', ws => {
|
|
29
69
|
let authenticated = false, alive = true;
|
|
30
70
|
const active = new Map();
|
|
@@ -105,5 +145,8 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
105
145
|
catch (error) { reply.json(500, { error: error.message }); }
|
|
106
146
|
});
|
|
107
147
|
});
|
|
108
|
-
return { close() {
|
|
109
|
-
|
|
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,7 +5,7 @@ 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
|
+
import { pruneRetainedDesktopRuntimes, resolveCodexRuntime, runtimeFilesAvailable } from './platform/windows/codex-runtime.mjs';
|
|
9
9
|
|
|
10
10
|
export const managedAppServerUrl = 'ws://127.0.0.1:45839';
|
|
11
11
|
export const managedAppServerReadyUrl = 'http://127.0.0.1:45839/readyz';
|
|
@@ -32,7 +32,6 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
32
32
|
this.timeout = timeout;
|
|
33
33
|
this.now = now;
|
|
34
34
|
this.recordPath = path.join(stateRoot, 'managed-app-server.json');
|
|
35
|
-
this.legacyRecordPath = path.join(stateRoot, 'shared-desktop.json');
|
|
36
35
|
this.client = client;
|
|
37
36
|
this.state = { state: 'absent', ready: false, error: null };
|
|
38
37
|
this.restartStatus = null;
|
|
@@ -45,7 +44,11 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
45
44
|
});
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
snapshot() {
|
|
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
|
+
}
|
|
49
52
|
|
|
50
53
|
async start() {
|
|
51
54
|
this.stopped = false;
|
|
@@ -68,13 +71,17 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
68
71
|
try {
|
|
69
72
|
fs.mkdirSync(this.stateRoot, { recursive: true });
|
|
70
73
|
let record = null, actual = null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 startedHere = !actual;
|
|
78
|
+
const savedRuntime = actual ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) : null;
|
|
79
|
+
const runtime = actual && savedRuntime?.executable && runtimeFilesAvailable(savedRuntime.executable)
|
|
80
|
+
? savedRuntime
|
|
81
|
+
: await resolveCodexRuntime(this.stateRoot, { refresh: !actual, environment: this.environment });
|
|
82
|
+
if (actual && !runtimeFilesAvailable(actual.executable)) {
|
|
83
|
+
throw new Error(`运行中的 Codex 内核目录已被移除或缺少 codex-code-mode-host.exe:${actual.executable};已找到可用内核 ${runtime.executable},请确认重启内核。`);
|
|
74
84
|
}
|
|
75
|
-
const runtime = actual
|
|
76
|
-
? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) || { executable: actual.executable }
|
|
77
|
-
: await resolveCodexRuntime(this.stateRoot, { environment: this.environment });
|
|
78
85
|
if (!actual) {
|
|
79
86
|
if (!await this.available()) throw new Error('45839 被未识别进程占用,未启动或结束任何进程。');
|
|
80
87
|
const childEnvironment = { ...this.environment };
|
|
@@ -97,6 +104,10 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
97
104
|
await this.waitUntilReady(actual);
|
|
98
105
|
await this.client.connect();
|
|
99
106
|
if (!this.client.ready) throw new Error('Codex 内核协议初始化未完成。');
|
|
107
|
+
if (startedHere) {
|
|
108
|
+
try { pruneRetainedDesktopRuntimes(this.stateRoot, actual.executable); }
|
|
109
|
+
catch (error) { console.error('旧版 Codex 内核文件清理失败:', error); }
|
|
110
|
+
}
|
|
100
111
|
const preferredExecutable = path.resolve(runtime.executable);
|
|
101
112
|
const updatePending = path.resolve(actual.executable).toLowerCase() !== preferredExecutable.toLowerCase();
|
|
102
113
|
const launcherRevision = Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0;
|
|
@@ -145,7 +156,7 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
145
156
|
const previous = this.state;
|
|
146
157
|
this.publish({ ...previous, state: 'restarting', ready: false, error: null });
|
|
147
158
|
try {
|
|
148
|
-
const record = readRecord(this.recordPath)
|
|
159
|
+
const record = readRecord(this.recordPath);
|
|
149
160
|
const actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
150
161
|
if (actual && !sameManagedProcess(record, actual)) {
|
|
151
162
|
throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
|
package/dist/src/onboarding.mjs
CHANGED
|
@@ -8,10 +8,10 @@ export const connectionModesText = [
|
|
|
8
8
|
export function phonePairingSteps(transportMode, paired = false) {
|
|
9
9
|
const mode = transportMode === 'lan-shared' ? '局域网 · Codex 内核已就绪' : '局域网(Codex 内核未就绪)';
|
|
10
10
|
return paired ? [
|
|
11
|
-
|
|
11
|
+
`已有移动设备授权。移动端可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
|
|
12
12
|
'添加另一台设备时执行 relax pair;每台设备只需扫码配对一次。',
|
|
13
13
|
] : [
|
|
14
|
-
'
|
|
14
|
+
'请先在移动端安装 Code Relax。当前为鸿蒙 HarmonyOS 7 私测版,调试 HAP 需通过 DevEco/调试设备安装,尚无商店下载入口。',
|
|
15
15
|
'使用 App 扫描配对二维码;每台移动设备只需配对一次。',
|
|
16
16
|
`配对后可直接使用私有中继;${mode} 会接收并验证当前网络候选,也可手动输入 IP。`,
|
|
17
17
|
];
|
|
@@ -25,5 +25,5 @@ export const desktopSetupSteps = [
|
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
export function pairingDisplayText(nextSteps, notice) {
|
|
28
|
-
return ['Bridge
|
|
28
|
+
return ['Bridge 已就绪,请在移动端完成连接。', connectionModesText, ...nextSteps.map((step, index) => `${index + 1}. ${step}`), notice].join('\n\n');
|
|
29
29
|
}
|