@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.
- package/README.md +53 -64
- package/dist/bin/codex-remote.mjs +90 -71
- package/dist/bin/self-relay-admin.mjs +8 -0
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/bin/self-relay-server.mjs +5 -2
- package/dist/shared/app-server-events.cjs +64 -24
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +16 -21
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +59 -10
- package/dist/src/managed-app-server.mjs +36 -14
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +6 -8
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
- package/dist/src/platform/windows/background-process.mjs +7 -2
- package/dist/src/platform/windows/codex-runtime.mjs +117 -0
- package/dist/src/platform/windows/desktop-host.mjs +17 -3
- package/dist/src/platform/windows/desktop-info.ps1 +2 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -14
- package/dist/src/platform/windows/launch-worker.mjs +42 -0
- package/dist/src/platform/windows/resident-startup.ps1 +58 -18
- package/dist/src/platform/windows/service-host.mjs +4 -16
- package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
- package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
- package/dist/src/runtime-discovery.mjs +43 -0
- package/dist/src/self-relay/admin-cloud.mjs +101 -0
- package/dist/src/self-relay/admin-page.mjs +71 -0
- package/dist/src/self-relay/admin-server.mjs +61 -0
- package/dist/src/self-relay/admin-state.mjs +122 -0
- package/dist/src/self-relay/client.mjs +2 -0
- package/dist/src/self-relay/connector.mjs +107 -20
- package/dist/src/self-relay/demo.mjs +4 -5
- package/dist/src/self-relay/lifecycle.mjs +58 -70
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +224 -29
- package/dist/src/server.mjs +616 -1299
- package/dist/src/service-doctor.mjs +14 -26
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/service-lifecycle.mjs +12 -12
- package/dist/src/shared-app-server.mjs +206 -46
- package/dist/src/shared-catalog.mjs +5 -25
- package/dist/src/shared-recovery.mjs +11 -9
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/src/skill-install.mjs +0 -13
- package/package.json +16 -18
- package/tools/postinstall.mjs +140 -3
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -355
- package/dist/src/debug-ui.mjs +0 -131
- package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
- package/dist/src/platform/windows/desktop-tools.mjs +0 -48
- package/dist/src/platform/windows/launch-worker.ps1 +0 -13
- package/dist/src/thread-catalog.mjs +0 -42
- package/dist/tools/find-desktop-pipe.mjs +0 -10
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -57
- package/dist/web/chat-transport.js +0 -196
- package/dist/web/chat.css +0 -736
- package/dist/web/chat.js +0 -5149
- package/dist/web/client-platform.js +0 -11
- package/dist/web/harmony-platform.js +0 -51
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -146
- package/dist/web/index.html +0 -222
- package/dist/web/lan-session.js +0 -122
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -83
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -119
- package/dist/web/server-requests.js +0 -67
- package/dist/web/timeline-sync.js +0 -67
- 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
|
@@ -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
|
|
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
|
|
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 (!['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('
|
|
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 (
|
|
25
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 =
|
|
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,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
|
-
|
|
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
|
-
try {
|
|
17
|
-
|
|
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
|
-
|
|
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() {
|
|
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() {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
92
|
+
actual = started;
|
|
93
|
+
record = { ...started, launcherRevision: managedAppServerLauncherRevision };
|
|
84
94
|
writeRecord(this.recordPath, record);
|
|
85
95
|
} else if (!readRecord(this.recordPath)) {
|
|
86
|
-
|
|
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(
|
|
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,
|
|
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)
|
|
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
|
-
}
|
package/dist/src/onboarding.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
export const connectionModesText = [
|
|
2
2
|
'可选连接模式:',
|
|
3
3
|
'• 局域网:启动时会把当前 Wi-Fi/有线网络候选临时发给已配对设备;App 验证后保存,也可手动输入 IP。',
|
|
4
|
-
'•
|
|
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' ? '局域网 ·
|
|
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
|
];
|
|
@@ -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
|
-
'
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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
|
}
|