@walkhi/code-relax 0.1.0-beta.11 → 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.
@@ -17,9 +17,11 @@ import { setTimeout as delay } from 'node:timers/promises';
17
17
  import { requestRecovery, recoveryStatus, recoveryWarning } from '../src/shared-recovery.mjs';
18
18
  import { configureManagedEnvironment, clearManagedEnvironment } from '../src/platform/windows/user-environment.mjs';
19
19
  import { resolveCodexRuntime } from '../src/platform/windows/codex-runtime.mjs';
20
+ import { controlRequest } from '../src/service-control.mjs';
21
+ import { processAlive } from '../src/background-process.mjs';
20
22
 
21
23
  const help = `Code Relax(Windows)
22
- 用法:relax <setup|doctor|skill-install|skill-remove|uninstall|start|pair|status|stop> [--json]
24
+ 用法:relax <命令> [--json];下列命令均可用
23
25
  setup 部署并启动固定目录服务、受管 app-server 和登录自启动;安全配置 Desktop 后续接入
24
26
  start 启动或复用 Bridge 和 Codex 内核,并恢复已配置的私有中继
25
27
  shared-recover --confirm 重新打开 Desktop 并接入 Codex 内核;不强制重启已有内核
@@ -145,7 +147,8 @@ async function main() {
145
147
  const runtime = await resolveCodexRuntime(physicalStateRoot, { refresh: true });
146
148
  const desktopNeedsAttach = desktopBeforeSetup.running && !await host.sharedServiceUrl();
147
149
  const previous = hadInstalledService ? await service.status() : { processRunning: false };
148
- const previousRelay = await selfRelay('relay-status', stateRoot);
150
+ const legacyRelaySession = readRecord(path.join(stateRoot, 'self-relay-session.json'));
151
+ const previousRelay = legacyRelaySession ? await controlRequest(legacyRelaySession) : await selfRelay('relay-status', stateRoot);
149
152
  const resumeAfterUpdate = previous.processRunning || previousRelay.running;
150
153
  const executable = fs.realpathSync(process.execPath);
151
154
  const startInstalled = async () => {
@@ -165,7 +168,14 @@ async function main() {
165
168
  beforeActivate: async () => {
166
169
  if (desktopNeedsAttach) await desktopHost.closeDesktop(desktopBeforeSetup.desktop);
167
170
  stoppedForUpdate = resumeAfterUpdate;
168
- if (previousRelay.running) await selfRelay('relay-stop', stateRoot);
171
+ if (previousRelay.running && legacyRelaySession) {
172
+ await controlRequest(legacyRelaySession, 'POST');
173
+ const deadline = Date.now() + 10000;
174
+ while (processAlive(legacyRelaySession.pid)) {
175
+ if (Date.now() >= deadline) throw new Error('旧中继进程未在 10 秒内退出。');
176
+ await delay(100);
177
+ }
178
+ } else if (previousRelay.running) await selfRelay('relay-stop', stateRoot);
169
179
  if (previous.processRunning) await service.stop();
170
180
  },
171
181
  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
- if (!stateFile) throw new Error('RELAY_STATE_FILE is required');
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 records = deviceStore(stateFile).entries();
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 store = deviceStore(stateFile), record = store.entries().find(item => item.id === id);
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', clientHash: undefined, clientId: undefined, revision: record.revision + 1, updatedAt: new Date().toISOString() });
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
- throw new Error('此类请求需在工作站处理');
26
- }
27
- class Store {
28
- constructor(epoch, changed = () => {}) { this.epoch = epoch; this.changed = changed; this.requests = new Map(); }
29
- list(threadId) { return [...this.requests.values()].filter(r => r.params.threadId === threadId).map(r => ({ ...r })); }
30
- receive(message) {
31
- if (!['string', 'number'].includes(typeof message.id) || !message.params?.threadId || this.requests.has(message.id)) return;
32
- const request = { id: message.id, method: message.method, params: message.params, epoch: this.epoch, status: 'pending' };
33
- this.requests.set(message.id, request); this.changed(request.params.threadId);
34
- }
35
- notification(method, params) {
36
- if (method === 'serverRequest/resolved') {
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
- return { Store, decisions, validate, command, file, input };
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 runtime = actual
78
- ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) || { executable: actual.executable }
79
- : await resolveCodexRuntime(this.stateRoot, { environment: this.environment });
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 fingerprint = file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; };
16
+ export const codeModeHostPath = executable => path.join(path.dirname(executable), 'codex-code-mode-host.exe');
17
+ export const runtimeFilesAvailable = executable => fs.existsSync(executable) && fs.existsSync(codeModeHostPath(executable));
18
+ const fingerprint = executable => [executable, codeModeHostPath(executable)]
19
+ .map(file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; }).join('|');
20
+ const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
21
+ const desktopRuntimeScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'desktop-runtime.ps1');
22
+
23
+ async function fileHash(file) {
24
+ const hash = createHash('sha256');
25
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
26
+ return hash.digest('hex');
27
+ }
28
+
29
+ async function installedDesktopRuntime() {
30
+ const { stdout } = await exec(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
31
+ '-File', desktopRuntimeScript], { windowsHide: true, encoding: 'utf8', timeout: 30000 });
32
+ return JSON.parse(stdout.trim());
33
+ }
34
+
35
+ async function matchesInstalledDesktop(executable, installed) {
36
+ if (!installed || !runtimeFilesAvailable(executable) || !runtimeFilesAvailable(installed.executable)) return false;
37
+ const files = [[executable, installed.executable], [codeModeHostPath(executable), installed.codeModeHost]];
38
+ for (const [candidate, bundled] of files) {
39
+ if (fs.statSync(candidate).size !== fs.statSync(bundled).size || await fileHash(candidate) !== await fileHash(bundled)) return false;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ function runtimeFiles(directory, relative = '') {
45
+ const files = [];
46
+ for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true })) {
47
+ const name = path.join(relative, entry.name);
48
+ if (entry.isDirectory()) files.push(...runtimeFiles(directory, name));
49
+ else if (entry.isFile()) files.push(name);
50
+ else throw new Error(`Codex 内核目录包含不支持的文件类型:${name}`);
51
+ }
52
+ return files.sort();
53
+ }
54
+
55
+ function runtimeDirectories(files) {
56
+ const directories = new Set();
57
+ for (const file of files) {
58
+ let directory = path.dirname(file);
59
+ while (directory !== '.') { directories.add(directory); directory = path.dirname(directory); }
60
+ }
61
+ return [...directories].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
62
+ }
63
+
64
+ async function retainDesktopRuntime(executable, installed, stateRoot) {
65
+ if (!/^[\w.-]+$/.test(installed.package)) throw new Error('Codex Desktop 安装包名称无效。');
66
+ const source = path.dirname(executable);
67
+ const files = runtimeFiles(source);
68
+ if (files.includes('runtime.json')) throw new Error('Codex 内核目录与保留清单文件名冲突。');
69
+ const directories = runtimeDirectories(files);
70
+ const root = path.join(stateRoot, 'desktop-runtimes');
71
+ const target = path.join(root, installed.package);
72
+ const retained = path.join(target, 'codex.exe');
73
+ if (fs.existsSync(target)) {
74
+ const manifest = readRecord(path.join(target, 'runtime.json'));
75
+ if (manifest?.package !== installed.package || JSON.stringify(manifest.files) !== JSON.stringify(files)
76
+ || JSON.stringify(runtimeFiles(target)) !== JSON.stringify([...files, 'runtime.json'].sort())
77
+ || !await matchesInstalledDesktop(retained, installed)) {
78
+ throw new Error(`已保留的 Codex 内核文件不完整:${target}`);
79
+ }
80
+ return retained;
81
+ }
82
+ fs.mkdirSync(root, { recursive: true });
83
+ const staging = path.join(root, `.${installed.package}.${randomUUID()}.tmp`);
84
+ fs.mkdirSync(staging);
85
+ const created = [];
86
+ try {
87
+ for (const directory of directories) fs.mkdirSync(path.join(staging, directory));
88
+ for (const name of files) {
89
+ const from = path.join(source, name), to = path.join(staging, name);
90
+ created.push(to);
91
+ try { fs.linkSync(from, to); }
92
+ catch (error) {
93
+ if (error.code !== 'EXDEV') throw error;
94
+ fs.copyFileSync(from, to);
95
+ }
96
+ }
97
+ if (!await matchesInstalledDesktop(path.join(staging, 'codex.exe'), installed)) {
98
+ throw new Error('保留的 Codex 内核与当前安装包不一致。');
99
+ }
100
+ const manifestPath = path.join(staging, 'runtime.json');
101
+ writeRecord(manifestPath, { package: installed.package, files });
102
+ created.push(manifestPath);
103
+ fs.renameSync(staging, target);
104
+ return retained;
105
+ } catch (error) {
106
+ for (const file of created) fs.rmSync(file, { force: true });
107
+ for (const directory of directories.reverse()) {
108
+ const nested = path.join(staging, directory);
109
+ if (fs.existsSync(nested)) fs.rmdirSync(nested);
110
+ }
111
+ fs.rmdirSync(staging);
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ export function pruneRetainedDesktopRuntimes(stateRoot, keepExecutable) {
117
+ const root = path.join(stateRoot, 'desktop-runtimes');
118
+ if (!fs.existsSync(root)) return;
119
+ const keep = path.resolve(path.dirname(keepExecutable)).toLowerCase();
120
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
121
+ if (!entry.isDirectory() || !/^OpenAI\.Codex_[\w.-]+$/.test(entry.name)) continue;
122
+ const directory = path.join(root, entry.name);
123
+ if (path.resolve(directory).toLowerCase() === keep || fs.lstatSync(directory).isSymbolicLink()) continue;
124
+ const manifest = readRecord(path.join(directory, 'runtime.json'));
125
+ const files = manifest?.files;
126
+ if (manifest?.package !== entry.name || !Array.isArray(files) || !files.includes('codex.exe')
127
+ || !files.includes('codex-code-mode-host.exe') || new Set(files).size !== files.length
128
+ || !files.every(name => typeof name === 'string' && name !== 'runtime.json'
129
+ && path.resolve(directory, name).toLowerCase().startsWith(`${path.resolve(directory).toLowerCase()}${path.sep}`))) continue;
130
+ const expected = [...files, 'runtime.json'].sort();
131
+ if (JSON.stringify(runtimeFiles(directory)) !== JSON.stringify(expected)) continue;
132
+ if (!fs.lstatSync(path.join(directory, 'runtime.json')).isFile()
133
+ || !files.every(name => fs.lstatSync(path.join(directory, name)).isFile())) continue;
134
+ for (const name of files) fs.unlinkSync(path.join(directory, name));
135
+ fs.unlinkSync(path.join(directory, 'runtime.json'));
136
+ for (const name of runtimeDirectories(files).reverse()) fs.rmdirSync(path.join(directory, name));
137
+ fs.rmdirSync(directory);
138
+ }
139
+ }
15
140
 
16
141
  // Resolve native binaries, never execute a shell shim from a background task.
17
142
  export function discoverCodex(environment = process.env) {
@@ -92,11 +217,18 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
92
217
  environment = { ...environment,
93
218
  CODEX_HOME: environment.CODEX_HOME || saved?.codexHome || path.join(os.homedir(), '.codex'),
94
219
  CODE_RELAX_CODEX_PATH: environment.CODE_RELAX_CODEX_PATH || (saved?.source === 'explicit' ? saved.executable : '') };
95
- if (!refresh && saved?.executable && fs.existsSync(saved.executable)
220
+ if (!refresh && saved?.executable && runtimeFilesAvailable(saved.executable)
96
221
  && saved.fingerprint === fingerprint(saved.executable)) return saved;
97
222
  const candidates = [], rejected = [];
223
+ let installed = null;
224
+ try { installed = await installedDesktopRuntime(); }
225
+ catch (error) { rejected.push(`无法读取当前 Codex Desktop 安装包:${error.message}`); }
98
226
  for (const candidate of discoverCodex(environment)) {
99
227
  try {
228
+ if (!runtimeFilesAvailable(candidate.executable)) throw new Error('缺少同目录的 codex-code-mode-host.exe');
229
+ if (candidate.source === 'desktop' && !await matchesInstalledDesktop(candidate.executable, installed)) {
230
+ throw new Error('不是当前 Codex Desktop 安装包配套的 CLI');
231
+ }
100
232
  const { stdout } = await exec(candidate.executable, ['--version'], { windowsHide: true, timeout: 10000 });
101
233
  const version = /^codex-cli (\d+\.\d+\.\d+(?:-[\w.]+)?)/m.exec(stdout)?.[1];
102
234
  if (!version) throw new Error('无法识别版本');
@@ -106,8 +238,10 @@ export async function resolveCodexRuntime(stateRoot, { refresh = false, environm
106
238
  candidates.sort((a, b) => Number(b.source === 'desktop') - Number(a.source === 'desktop') || compareVersions(b.version, a.version));
107
239
  for (const candidate of candidates) {
108
240
  try {
109
- await verifyProtocol(candidate.executable, stateRoot);
110
- const result = { ...candidate, fingerprint: fingerprint(candidate.executable),
241
+ const executable = candidate.source === 'desktop'
242
+ ? await retainDesktopRuntime(candidate.executable, installed, stateRoot) : candidate.executable;
243
+ await verifyProtocol(executable, stateRoot);
244
+ const result = { ...candidate, executable, fingerprint: fingerprint(executable),
111
245
  codexHome: path.resolve(environment.CODEX_HOME || path.join(os.homedir(), '.codex')) };
112
246
  writeRecord(recordFile, result);
113
247
  return result;
@@ -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));
@@ -5,7 +5,9 @@ $ErrorActionPreference = 'Stop'
5
5
  $taskName = 'Code Relax Bridge'
6
6
  $taskSource = '@walkhi/code-relax'
7
7
  $taskPath = '\' + $taskName
8
- $arguments = '"' + $Entry + '" start --json'
8
+ $launcher = Join-Path $PSScriptRoot 'resident-start.js'
9
+ $wscript = Join-Path $env:WINDIR 'System32\wscript.exe'
10
+ $arguments = '//B //Nologo "' + $launcher + '" "' + $NodePath + '" "' + $Entry + '"'
9
11
  $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
10
12
 
11
13
  $scheduler = New-Object -ComObject 'Schedule.Service'
@@ -30,7 +32,8 @@ if ($Action -ne 'status' -and $legacyExists) {
30
32
  }
31
33
 
32
34
  if ($Action -eq 'enable') {
33
- if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry)) { throw 'Installed runtime is missing.' }
35
+ if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry) -or
36
+ -not (Test-Path -LiteralPath $launcher) -or -not (Test-Path -LiteralPath $wscript)) { throw 'Installed runtime is missing.' }
34
37
  $definition = $scheduler.NewTask(0)
35
38
  $definition.RegistrationInfo.Description = 'Start the Code Relax Bridge when this user signs in.'
36
39
  $definition.RegistrationInfo.Source = $taskSource
@@ -48,7 +51,7 @@ if ($Action -eq 'enable') {
48
51
  $trigger.UserId = $currentUser
49
52
  $trigger.Enabled = $true
50
53
  $exec = $definition.Actions.Create(0) # TASK_ACTION_EXEC
51
- $exec.Path = $NodePath
54
+ $exec.Path = $wscript
52
55
  $exec.Arguments = $arguments
53
56
  $exec.WorkingDirectory = $env:USERPROFILE
54
57
  [void]$root.RegisterTaskDefinition($taskName, $definition, 6, $null, $null, 3, $null) # TASK_CREATE_OR_UPDATE