@walkhi/code-relax 0.1.0-beta.2 → 0.1.0-beta.21
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 +55 -71
- package/dist/bin/codex-remote.mjs +150 -126
- package/dist/bin/manage-relay-admin.mjs +20 -0
- package/dist/bin/migrate-relay-db.mjs +107 -0
- package/dist/bin/self-relay-admin.mjs +8 -0
- package/dist/bin/self-relay-demo.mjs +2 -2
- package/dist/bin/self-relay-server.mjs +18 -10
- package/dist/shared/app-server-events.cjs +42 -15
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/skills/code-relax/SKILL.md +7 -6
- package/dist/src/desktop-launcher.mjs +16 -21
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +52 -9
- package/dist/src/managed-app-server.mjs +44 -13
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +9 -11
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/background-process.mjs +1 -1
- package/dist/src/platform/windows/codex-runtime.mjs +251 -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-runtime.ps1 +10 -0
- 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-start.js +20 -0
- package/dist/src/platform/windows/resident-startup.ps1 +76 -20
- package/dist/src/platform/windows/service-host.mjs +4 -16
- package/dist/src/platform/windows/start-hidden-console.ps1 +10 -5
- package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
- package/dist/src/platform/windows/user-environment.mjs +1 -1
- 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 +79 -0
- package/dist/src/self-relay/admin-server.mjs +66 -0
- package/dist/src/self-relay/admin-state.mjs +82 -19
- package/dist/src/self-relay/admin-style.mjs +9 -0
- package/dist/src/self-relay/admin-usage-page.mjs +28 -0
- package/dist/src/self-relay/connector.mjs +243 -30
- package/dist/src/self-relay/demo.mjs +3 -4
- package/dist/src/self-relay/email-accounts.mjs +141 -0
- package/dist/src/self-relay/lifecycle.mjs +99 -142
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/p2p-probe.mjs +1 -1
- package/dist/src/self-relay/server.mjs +335 -59
- package/dist/src/self-relay/usage-diagnostics.mjs +46 -0
- package/dist/src/self-relay/usage-meter.mjs +125 -0
- package/dist/src/server.mjs +731 -1439
- package/dist/src/service-control.mjs +4 -4
- package/dist/src/service-doctor.mjs +27 -29
- package/dist/src/service-health.mjs +2 -2
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/service-lifecycle.mjs +19 -19
- package/dist/src/shared-app-server.mjs +277 -270
- package/dist/src/shared-catalog.mjs +1 -11
- 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 +17 -18
- package/tools/postinstall.mjs +69 -15
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -358
- 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 -20
- package/dist/src/thread-catalog.mjs +0 -47
- package/dist/tools/find-desktop-pipe.mjs +0 -10
- package/dist/web/activity-view.js +0 -283
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -57
- package/dist/web/chat-transport.js +0 -202
- package/dist/web/chat.css +0 -782
- package/dist/web/chat.js +0 -3830
- package/dist/web/client-platform.js +0 -11
- package/dist/web/community-view.js +0 -20
- package/dist/web/community.css +0 -77
- package/dist/web/community.html +0 -37
- package/dist/web/composer-controller.js +0 -101
- package/dist/web/conversation-controller.js +0 -99
- package/dist/web/disclosure-state-controller.js +0 -95
- package/dist/web/draft-controller.js +0 -103
- package/dist/web/harmony-platform.js +0 -52
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -240
- package/dist/web/history-controller.js +0 -167
- package/dist/web/index.html +0 -325
- package/dist/web/lan-session.js +0 -122
- package/dist/web/link-action-controller.js +0 -212
- package/dist/web/message-send-controller.js +0 -177
- package/dist/web/message-view.js +0 -98
- package/dist/web/p2p-data-channel.js +0 -50
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -123
- package/dist/web/page-resume.js +0 -28
- package/dist/web/pending-message-store.js +0 -108
- package/dist/web/queue-controller.js +0 -82
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -129
- package/dist/web/server-requests.js +0 -67
- package/dist/web/station-connection-controller.js +0 -75
- package/dist/web/task-list-view.js +0 -296
- package/dist/web/thread-attention-controller.js +0 -124
- package/dist/web/thread-context-controller.js +0 -30
- package/dist/web/thread-list-controller.js +0 -61
- package/dist/web/thread-list-sync.js +0 -86
- package/dist/web/thread-title-controller.js +0 -57
- package/dist/web/timeline-formatters.js +0 -249
- package/dist/web/timeline-reducer.js +0 -81
- package/dist/web/timeline-renderer.js +0 -161
- package/dist/web/timeline-scroll-controller.js +0 -50
- package/dist/web/timeline-sync.js +0 -67
- package/dist/web/usage-controller.js +0 -258
- package/dist/web/vendor/README.md +0 -14
- package/dist/web/vendor/highlight-powershell.min.js +0 -39
- package/dist/web/vendor/highlight.LICENSE.txt +0 -29
- package/dist/web/vendor/highlight.min.js +0 -1262
- package/dist/web/vendor/lucide.LICENSE.txt +0 -17
|
@@ -0,0 +1,107 @@
|
|
|
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', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
|
|
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
|
+
if (versions.some(row => row.version === '003_relay_usage')) names.push('relay_admin_emails', 'relay_usage_settings', 'relay_usage_totals', 'relay_usage_computer_totals', 'relay_usage_entitlements', 'relay_usage_budgets', 'relay_usage_computer_budgets', 'relay_usage_sessions');
|
|
44
|
+
if (versions.some(row => row.version === '004_relay_usage_actions')) names.push('relay_usage_actions');
|
|
45
|
+
if (versions.some(row => row.version === '005_relay_usage_diagnostics')) names.push('relay_usage_diagnostics');
|
|
46
|
+
const counts = {};
|
|
47
|
+
for (const name of names) { const [rows] = await db.query(`SELECT COUNT(*) AS count FROM ${name}`); counts[name] = rows[0].count; }
|
|
48
|
+
if (process.env.RELAY_STATE_FILE && process.env.RELAY_ADMIN_STATE_FILE) {
|
|
49
|
+
const devices = deviceStore(process.env.RELAY_STATE_FILE).entries();
|
|
50
|
+
const admin = relayAdminState(process.env.RELAY_ADMIN_STATE_FILE, 8).read();
|
|
51
|
+
const expected = { computers: devices.length,
|
|
52
|
+
mobile_authorizations: devices.reduce((sum, item) => sum + item.clients.length, 0),
|
|
53
|
+
official_computers: devices.filter(item => item.officialComputer).length,
|
|
54
|
+
announcements: admin.announcements.length, relay_settings: 1 };
|
|
55
|
+
if (Object.keys(expected).some(name => counts[name] !== expected[name])) throw new Error('Imported row counts differ from JSON');
|
|
56
|
+
const [saved] = await db.query('SELECT id,host_hash,status,revision FROM computers');
|
|
57
|
+
const original = new Map(devices.map(item => [item.id, item]));
|
|
58
|
+
for (const row of saved) {
|
|
59
|
+
const item = original.get(row.id);
|
|
60
|
+
if (!item || item.hostHash !== row.host_hash || item.status !== row.status || item.revision !== Number(row.revision)) {
|
|
61
|
+
throw new Error('Imported computer identity differs from JSON');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const [authorized] = await db.query('SELECT computer_id,client_id,credential_hash FROM mobile_authorizations');
|
|
65
|
+
for (const row of authorized) {
|
|
66
|
+
const item = original.get(row.computer_id)?.clients.find(client => client.id === row.client_id);
|
|
67
|
+
if (!item || item.hash !== row.credential_hash) throw new Error('Imported mobile authorization differs from JSON');
|
|
68
|
+
}
|
|
69
|
+
const [settings] = await db.query('SELECT max_relay_sessions FROM relay_settings WHERE id=1');
|
|
70
|
+
if (settings[0]?.max_relay_sessions !== admin.maxRelaySessions) throw new Error('Imported relay setting differs from JSON');
|
|
71
|
+
console.log(JSON.stringify({ verified: true, counts }));
|
|
72
|
+
} else console.log(JSON.stringify(counts));
|
|
73
|
+
} else {
|
|
74
|
+
const stateFile = process.env.RELAY_STATE_FILE, adminFile = process.env.RELAY_ADMIN_STATE_FILE;
|
|
75
|
+
if (!stateFile || !adminFile) throw new Error('RELAY_STATE_FILE and RELAY_ADMIN_STATE_FILE required for import');
|
|
76
|
+
const devices = deviceStore(stateFile).entries();
|
|
77
|
+
const admin = relayAdminState(adminFile, 8).read();
|
|
78
|
+
const [existing] = await db.query('SELECT (SELECT COUNT(*) FROM computers) AS computers, (SELECT COUNT(*) FROM relay_settings) AS settings');
|
|
79
|
+
if (existing[0].computers || existing[0].settings) throw new Error('Import target is not empty');
|
|
80
|
+
await db.beginTransaction();
|
|
81
|
+
try {
|
|
82
|
+
for (const item of devices) {
|
|
83
|
+
await db.execute('INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at) VALUES (?,?,?,?,?,?)',
|
|
84
|
+
[item.id, item.hostHash, item.status, item.revision, sqlTime(item.createdAt), sqlTime(item.updatedAt)]);
|
|
85
|
+
for (const client of item.clients) await db.execute(
|
|
86
|
+
'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at) VALUES (?,?,?,?,?)',
|
|
87
|
+
[item.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt)]);
|
|
88
|
+
if (item.officialComputer) {
|
|
89
|
+
const official = item.officialComputer;
|
|
90
|
+
await db.execute('INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at) VALUES (?,?,?,?,?)',
|
|
91
|
+
[item.id, official.environmentId, official.name, official.hostname, sqlTime(official.updatedAt)]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await db.execute('INSERT INTO relay_settings (id,max_relay_sessions,updated_at) VALUES (1,?,UTC_TIMESTAMP(3))', [admin.maxRelaySessions]);
|
|
95
|
+
for (const item of admin.announcements) await db.execute(
|
|
96
|
+
'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
|
|
97
|
+
[item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
|
|
98
|
+
item.publishedAt ? sqlTime(item.publishedAt) : null]);
|
|
99
|
+
await db.commit();
|
|
100
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
101
|
+
console.log(JSON.stringify({ imported: true, computers: devices.length,
|
|
102
|
+
mobileAuthorizations: devices.reduce((sum, item) => sum + item.clients.length, 0),
|
|
103
|
+
officialComputers: devices.filter(item => item.officialComputer).length,
|
|
104
|
+
announcements: admin.announcements.length }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} finally { await db.end(); }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { startAdminServer } from '../src/self-relay/admin-server.mjs';
|
|
2
|
+
|
|
3
|
+
const port = Number(process.env.RELAY_ADMIN_PORT || 45851);
|
|
4
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error('Invalid RELAY_ADMIN_PORT');
|
|
5
|
+
const server = await startAdminServer({ port });
|
|
6
|
+
console.log(JSON.stringify({ service: 'codex-relay-admin', port: server.port }));
|
|
7
|
+
await new Promise(resolve => { process.once('SIGTERM', resolve); process.once('SIGINT', resolve); });
|
|
8
|
+
await server.close();
|
|
@@ -3,7 +3,7 @@ import { startRelayDemo } from '../src/self-relay/demo.mjs';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
|
|
6
|
-
const mode =
|
|
6
|
+
const mode = 'lan-shared';
|
|
7
7
|
let remoteRelay;
|
|
8
8
|
if (process.env.RELAY_URL) {
|
|
9
9
|
let hostKey;
|
|
@@ -25,7 +25,7 @@ try {
|
|
|
25
25
|
const health = await client.request('/api/health');
|
|
26
26
|
if (health.status !== 200 || health.body.transportMode !== mode) throw new Error('Relay validation failed');
|
|
27
27
|
console.log(JSON.stringify({ ok: true, simulated: true, mode, relay: demo.relay.url,
|
|
28
|
-
bridge: demo.bridgeUrl, message: '中继 → 连接器 →
|
|
28
|
+
bridge: demo.bridgeUrl, message: '中继 → 连接器 → 模拟桌面服务已连通;未连接 Codex,未执行任务。' }, null, 2));
|
|
29
29
|
if (!process.argv.includes('--check')) {
|
|
30
30
|
console.log('验证连接保持运行。Ctrl+C 停止全部模拟进程内服务。');
|
|
31
31
|
await new Promise(resolve => { process.once('SIGINT', resolve); process.once('SIGTERM', resolve); });
|
|
@@ -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,25 @@ 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,
|
|
55
|
+
usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
|
|
48
56
|
publicRelayUrl: process.env.RELAY_PUBLIC_URL || '', appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || '',
|
|
49
57
|
log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
|
|
50
58
|
console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
(function (root, factory) {
|
|
2
2
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
3
|
else root.CodexAppServerEvents = factory();
|
|
4
|
-
})(globalThis, function () {
|
|
4
|
+
})(globalThis, function () {
|
|
5
5
|
function mergeItems(existing, incoming = []) {
|
|
6
6
|
for (const source of incoming) {
|
|
7
7
|
const item = compactItem(source);
|
|
8
8
|
const index = existing.findIndex(current => current.id === item.id);
|
|
9
9
|
if (index < 0) existing.push(item);
|
|
10
10
|
else existing[index] = { ...existing[index], ...item };
|
|
11
|
-
}
|
|
11
|
+
}
|
|
12
12
|
return existing;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -137,6 +137,18 @@
|
|
|
137
137
|
}
|
|
138
138
|
return match;
|
|
139
139
|
}
|
|
140
|
+
function catalogThreadProjectId(thread, projects) {
|
|
141
|
+
if (thread.projectId) return thread.projectId;
|
|
142
|
+
const local = thread.environments?.find(environment => environment.environmentId === 'local');
|
|
143
|
+
if (local && Array.isArray(local.runtimeWorkspaceRoots)) {
|
|
144
|
+
for (const root of local.runtimeWorkspaceRoots) {
|
|
145
|
+
const projectId = threadProjectId({ cwd: root }, projects);
|
|
146
|
+
if (projectId) return projectId;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return threadProjectId({ cwd: thread.cwd }, projects);
|
|
151
|
+
}
|
|
140
152
|
function projectlessThreadTarget(cwd = '') {
|
|
141
153
|
return { projectId: null, runtimeWorkspaceRoots: [], ...(cwd ? { cwd } : {}) };
|
|
142
154
|
}
|
|
@@ -238,16 +250,17 @@
|
|
|
238
250
|
}
|
|
239
251
|
return optionsCache.context(threadId, contexts.get(threadId));
|
|
240
252
|
}
|
|
241
|
-
async function createThread(request, target, input, settings) {
|
|
242
|
-
const { thread } = await request('thread/start', target);
|
|
243
|
-
await request('turn/start', { threadId:thread.id, input,
|
|
253
|
+
async function createThread(request, target, input, settings) {
|
|
254
|
+
const { thread } = await request('thread/start', target);
|
|
255
|
+
const started = await request('turn/start', { threadId:thread.id, input,
|
|
256
|
+
...mobileTurnSource(settings.clientUserMessageId),
|
|
244
257
|
...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
|
|
245
258
|
...(settings.model ? { model:settings.model } : {}),
|
|
246
259
|
...(settings.thinking ? { effort:settings.thinking } : {}),
|
|
247
260
|
...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
|
|
248
|
-
return { threadId:thread.id };
|
|
261
|
+
return { threadId:thread.id, turnId:started?.turn?.id || null };
|
|
249
262
|
}
|
|
250
|
-
async function readQueueItems(request, threadId) {
|
|
263
|
+
async function readQueueItems(request, threadId) {
|
|
251
264
|
const items = []; let cursor = null;
|
|
252
265
|
do {
|
|
253
266
|
const result = await request('thread/queue/list', { threadId, cursor });
|
|
@@ -268,14 +281,18 @@
|
|
|
268
281
|
await request('thread/queue/add', { threadId, input, clientUserMessageId });
|
|
269
282
|
return readQueue(request, threadId);
|
|
270
283
|
}
|
|
271
|
-
async function changeQueue(request, threadId, queuedSubmissionId, action) {
|
|
284
|
+
async function changeQueue(request, threadId, queuedSubmissionId, action) {
|
|
272
285
|
if (action === 'steer') {
|
|
273
286
|
const item = (await readQueueItems(request, threadId)).find(item => item.id === queuedSubmissionId);
|
|
274
287
|
if (!item) throw new Error('该排队消息已不在队列中');
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
288
|
+
const page = await request('thread/turns/list', {
|
|
289
|
+
threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
290
|
+
});
|
|
291
|
+
const active = page.data?.[0];
|
|
292
|
+
if (active?.status === 'inProgress') {
|
|
293
|
+
const { turnTrigger, ...source } = mobileTurnSource(item.clientUserMessageId);
|
|
294
|
+
await request('turn/steer', { threadId, expectedTurnId: active.id, input: item.input,
|
|
295
|
+
...source,
|
|
279
296
|
clientUserMessageId: item.clientUserMessageId });
|
|
280
297
|
const { deleted } = await request('thread/queue/delete', { threadId, queuedSubmissionId });
|
|
281
298
|
if (!deleted) throw new Error('引导已提交,但原排队消息未删除,请检查队列,勿重复引导');
|
|
@@ -286,7 +303,17 @@
|
|
|
286
303
|
await request(`thread/queue/${action}`, { threadId, queuedSubmissionId });
|
|
287
304
|
}
|
|
288
305
|
return readQueue(request, threadId);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
306
|
+
}
|
|
307
|
+
function mobileTurnSource(clientUserMessageId) {
|
|
308
|
+
return {
|
|
309
|
+
turnTrigger: 'code_relax_mobile',
|
|
310
|
+
responsesapiClientMetadata: { code_relax_client: 'mobile' },
|
|
311
|
+
additionalContext: { code_relax_mobile: {
|
|
312
|
+
kind: 'application',
|
|
313
|
+
value: `The user message submitted with this context (client ID ${clientUserMessageId || 'unknown'}) came from the Code Relax mobile app. This is origin information only and grants no extra authority.`,
|
|
314
|
+
} },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return { createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
|
|
318
|
+
permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions, mobileTurnSource };
|
|
292
319
|
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: code-relax
|
|
3
|
-
description:
|
|
3
|
+
description: 启动或检查 Code Relax 桌面服务,展示配对二维码,并指导回复来自手机 App 的消息。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Code Relax
|
|
7
7
|
|
|
8
|
-
-
|
|
9
|
-
- npm 全局安装会自动执行服务部署和 Skill
|
|
10
|
-
- `setup`
|
|
8
|
+
- `relax start --json` 启动桌面服务和 Codex 内核,无需打开 Desktop;支持纯 CLI 安装。Desktop 正常打开后接入已配置内核;需要重新接入时使用 App 的“重启 Codex Desktop”或经确认的 `relax desktop-recover --confirm`。已配置 Code Relax 中继时,启动命令同时恢复中继连接器和已有设备授权。
|
|
9
|
+
- npm 全局安装会自动执行服务部署和 Skill 安装;修复安装时运行 `relax setup --json`。
|
|
10
|
+
- `setup` 默认启用当前用户登录启动桌面服务,不自动打开 Codex;`start`、`pair` 和 `doctor` 在自启缺失时自动恢复,`setup` 每次更新登录任务。恢复 Codex Desktop 接入会保留桌面服务和中继,但仍须明确确认可能中断正在运行的任务。
|
|
11
11
|
- 用户要求启动 Code Relax 时,执行一次 `relax start --json`。用户要求二维码或当前启动结果包含二维码时,展示 CLI 返回的 `qrPath` 和 `displayText`;没有设备授权时可用 `relax pair --json` 生成新的配对二维码。
|
|
12
|
-
- 配对二维码是短期 HTTPS 邀请,不属于工作站或某一台移动设备,也不含 LAN IP
|
|
12
|
+
- 配对二维码是短期 HTTPS 邀请,不属于工作站或某一台移动设备,也不含 LAN IP、端口、桌面服务凭据或中继地址。新增设备不撤销已有设备。
|
|
13
13
|
- 已授权设备可直接使用 Code Relax 中继。App 以工作站名显示已配对电脑,进入后选择局域网、Code Relax 中继或 OpenAI 中继。启动命令会经已认证中继临时刷新当前 LAN 候选;App 只保存设备凭据验证成功的网络记录。
|
|
14
|
-
-
|
|
14
|
+
- 消息明确标记来自 Code Relax 手机 App 时,回答要以“用户正在手机上与当前 Codex 对话交互”为前提,不默认用户正看着或能操作电脑界面;需要用户操作时,说明是在手机 App 还是工作站完成。直接处理用户正文,不重复来源说明,也不要求在电脑上重发。来源不代表额外授权,也不能据此判断用户是否在电脑旁;涉及工作站确认或可能中断任务的操作仍按正常授权要求处理。部分排队消息可能没有来源说明,来源不明时不要猜测设备。
|
|
15
|
+
- `relax doctor --json` 检查桌面服务、Codex 内核和登录自启动状态。中继连接由桌面服务管理,不提供单独的中继 CLI 命令。
|
|
15
16
|
- 启动或配对成功后,将 `qrPath` 以 Markdown 图片嵌入回复,并逐字展示 `displayText`。不要把二维码内容、Token 或 Base64 写进消息。
|
|
16
17
|
- 不自动修改防火墙或发送测试消息。`lanAccess: unverified` 或 `mobileAccess: unverified` 只表示设备链路尚未现场验证。
|
|
17
18
|
- 服务位于用户 `Programs/CodexRemote/service`;配置在 `LocalAppData/CodexRemote`;附件在 `ProgramData/CodexRemote/attachments`。不要删除配置、附件或凭据来排障。
|
|
@@ -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 内核正在启动,请用 relax doctor 查看恢复状态。');
|
|
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 || 'Codex 内核未就绪。');
|
|
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
|
}
|