@walkhi/code-relax 0.1.0-beta.11 → 0.1.0-beta.13
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/dist/bin/codex-remote.mjs +42 -6
- package/dist/bin/migrate-relay-db.mjs +104 -0
- package/dist/bin/self-relay-server.mjs +17 -10
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/src/managed-app-server.mjs +13 -4
- package/dist/src/platform/windows/codex-runtime.mjs +138 -4
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
- package/dist/src/platform/windows/resident-start.js +8 -0
- package/dist/src/platform/windows/resident-startup.ps1 +6 -3
- package/dist/src/self-relay/admin-state.mjs +8 -6
- package/dist/src/self-relay/email-accounts.mjs +125 -0
- package/dist/src/self-relay/lifecycle.mjs +48 -80
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/server.mjs +88 -26
- package/dist/src/server.mjs +745 -726
- package/dist/src/shared-app-server.mjs +285 -285
- package/package.json +2 -1
|
@@ -15,11 +15,14 @@ import fs from 'node:fs';
|
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
17
17
|
import { requestRecovery, recoveryStatus, recoveryWarning } from '../src/shared-recovery.mjs';
|
|
18
|
-
import { configureManagedEnvironment, clearManagedEnvironment } from '../src/platform/windows/user-environment.mjs';
|
|
18
|
+
import { configureManagedEnvironment, clearManagedEnvironment, readManagedEnvironment } from '../src/platform/windows/user-environment.mjs';
|
|
19
19
|
import { resolveCodexRuntime } from '../src/platform/windows/codex-runtime.mjs';
|
|
20
|
+
import { managedAppServerUrl } from '../src/managed-app-server.mjs';
|
|
21
|
+
import { controlRequest } from '../src/service-control.mjs';
|
|
22
|
+
import { processAlive } from '../src/background-process.mjs';
|
|
20
23
|
|
|
21
24
|
const help = `Code Relax(Windows)
|
|
22
|
-
用法:relax
|
|
25
|
+
用法:relax <命令> [--json];下列命令均可用
|
|
23
26
|
setup 部署并启动固定目录服务、受管 app-server 和登录自启动;安全配置 Desktop 后续接入
|
|
24
27
|
start 启动或复用 Bridge 和 Codex 内核,并恢复已配置的私有中继
|
|
25
28
|
shared-recover --confirm 重新打开 Desktop 并接入 Codex 内核;不强制重启已有内核
|
|
@@ -72,8 +75,33 @@ async function main() {
|
|
|
72
75
|
if (command.startsWith('autostart-')) {
|
|
73
76
|
const installation = readRecord(path.join(serviceRoot, 'installation.json'));
|
|
74
77
|
if (!installation?.nodeExecutable) throw new Error('请先执行 setup。');
|
|
75
|
-
const
|
|
76
|
-
|
|
78
|
+
const action = command.slice('autostart-'.length);
|
|
79
|
+
let environment;
|
|
80
|
+
if (action === 'disable') {
|
|
81
|
+
environment = await clearManagedEnvironment(stateRoot);
|
|
82
|
+
const current = await readManagedEnvironment();
|
|
83
|
+
if (current.userValue === managedAppServerUrl || (!current.userValue && current.machineValue === managedAppServerUrl)) {
|
|
84
|
+
throw new Error('Desktop 仍会连接 Code Relax 内核;请先处理非本包管理的 CODEX_APP_SERVER_WS_URL,未关闭登录启动。');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (action === 'enable') {
|
|
88
|
+
const running = await service.status();
|
|
89
|
+
if (!running.running) await service.start();
|
|
90
|
+
const deadline = Date.now() + 35000;
|
|
91
|
+
while (true) {
|
|
92
|
+
const ready = await service.status();
|
|
93
|
+
if (ready.running && ready.managedAppServer?.ready) break;
|
|
94
|
+
if (Date.now() >= deadline) throw new Error('Codex 内核未就绪,未启用 Desktop 自动接入。');
|
|
95
|
+
await delay(250);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const result = await residentStartup({ serviceRoot, nodeExecutable: installation.nodeExecutable }, action);
|
|
99
|
+
if (action === 'enable' && (await desktopHost.inspect()).desktop) environment = await configureManagedEnvironment(stateRoot);
|
|
100
|
+
const output = { ...result, ...(environment ? { environment } : {}) };
|
|
101
|
+
const environmentText = environment?.preserved ? ';用户自定义的 Desktop 地址已保留,请自行核对' :
|
|
102
|
+
environment?.removed ? ';已清除 Code Relax 设置的 Desktop 地址' :
|
|
103
|
+
environment?.configured ? ';已配置 Desktop 接入地址' : '';
|
|
104
|
+
console.log(json ? JSON.stringify(output) : `登录启动:${result.enabled ? '已启用' : '已关闭'}${environmentText}`); return;
|
|
77
105
|
}
|
|
78
106
|
if (command === 'shared-recover' || command === 'shared-recovery-status') {
|
|
79
107
|
if (command === 'shared-recover' && !flags.includes('--confirm')) throw new Error(recoveryWarning + ' 使用 --confirm 确认。');
|
|
@@ -145,7 +173,8 @@ async function main() {
|
|
|
145
173
|
const runtime = await resolveCodexRuntime(physicalStateRoot, { refresh: true });
|
|
146
174
|
const desktopNeedsAttach = desktopBeforeSetup.running && !await host.sharedServiceUrl();
|
|
147
175
|
const previous = hadInstalledService ? await service.status() : { processRunning: false };
|
|
148
|
-
const
|
|
176
|
+
const legacyRelaySession = readRecord(path.join(stateRoot, 'self-relay-session.json'));
|
|
177
|
+
const previousRelay = legacyRelaySession ? await controlRequest(legacyRelaySession) : await selfRelay('relay-status', stateRoot);
|
|
149
178
|
const resumeAfterUpdate = previous.processRunning || previousRelay.running;
|
|
150
179
|
const executable = fs.realpathSync(process.execPath);
|
|
151
180
|
const startInstalled = async () => {
|
|
@@ -165,7 +194,14 @@ async function main() {
|
|
|
165
194
|
beforeActivate: async () => {
|
|
166
195
|
if (desktopNeedsAttach) await desktopHost.closeDesktop(desktopBeforeSetup.desktop);
|
|
167
196
|
stoppedForUpdate = resumeAfterUpdate;
|
|
168
|
-
if (previousRelay.running
|
|
197
|
+
if (previousRelay.running && legacyRelaySession) {
|
|
198
|
+
await controlRequest(legacyRelaySession, 'POST');
|
|
199
|
+
const deadline = Date.now() + 10000;
|
|
200
|
+
while (processAlive(legacyRelaySession.pid)) {
|
|
201
|
+
if (Date.now() >= deadline) throw new Error('旧中继进程未在 10 秒内退出。');
|
|
202
|
+
await delay(100);
|
|
203
|
+
}
|
|
204
|
+
} else if (previousRelay.running) await selfRelay('relay-stop', stateRoot);
|
|
169
205
|
if (previous.processRunning) await service.stop();
|
|
170
206
|
},
|
|
171
207
|
verifyActivation: async () => { await startInstalled(); },
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import mysql from 'mysql2/promise';
|
|
6
|
+
import { deviceStore } from '../src/self-relay/device-store.mjs';
|
|
7
|
+
import { relayAdminState } from '../src/self-relay/admin-state.mjs';
|
|
8
|
+
|
|
9
|
+
const command = process.argv[2];
|
|
10
|
+
if (!['--schema', '--import', '--check'].includes(command) || !process.env.RELAY_MYSQL_CONFIG_FILE) {
|
|
11
|
+
throw new Error('Use --schema, --import or --check with RELAY_MYSQL_CONFIG_FILE');
|
|
12
|
+
}
|
|
13
|
+
const config = JSON.parse(fs.readFileSync(process.env.RELAY_MYSQL_CONFIG_FILE, 'utf8'));
|
|
14
|
+
const db = await mysql.createConnection({ ...config, dateStrings: true, timezone: 'Z', multipleStatements: false });
|
|
15
|
+
const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
|
|
16
|
+
const migrations = ['001_relay_core', '002_email_accounts'];
|
|
17
|
+
try {
|
|
18
|
+
if (command === '--schema') {
|
|
19
|
+
const [existing] = await db.query("SHOW TABLES LIKE 'schema_migrations'");
|
|
20
|
+
const [applied] = existing.length ? await db.query('SELECT version, sha256 FROM schema_migrations') : [[]];
|
|
21
|
+
const known = new Map(applied.map(row => [row.version, row.sha256]));
|
|
22
|
+
if (known.size !== applied.length || [...known.keys()].some(version => !migrations.includes(version))) throw new Error('Unexpected schema version');
|
|
23
|
+
for (const version of migrations) {
|
|
24
|
+
const source = fs.readFileSync(fileURLToPath(new URL(`../database/${version}.sql`, import.meta.url)), 'utf8');
|
|
25
|
+
const sha256 = createHash('sha256').update(source).digest('hex');
|
|
26
|
+
if (known.has(version)) {
|
|
27
|
+
if (known.get(version) !== sha256) throw new Error(`Migration changed: ${version}`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (version !== migrations[0] && !known.has(migrations[migrations.indexOf(version) - 1])) throw new Error('Previous migration missing');
|
|
31
|
+
const statements = source.split('\n').filter(line => !line.trimStart().startsWith('--')).join('\n');
|
|
32
|
+
for (const statement of statements.split(';').map(item => item.trim()).filter(Boolean)) await db.query(statement);
|
|
33
|
+
await db.execute('INSERT INTO schema_migrations (version, sha256) VALUES (?,?)', [version, sha256]);
|
|
34
|
+
known.set(version, sha256);
|
|
35
|
+
console.log(JSON.stringify({ schema: version, sha256 }));
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
const [versions] = await db.query('SELECT version FROM schema_migrations');
|
|
39
|
+
if (!versions.some(row => row.version === '001_relay_core') || versions.some(row => !migrations.includes(row.version))) throw new Error('Unexpected schema version');
|
|
40
|
+
if (command === '--check') {
|
|
41
|
+
const names = ['computers', 'mobile_authorizations', 'official_computers', 'announcements', 'relay_settings', 'users', 'user_login_identities'];
|
|
42
|
+
if (versions.some(row => row.version === '002_email_accounts')) names.push('email_login_challenges', 'user_sessions', 'email_send_limits');
|
|
43
|
+
const counts = {};
|
|
44
|
+
for (const name of names) { const [rows] = await db.query(`SELECT COUNT(*) AS count FROM ${name}`); counts[name] = rows[0].count; }
|
|
45
|
+
if (process.env.RELAY_STATE_FILE && process.env.RELAY_ADMIN_STATE_FILE) {
|
|
46
|
+
const devices = deviceStore(process.env.RELAY_STATE_FILE).entries();
|
|
47
|
+
const admin = relayAdminState(process.env.RELAY_ADMIN_STATE_FILE, 8).read();
|
|
48
|
+
const expected = { computers: devices.length,
|
|
49
|
+
mobile_authorizations: devices.reduce((sum, item) => sum + item.clients.length, 0),
|
|
50
|
+
official_computers: devices.filter(item => item.officialComputer).length,
|
|
51
|
+
announcements: admin.announcements.length, relay_settings: 1 };
|
|
52
|
+
if (Object.keys(expected).some(name => counts[name] !== expected[name])) throw new Error('Imported row counts differ from JSON');
|
|
53
|
+
const [saved] = await db.query('SELECT id,host_hash,status,revision FROM computers');
|
|
54
|
+
const original = new Map(devices.map(item => [item.id, item]));
|
|
55
|
+
for (const row of saved) {
|
|
56
|
+
const item = original.get(row.id);
|
|
57
|
+
if (!item || item.hostHash !== row.host_hash || item.status !== row.status || item.revision !== Number(row.revision)) {
|
|
58
|
+
throw new Error('Imported computer identity differs from JSON');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const [authorized] = await db.query('SELECT computer_id,client_id,credential_hash FROM mobile_authorizations');
|
|
62
|
+
for (const row of authorized) {
|
|
63
|
+
const item = original.get(row.computer_id)?.clients.find(client => client.id === row.client_id);
|
|
64
|
+
if (!item || item.hash !== row.credential_hash) throw new Error('Imported mobile authorization differs from JSON');
|
|
65
|
+
}
|
|
66
|
+
const [settings] = await db.query('SELECT max_relay_sessions FROM relay_settings WHERE id=1');
|
|
67
|
+
if (settings[0]?.max_relay_sessions !== admin.maxRelaySessions) throw new Error('Imported relay setting differs from JSON');
|
|
68
|
+
console.log(JSON.stringify({ verified: true, counts }));
|
|
69
|
+
} else console.log(JSON.stringify(counts));
|
|
70
|
+
} else {
|
|
71
|
+
const stateFile = process.env.RELAY_STATE_FILE, adminFile = process.env.RELAY_ADMIN_STATE_FILE;
|
|
72
|
+
if (!stateFile || !adminFile) throw new Error('RELAY_STATE_FILE and RELAY_ADMIN_STATE_FILE required for import');
|
|
73
|
+
const devices = deviceStore(stateFile).entries();
|
|
74
|
+
const admin = relayAdminState(adminFile, 8).read();
|
|
75
|
+
const [existing] = await db.query('SELECT (SELECT COUNT(*) FROM computers) AS computers, (SELECT COUNT(*) FROM relay_settings) AS settings');
|
|
76
|
+
if (existing[0].computers || existing[0].settings) throw new Error('Import target is not empty');
|
|
77
|
+
await db.beginTransaction();
|
|
78
|
+
try {
|
|
79
|
+
for (const item of devices) {
|
|
80
|
+
await db.execute('INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at) VALUES (?,?,?,?,?,?)',
|
|
81
|
+
[item.id, item.hostHash, item.status, item.revision, sqlTime(item.createdAt), sqlTime(item.updatedAt)]);
|
|
82
|
+
for (const client of item.clients) await db.execute(
|
|
83
|
+
'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at) VALUES (?,?,?,?,?)',
|
|
84
|
+
[item.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt)]);
|
|
85
|
+
if (item.officialComputer) {
|
|
86
|
+
const official = item.officialComputer;
|
|
87
|
+
await db.execute('INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at) VALUES (?,?,?,?,?)',
|
|
88
|
+
[item.id, official.environmentId, official.name, official.hostname, sqlTime(official.updatedAt)]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await db.execute('INSERT INTO relay_settings (id,max_relay_sessions,updated_at) VALUES (1,?,UTC_TIMESTAMP(3))', [admin.maxRelaySessions]);
|
|
92
|
+
for (const item of admin.announcements) await db.execute(
|
|
93
|
+
'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
|
|
94
|
+
[item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
|
|
95
|
+
item.publishedAt ? sqlTime(item.publishedAt) : null]);
|
|
96
|
+
await db.commit();
|
|
97
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
98
|
+
console.log(JSON.stringify({ imported: true, computers: devices.length,
|
|
99
|
+
mobileAuthorizations: devices.reduce((sum, item) => sum + item.clients.length, 0),
|
|
100
|
+
officialComputers: devices.filter(item => item.officialComputer).length,
|
|
101
|
+
announcements: admin.announcements.length }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} finally { await db.end(); }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
-
import { deviceStore, validId } from '../src/self-relay/device-store.mjs';
|
|
3
|
+
import { deviceStore, validId } from '../src/self-relay/device-store.mjs';
|
|
4
|
+
import { openRelayMysql } from '../src/self-relay/mysql-state.mjs';
|
|
4
5
|
import { startSelfRelay } from '../src/self-relay/server.mjs';
|
|
5
6
|
import { updateDebugAllowlist } from '../src/self-relay/debug-allowlist.mjs';
|
|
6
7
|
|
|
@@ -9,15 +10,18 @@ if (!keyFile) throw new Error('RELAY_HOST_KEY_FILE is required');
|
|
|
9
10
|
const hostKey = fs.readFileSync(keyFile, 'utf8').trim();
|
|
10
11
|
const port = Number(process.env.RELAY_PORT || 45850);
|
|
11
12
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid RELAY_PORT');
|
|
12
|
-
const stateFile = process.env.RELAY_STATE_FILE;
|
|
13
|
-
|
|
13
|
+
const stateFile = process.env.RELAY_STATE_FILE;
|
|
14
|
+
const mysqlConfigFile = process.env.RELAY_MYSQL_CONFIG_FILE;
|
|
15
|
+
const accountConfigFile = process.env.RELAY_ACCOUNT_CONFIG_FILE;
|
|
16
|
+
if (!stateFile && !mysqlConfigFile) throw new Error('RELAY_STATE_FILE or RELAY_MYSQL_CONFIG_FILE is required');
|
|
14
17
|
const debugAllowlistFile = process.env.RELAY_DEBUG_ALLOWLIST_FILE || '/etc/codex-relay/debug-allowlist.json';
|
|
15
18
|
const adminStateFile = process.env.RELAY_ADMIN_STATE_FILE || '/var/lib/codex-relay/admin.json';
|
|
16
19
|
const maxRelaySessions = Number(process.env.RELAY_MAX_RELAY_SESSIONS || 8);
|
|
17
20
|
if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid RELAY_MAX_RELAY_SESSIONS');
|
|
18
21
|
const debugCommand = process.argv.find(value => ['--debug-list', '--debug-allow', '--debug-deny'].includes(value));
|
|
19
22
|
if (debugCommand) {
|
|
20
|
-
const
|
|
23
|
+
const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
|
|
24
|
+
const records = (database?.devices || deviceStore(stateFile)).entries();
|
|
21
25
|
if (debugCommand === '--debug-list') {
|
|
22
26
|
console.log(JSON.stringify(records.map(record => ({
|
|
23
27
|
deviceId: record.id, status: record.status, clientCount: record.clients.length,
|
|
@@ -30,21 +34,24 @@ if (debugCommand) {
|
|
|
30
34
|
updateDebugAllowlist(debugAllowlistFile, deviceId, debugCommand === '--debug-allow');
|
|
31
35
|
console.log('Debug allowlist updated; restart relay to apply.');
|
|
32
36
|
}
|
|
37
|
+
if (database) await database.close();
|
|
33
38
|
process.exit(0);
|
|
34
39
|
}
|
|
35
|
-
if (process.argv.includes('--init-state')) { deviceStore(stateFile, { initialize: true }); console.log('Device store initialized'); process.exit(0); }
|
|
36
|
-
const revokeIndex = process.argv.indexOf('--revoke-host');
|
|
37
|
-
if (revokeIndex >= 0) {
|
|
40
|
+
if (process.argv.includes('--init-state')) { if (mysqlConfigFile) throw new Error('Use migrations for MySQL mode'); deviceStore(stateFile, { initialize: true }); console.log('Device store initialized'); process.exit(0); }
|
|
41
|
+
const revokeIndex = process.argv.indexOf('--revoke-host');
|
|
42
|
+
if (revokeIndex >= 0) {
|
|
38
43
|
// Run only with the service stopped: this is an offline administrator operation.
|
|
39
44
|
if (!process.argv.includes('--offline')) throw new Error('Stop the relay service and supply --offline');
|
|
40
45
|
const id = process.argv[revokeIndex + 1];
|
|
41
46
|
if (!validId(id)) throw new Error('Invalid device ID');
|
|
42
|
-
const
|
|
47
|
+
const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
|
|
48
|
+
const store = database?.devices || deviceStore(stateFile), record = store.entries().find(item => item.id === id);
|
|
43
49
|
if (!record) throw new Error('Device not found');
|
|
44
|
-
store.save({ ...record, status: 'revoked',
|
|
50
|
+
await store.save({ ...record, status: 'revoked', clients: [], revision: record.revision + 1, updatedAt: new Date().toISOString() });
|
|
51
|
+
if (database) await database.close();
|
|
45
52
|
console.log('Computer and phone authorization revoked'); process.exit(0);
|
|
46
53
|
}
|
|
47
|
-
const relay = await startSelfRelay({ port, hostKey, stateFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
|
|
54
|
+
const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
|
|
48
55
|
publicRelayUrl: process.env.RELAY_PUBLIC_URL || '', appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || '',
|
|
49
56
|
log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
|
|
50
57
|
console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
|
|
@@ -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 @@ 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';
|
|
@@ -74,9 +74,14 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
74
74
|
const candidate = readRecord(this.recordPath);
|
|
75
75
|
const process = candidate?.pid ? await this.host.process(candidate.pid) : null;
|
|
76
76
|
if (sameManagedProcess(candidate, process)) { record = candidate; actual = process; }
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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},请确认重启内核。`);
|
|
84
|
+
}
|
|
80
85
|
if (!actual) {
|
|
81
86
|
if (!await this.available()) throw new Error('45839 被未识别进程占用,未启动或结束任何进程。');
|
|
82
87
|
const childEnvironment = { ...this.environment };
|
|
@@ -99,6 +104,10 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
99
104
|
await this.waitUntilReady(actual);
|
|
100
105
|
await this.client.connect();
|
|
101
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
|
+
}
|
|
102
111
|
const preferredExecutable = path.resolve(runtime.executable);
|
|
103
112
|
const updatePending = path.resolve(actual.executable).toLowerCase() !== preferredExecutable.toLowerCase();
|
|
104
113
|
const launcherRevision = Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0;
|
|
@@ -3,6 +3,8 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
6
8
|
import { promisify } from 'node:util';
|
|
7
9
|
import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
|
|
8
10
|
|
|
@@ -11,7 +13,130 @@ const requiredMethods = ['thread/start', 'thread/resume', 'thread/list', 'thread
|
|
|
11
13
|
'thread/items/list', 'turn/start', 'turn/steer', 'turn/interrupt', 'model/list', 'project/list',
|
|
12
14
|
'thread/settings/update', 'configRequirements/read', 'thread/queue/list', 'thread/queue/add',
|
|
13
15
|
'thread/queue/delete', 'thread/queue/start', 'thread/read', 'thread/unsubscribe', 'config/read', 'account/rateLimits/read'];
|
|
14
|
-
const
|
|
16
|
+
export const codeModeHostPath = executable => path.join(path.dirname(executable), 'codex-code-mode-host.exe');
|
|
17
|
+
export const runtimeFilesAvailable = executable => fs.existsSync(executable) && fs.existsSync(codeModeHostPath(executable));
|
|
18
|
+
const fingerprint = executable => [executable, codeModeHostPath(executable)]
|
|
19
|
+
.map(file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; }).join('|');
|
|
20
|
+
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
21
|
+
const desktopRuntimeScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'desktop-runtime.ps1');
|
|
22
|
+
|
|
23
|
+
async function fileHash(file) {
|
|
24
|
+
const hash = createHash('sha256');
|
|
25
|
+
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
|
26
|
+
return hash.digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function installedDesktopRuntime() {
|
|
30
|
+
const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
31
|
+
'-File', desktopRuntimeScript], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
|
|
32
|
+
return JSON.parse(stdout.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function matchesInstalledDesktop(executable, installed) {
|
|
36
|
+
if (!installed || !runtimeFilesAvailable(executable) || !runtimeFilesAvailable(installed.executable)) return false;
|
|
37
|
+
const files = [[executable, installed.executable], [codeModeHostPath(executable), installed.codeModeHost]];
|
|
38
|
+
for (const [candidate, bundled] of files) {
|
|
39
|
+
if (fs.statSync(candidate).size !== fs.statSync(bundled).size || await fileHash(candidate) !== await fileHash(bundled)) return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runtimeFiles(directory, relative = '') {
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true })) {
|
|
47
|
+
const name = path.join(relative, entry.name);
|
|
48
|
+
if (entry.isDirectory()) files.push(...runtimeFiles(directory, name));
|
|
49
|
+
else if (entry.isFile()) files.push(name);
|
|
50
|
+
else throw new Error(`Codex 内核目录包含不支持的文件类型:${name}`);
|
|
51
|
+
}
|
|
52
|
+
return files.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runtimeDirectories(files) {
|
|
56
|
+
const directories = new Set();
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
let directory = path.dirname(file);
|
|
59
|
+
while (directory !== '.') { directories.add(directory); directory = path.dirname(directory); }
|
|
60
|
+
}
|
|
61
|
+
return [...directories].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function retainDesktopRuntime(executable, installed, stateRoot) {
|
|
65
|
+
if (!/^[\w.-]+$/.test(installed.package)) throw new Error('Codex Desktop 安装包名称无效。');
|
|
66
|
+
const source = path.dirname(executable);
|
|
67
|
+
const files = runtimeFiles(source);
|
|
68
|
+
if (files.includes('runtime.json')) throw new Error('Codex 内核目录与保留清单文件名冲突。');
|
|
69
|
+
const directories = runtimeDirectories(files);
|
|
70
|
+
const root = path.join(stateRoot, 'desktop-runtimes');
|
|
71
|
+
const target = path.join(root, installed.package);
|
|
72
|
+
const retained = path.join(target, 'codex.exe');
|
|
73
|
+
if (fs.existsSync(target)) {
|
|
74
|
+
const manifest = readRecord(path.join(target, 'runtime.json'));
|
|
75
|
+
if (manifest?.package !== installed.package || JSON.stringify(manifest.files) !== JSON.stringify(files)
|
|
76
|
+
|| JSON.stringify(runtimeFiles(target)) !== JSON.stringify([...files, 'runtime.json'].sort())
|
|
77
|
+
|| !await matchesInstalledDesktop(retained, installed)) {
|
|
78
|
+
throw new Error(`已保留的 Codex 内核文件不完整:${target}`);
|
|
79
|
+
}
|
|
80
|
+
return retained;
|
|
81
|
+
}
|
|
82
|
+
fs.mkdirSync(root, { recursive: true });
|
|
83
|
+
const staging = path.join(root, `.${installed.package}.${randomUUID()}.tmp`);
|
|
84
|
+
fs.mkdirSync(staging);
|
|
85
|
+
const created = [];
|
|
86
|
+
try {
|
|
87
|
+
for (const directory of directories) fs.mkdirSync(path.join(staging, directory));
|
|
88
|
+
for (const name of files) {
|
|
89
|
+
const from = path.join(source, name), to = path.join(staging, name);
|
|
90
|
+
created.push(to);
|
|
91
|
+
try { fs.linkSync(from, to); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code !== 'EXDEV') throw error;
|
|
94
|
+
fs.copyFileSync(from, to);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!await matchesInstalledDesktop(path.join(staging, 'codex.exe'), installed)) {
|
|
98
|
+
throw new Error('保留的 Codex 内核与当前安装包不一致。');
|
|
99
|
+
}
|
|
100
|
+
const manifestPath = path.join(staging, 'runtime.json');
|
|
101
|
+
writeRecord(manifestPath, { package: installed.package, files });
|
|
102
|
+
created.push(manifestPath);
|
|
103
|
+
fs.renameSync(staging, target);
|
|
104
|
+
return retained;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
for (const file of created) fs.rmSync(file, { force: true });
|
|
107
|
+
for (const directory of directories.reverse()) {
|
|
108
|
+
const nested = path.join(staging, directory);
|
|
109
|
+
if (fs.existsSync(nested)) fs.rmdirSync(nested);
|
|
110
|
+
}
|
|
111
|
+
fs.rmdirSync(staging);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function pruneRetainedDesktopRuntimes(stateRoot, keepExecutable) {
|
|
117
|
+
const root = path.join(stateRoot, 'desktop-runtimes');
|
|
118
|
+
if (!fs.existsSync(root)) return;
|
|
119
|
+
const keep = path.resolve(path.dirname(keepExecutable)).toLowerCase();
|
|
120
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
121
|
+
if (!entry.isDirectory() || !/^OpenAI\.Codex_[\w.-]+$/.test(entry.name)) continue;
|
|
122
|
+
const directory = path.join(root, entry.name);
|
|
123
|
+
if (path.resolve(directory).toLowerCase() === keep || fs.lstatSync(directory).isSymbolicLink()) continue;
|
|
124
|
+
const manifest = readRecord(path.join(directory, 'runtime.json'));
|
|
125
|
+
const files = manifest?.files;
|
|
126
|
+
if (manifest?.package !== entry.name || !Array.isArray(files) || !files.includes('codex.exe')
|
|
127
|
+
|| !files.includes('codex-code-mode-host.exe') || new Set(files).size !== files.length
|
|
128
|
+
|| !files.every(name => typeof name === 'string' && name !== 'runtime.json'
|
|
129
|
+
&& path.resolve(directory, name).toLowerCase().startsWith(`${path.resolve(directory).toLowerCase()}${path.sep}`))) continue;
|
|
130
|
+
const expected = [...files, 'runtime.json'].sort();
|
|
131
|
+
if (JSON.stringify(runtimeFiles(directory)) !== JSON.stringify(expected)) continue;
|
|
132
|
+
if (!fs.lstatSync(path.join(directory, 'runtime.json')).isFile()
|
|
133
|
+
|| !files.every(name => fs.lstatSync(path.join(directory, name)).isFile())) continue;
|
|
134
|
+
for (const name of files) fs.unlinkSync(path.join(directory, name));
|
|
135
|
+
fs.unlinkSync(path.join(directory, 'runtime.json'));
|
|
136
|
+
for (const name of runtimeDirectories(files).reverse()) fs.rmdirSync(path.join(directory, name));
|
|
137
|
+
fs.rmdirSync(directory);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
15
140
|
|
|
16
141
|
// Resolve native binaries, never execute a shell shim from a background task.
|
|
17
142
|
export function discoverCodex(environment = process.env) {
|
|
@@ -92,11 +217,18 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
|
|
|
92
217
|
environment = { ...environment,
|
|
93
218
|
CODEX_HOME: environment.CODEX_HOME || saved?.codexHome || path.join(os.homedir(), '.codex'),
|
|
94
219
|
CODE_RELAX_CODEX_PATH: environment.CODE_RELAX_CODEX_PATH || (saved?.source === 'explicit' ? saved.executable : '') };
|
|
95
|
-
if (!refresh && saved?.executable &&
|
|
220
|
+
if (!refresh && saved?.executable && runtimeFilesAvailable(saved.executable)
|
|
96
221
|
&& saved.fingerprint === fingerprint(saved.executable)) return saved;
|
|
97
222
|
const candidates = [], rejected = [];
|
|
223
|
+
let installed = null;
|
|
224
|
+
try { installed = await installedDesktopRuntime(); }
|
|
225
|
+
catch (error) { rejected.push(`无法读取当前 Codex Desktop 安装包:${error.message}`); }
|
|
98
226
|
for (const candidate of discoverCodex(environment)) {
|
|
99
227
|
try {
|
|
228
|
+
if (!runtimeFilesAvailable(candidate.executable)) throw new Error('缺少同目录的 codex-code-mode-host.exe');
|
|
229
|
+
if (candidate.source === 'desktop' && !await matchesInstalledDesktop(candidate.executable, installed)) {
|
|
230
|
+
throw new Error('不是当前 Codex Desktop 安装包配套的 CLI');
|
|
231
|
+
}
|
|
100
232
|
const { stdout } = await exec(candidate.executable, ['--version'], { windowsHide: true, timeout: 10000 });
|
|
101
233
|
const version = /^codex-cli (\d+\.\d+\.\d+(?:-[\w.]+)?)/m.exec(stdout)?.[1];
|
|
102
234
|
if (!version) throw new Error('无法识别版本');
|
|
@@ -106,8 +238,10 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
|
|
|
106
238
|
candidates.sort((a, b) => Number(b.source === 'desktop') - Number(a.source === 'desktop') || compareVersions(b.version, a.version));
|
|
107
239
|
for (const candidate of candidates) {
|
|
108
240
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
241
|
+
const executable = candidate.source === 'desktop'
|
|
242
|
+
? await retainDesktopRuntime(candidate.executable, installed, stateRoot) : candidate.executable;
|
|
243
|
+
await verifyProtocol(executable, stateRoot);
|
|
244
|
+
const result = { ...candidate, executable, fingerprint: fingerprint(executable),
|
|
111
245
|
codexHome: path.resolve(environment.CODEX_HOME || path.join(os.homedir(), '.codex')) };
|
|
112
246
|
writeRecord(recordFile, result);
|
|
113
247
|
return result;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
3
|
+
$package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
4
|
+
if (-not $package) { 'null'; exit }
|
|
5
|
+
$resources = Join-Path $package.InstallLocation 'app\resources'
|
|
6
|
+
@{
|
|
7
|
+
package = $package.PackageFullName
|
|
8
|
+
executable = Join-Path $resources 'codex.exe'
|
|
9
|
+
codeModeHost = Join-Path $resources 'codex-code-mode-host.exe'
|
|
10
|
+
} | ConvertTo-Json -Compress
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Windows Script Host has no console. Keep the login task alive until the
|
|
2
|
+
// short-lived CLI has handed the resident service to the background broker.
|
|
3
|
+
if (WScript.Arguments.length !== 2) WScript.Quit(2);
|
|
4
|
+
var shell = new ActiveXObject('WScript.Shell');
|
|
5
|
+
var node = WScript.Arguments.Item(0);
|
|
6
|
+
var entry = WScript.Arguments.Item(1);
|
|
7
|
+
var command = '"' + node + '" "' + entry + '" start --json';
|
|
8
|
+
WScript.Quit(shell.Run(command, 0, true));
|