@walkhi/code-relax 0.1.0-beta.4 → 0.1.0-beta.6

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 CHANGED
@@ -80,4 +80,4 @@ npm uninstall -g @walkhi/code-relax
80
80
 
81
81
  ## 发布边界
82
82
 
83
- registry 当前公开包为 `@walkhi/code-relax@0.1.0-beta.3`,主 CLI 为 `relax`,并保留 `code-relax` 兼容入口。该版将 WMI 后的第二层 PowerShell broker 改为 Node worker,并加入 `%LOCALAPPDATA%\CodexRemote\install.log` 持久安装日志;本机启动交接、npm 打包和公开 registry 已验证,仍需在原故障电脑复核。详细规则见 [npm 分发](../docs/npm-distribution.md)。
83
+ registry 当前公开包为 `@walkhi/code-relax@0.1.0-beta.6`,主 CLI 为 `relax`,并保留 `code-relax` 兼容入口。该版为受管 app-server 记录启动代次,升级发现旧启动链时自动重建进程,使 beta5 的隐藏控制台修复真正生效;`doctor` 会区分安装版本与运行代次。通信协议仍为 7。详细规则见 [npm 分发](../docs/npm-distribution.md)。
@@ -23,7 +23,7 @@ const help = `Code Relax(Windows)
23
23
  setup 部署并启动固定目录服务、受管 app-server 和登录自启动;安全配置 Desktop 后续接入
24
24
  start 启动或复用 Bridge,并恢复已配置的私有中继
25
25
  shared-start 启动或复用完整控制,再启动 Bridge 和已配置的私有中继
26
- shared-recover --confirm 启用或重新启动完整控制,保留 Bridge 和中继,可能中断当前任务
26
+ shared-recover --confirm 重新打开 Desktop 并接入完整控制;不强制重启已有 app-server
27
27
  shared-recovery-status 查看最近一次恢复的实际阶段和结果
28
28
  autostart-enable / autostart-disable / autostart-status 管理当前用户登录时启动 Bridge
29
29
  desktop-start 恢复基础控制,再启动 Bridge(切换前须自行退出 Desktop)
@@ -154,8 +154,8 @@ async function main() {
154
154
  result.environment = environment;
155
155
  result.skill = skill.managed ? removeSkill() : { removed: false, message: '未移除自定义或未安装的 Skill。' };
156
156
  result.message = environment?.preserved
157
- ? '固定目录服务已卸载;检测到用户自定义的 CODEX_APP_SERVER_WS_URL,已保留。配置、附件也已保留。npm 命令入口可再用 npm uninstall -g @walkhi/code-relax 移除。'
158
- : '固定目录服务已卸载;Code Relax 设置的 app-server 环境变量已清理,其他配置和附件已保留。npm 命令入口可再用 npm uninstall -g @walkhi/code-relax 移除。';
157
+ ? '固定目录服务已卸载;检测到用户自定义的 CODEX_APP_SERVER_WS_URL,已保留。配置、附件和可能仍在执行任务的 app-server 也已保留;重启 Windows可结束该进程。npm 命令入口可再用 npm uninstall -g @walkhi/code-relax 移除。'
158
+ : '固定目录服务已卸载;Code Relax 设置的 app-server 环境变量已清理,其他配置、附件和可能仍在执行任务的 app-server 已保留;重启 Windows可结束该进程。npm 命令入口可再用 npm uninstall -g @walkhi/code-relax 移除。';
159
159
  console.log(json ? JSON.stringify(result) : `${result.message}\n${result.skill.message}`);
160
160
  return;
161
161
  }
@@ -205,18 +205,36 @@ async function main() {
205
205
  if (!bridge.running || bridge.managedAppServer?.state !== 'ready') {
206
206
  throw new Error(bridge.managedAppServer?.error || 'Bridge 已安装,但受管 app-server 未就绪;未写入 Desktop 环境变量。');
207
207
  }
208
+ let managedAppServerMigrated = false;
209
+ if (bridge.managedAppServer.launcherRestartRequired) {
210
+ await service.restartManagedAppServer();
211
+ const migrationDeadline = Date.now() + 40_000;
212
+ do {
213
+ await delay(250);
214
+ bridge = await service.status();
215
+ if (bridge.managedAppServer?.restart?.status === 'failed') {
216
+ throw new Error(bridge.managedAppServer.restart.error || '受管 app-server 启动链迁移失败。');
217
+ }
218
+ } while (Date.now() < migrationDeadline && (!bridge.running || bridge.managedAppServer?.state !== 'ready' || bridge.managedAppServer.launcherRestartRequired));
219
+ if (!bridge.running || bridge.managedAppServer?.state !== 'ready' || bridge.managedAppServer.launcherRestartRequired) {
220
+ throw new Error('受管 app-server 未在 40 秒内完成启动链迁移。');
221
+ }
222
+ managedAppServerMigrated = true;
223
+ }
208
224
  const autostart = await residentStartup(installed, 'enable');
209
225
  const environment = await configureManagedEnvironment(physicalStateRoot);
210
226
  installed.autostart = autostart;
211
227
  installed.restarted = resumeAfterUpdate;
212
228
  installed.environment = environment;
213
229
  installed.managedAppServer = bridge.managedAppServer;
230
+ installed.managedAppServerMigrated = managedAppServerMigrated;
214
231
  installed.desktop = bridge.desktop;
215
232
  installed.desktopRestartRecommended = bridge.desktop?.state === 'ordinary';
233
+ const migrationText = managedAppServerMigrated ? '已自动重建旧启动链的 app-server;安装前尚未完成的任务可能已中断。\n' : '';
216
234
  const restartText = installed.desktopRestartRecommended
217
235
  ? 'Codex 当前仍在使用原连接。可以在手机端选择“立即重启(推荐)”,或稍后自行完全退出再启动。'
218
236
  : '下次正常启动 Codex 时会连接受管 app-server。';
219
- console.log(json ? JSON.stringify({ ...installed, nextSteps: desktopSetupSteps, displayText: `${desktopSetupSteps.join('\n')}\n${restartText}` }) : `服务已安装:${installed.serviceRoot}\n使用已安装的 Node:${installed.nodeExecutable}\n受管 app-server 已就绪;首次联网可能需要 Windows 防火墙授权。\n${desktopSetupSteps.join('\n')}\n${restartText}`);
237
+ console.log(json ? JSON.stringify({ ...installed, nextSteps: desktopSetupSteps, displayText: `${migrationText}${desktopSetupSteps.join('\n')}\n${restartText}` }) : `服务已安装:${installed.serviceRoot}\n使用已安装的 Node:${installed.nodeExecutable}\n受管 app-server 已就绪;首次联网可能需要 Windows 防火墙授权。\n${migrationText}${desktopSetupSteps.join('\n')}\n${restartText}`);
220
238
  return;
221
239
  }
222
240
  if (command === 'stop') {
@@ -8,6 +8,7 @@ import { SharedAppServer } from './shared-app-server.mjs';
8
8
 
9
9
  export const managedAppServerUrl = 'ws://127.0.0.1:45839';
10
10
  export const managedAppServerReadyUrl = 'http://127.0.0.1:45839/readyz';
11
+ export const managedAppServerLauncherRevision = 2;
11
12
  const retryDelays = [1_000, 2_000, 5_000, 10_000, 20_000];
12
13
 
13
14
  export function managedPortAvailable() {
@@ -80,21 +81,26 @@ export class ManagedAppServer extends EventEmitter {
80
81
  [path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
81
82
  const started = await this.host.process(pid);
82
83
  if (!sameManagedProcess(started, started)) throw new Error('Codex 内核启动后进程身份无法确认。');
83
- record = actual = started;
84
+ actual = started;
85
+ record = { ...started, launcherRevision: managedAppServerLauncherRevision };
84
86
  writeRecord(this.recordPath, record);
85
87
  } else if (!readRecord(this.recordPath)) {
86
- writeRecord(this.recordPath, actual);
88
+ record = { ...actual, launcherRevision: Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0 };
89
+ writeRecord(this.recordPath, record);
87
90
  }
88
91
  await this.waitUntilReady(actual);
89
92
  await this.client.connect();
90
93
  if (!this.client.ready) throw new Error('Codex 内核协议初始化未完成。');
91
94
  const preferredExecutable = path.resolve(info.cli);
92
95
  const updatePending = path.resolve(actual.executable).toLowerCase() !== preferredExecutable.toLowerCase();
96
+ const launcherRevision = Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0;
93
97
  this.retryAttempt = 0;
94
98
  this.retryAfter = 0;
95
99
  this.publish({ state: 'ready', ready: true, error: null, pid: actual.pid,
96
100
  created: actual.created, executable: actual.executable, preferredExecutable,
97
- updatePending, url: managedAppServerUrl });
101
+ updatePending, launcherRevision,
102
+ launcherRestartRequired: launcherRevision !== managedAppServerLauncherRevision,
103
+ url: managedAppServerUrl });
98
104
  return true;
99
105
  } catch (error) {
100
106
  this.failed(error instanceof Error ? error.message : String(error));
@@ -17,7 +17,6 @@ try {
17
17
  const descriptors = (spec.logs || []).map(file => fs.openSync(file, 'a'));
18
18
  try {
19
19
  const child = spawn(spec.executable, spec.args || [], {
20
- detached: true,
21
20
  windowsHide: true,
22
21
  env: spec.environment,
23
22
  stdio: ['ignore', descriptors[0] ?? 'ignore', descriptors[1] ?? 'ignore'],
@@ -26,7 +25,8 @@ try {
26
25
  child.once('spawn', resolve);
27
26
  child.once('error', reject);
28
27
  });
29
- child.unref();
28
+ // Keep this WMI-owned broker alive with the target so Codex tools inherit
29
+ // its hidden console instead of creating a visible console per command.
30
30
  result.pid = child.pid;
31
31
  diagnostic(`target started (PID ${child.pid})`);
32
32
  } finally {
@@ -1,9 +1,9 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { execFile } from 'node:child_process';
4
- import { setTimeout as delay } from 'node:timers/promises';
5
- import { startBackground, processAlive } from '../background-process.mjs';
6
- import { promisify } from 'node:util';
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { startBackground, processAlive } from '../background-process.mjs';
6
+ import { promisify } from 'node:util';
7
7
  import { randomBytes, randomUUID } from 'node:crypto';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { hostname } from 'node:os';
@@ -16,8 +16,8 @@ import { connectBridgeRelay } from './connector.mjs';
16
16
  import { validProbeIceServers } from './p2p-probe.mjs';
17
17
  import { relayAddress, validSecret } from './wire.mjs';
18
18
  import { saveLanAuthorizations } from './lan-authorizations.mjs';
19
- import { lanNetworks } from '../platform/windows/network-info.mjs';
20
-
19
+ import { lanNetworks } from '../platform/windows/network-info.mjs';
20
+
21
21
  const exec = promisify(execFile);
22
22
  const script = fileURLToPath(import.meta.url);
23
23
  const recordPath = root => path.join(root, 'self-relay-session.json');
@@ -32,34 +32,33 @@ async function loadConfig(stateRoot) {
32
32
  if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
33
33
  if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
34
34
  const url = process.env.RELAY_URL || config.url;
35
- if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
35
+ if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
36
36
  relayAddress(url);
37
37
  const resolved = relayAddress(url);
38
38
  const pairingUrl = config.pairingUrl || `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`;
39
39
  const pairing = new URL(pairingUrl);
40
40
  if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
41
41
  (pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
42
- return { ...config, url: resolved, pairingUrl: pairing.href };
43
- }
42
+ return { ...config, url: resolved, pairingUrl: pairing.href };
43
+ }
44
44
  async function registrationKey(config) {
45
45
  if (config.url === officialConfig.url) return undefined;
46
46
  const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
47
- let hostKey;
48
- if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
49
- else {
50
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
51
- !/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
52
- try {
53
- const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
54
- `sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
55
- hostKey = result.stdout.trim();
56
- } catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
57
- }
58
- if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
59
- return hostKey;
60
- }
61
-
62
- export async function selfRelay(command, stateRoot, requiredMode = '') {
47
+ let hostKey;
48
+ if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
49
+ else {
50
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
51
+ !/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
52
+ try {
53
+ const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
54
+ `sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
55
+ hostKey = result.stdout.trim();
56
+ } catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
57
+ }
58
+ if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
59
+ return hostKey;
60
+ }
61
+ export async function selfRelay(command, stateRoot, requiredMode = '') {
63
62
  const previous = readRecord(recordPath(stateRoot));
64
63
  let current;
65
64
  if (previous) {
@@ -139,12 +138,12 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
139
138
  fs.rmSync(legacyPath);
140
139
  }
141
140
  let identity = identities.servers[config.url];
142
- if (!identity) {
143
- identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
144
- identities.servers[config.url] = identity; await credentialStore('save', identities);
145
- }
146
- const hostKey = identity.registered ? undefined : await registrationKey(config);
147
- let bridgeError = '';
141
+ if (!identity) {
142
+ identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
143
+ identities.servers[config.url] = identity; await credentialStore('save', identities);
144
+ }
145
+ const hostKey = identity.registered ? undefined : await registrationKey(config);
146
+ let bridgeError = '';
148
147
  async function refreshHealth() {
149
148
  try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; bridgeError = ''; }
150
149
  catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; }
@@ -204,7 +203,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
204
203
  await refreshHealth();
205
204
  const lanCandidates = await currentLanCandidates();
206
205
  connector?.events.removeAllListeners(); connector?.close();
207
- const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
206
+ const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
208
207
  bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
209
208
  bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
210
209
  hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
@@ -768,8 +768,14 @@ server.on('error', error => {
768
768
  server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
769
769
  try {
770
770
  if (process.env.CODEX_REMOTE_INSTANCE && process.env.CODEX_REMOTE_CONTROL_SECRET && process.env.CODEX_REMOTE_READY_PATH) {
771
- control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
772
- secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: { 'refresh-execution': refreshExecution } });
771
+ control = await openControl({ instanceId: process.env.CODEX_REMOTE_INSTANCE,
772
+ secret: process.env.CODEX_REMOTE_CONTROL_SECRET, shutdown: shutDown, actions: {
773
+ 'refresh-execution': refreshExecution,
774
+ 'restart-managed-app-server': () => {
775
+ const state = managedAppServer.snapshot();
776
+ return state.launcherRestartRequired ? managedAppServer.restart(crypto.randomUUID()) : state;
777
+ },
778
+ } });
773
779
  writeRecord(process.env.CODEX_REMOTE_READY_PATH, { instanceId: process.env.CODEX_REMOTE_INSTANCE,
774
780
  pid: process.pid, controlPort: control.port });
775
781
  }
@@ -40,6 +40,8 @@ export async function doctor({ paths, host, service, skillTarget }) {
40
40
  : state.managedAppServer?.error || '受管 app-server 尚未就绪。');
41
41
  if (state.managedAppServer?.updatePending) add('managed-app-server-update', 'warning',
42
42
  'Codex Desktop 已提供新版 CLI;当前 app-server 会继续完成任务,并在下次进程自然重启或重新登录 Windows 后采用新版。');
43
+ if (state.managedAppServer?.launcherRestartRequired) add('managed-app-server-launcher', 'warning',
44
+ '安装文件已经更新,但当前 app-server 仍由旧启动链创建;隐藏控制台等运行时修复尚未生效,请执行 relax setup 或重启 Windows。');
43
45
  });
44
46
  await check('desktop-environment', async () => {
45
47
  const environment = await readManagedEnvironment();
@@ -61,7 +61,7 @@ export function lifecycle({ stateRoot, host }) {
61
61
  desktopRestartRecommended: result?.desktopRestartRecommended === true,
62
62
  resident: result?.resident === true };
63
63
  }
64
- async function pair() {
64
+ async function pair() {
65
65
  const session = readRecord(sessionPath);
66
66
  const connection = await alive(session) ? await health(session) : null;
67
67
  if (connection?.status !== 'ok' || !session.lan) {
@@ -76,7 +76,12 @@ export function lifecycle({ stateRoot, host }) {
76
76
  execution: connection.execution, managedAppServer: connection.managedAppServer, desktop: connection.desktop,
77
77
  desktopRestartRecommended: connection.desktopRestartRecommended === true, accessUrl: session.accessUrl,
78
78
  qrPath: path.join(stateRoot, 'connection-qr.png') };
79
- }
79
+ }
80
+ async function restartManagedAppServer() {
81
+ const session = readRecord(sessionPath);
82
+ if (!await alive(session)) throw new Error('Bridge 尚未运行,无法迁移受管 app-server。');
83
+ return controlRequest(session, 'POST', 'restart-managed-app-server');
84
+ }
80
85
  async function start() {
81
86
  let session = readRecord(sessionPath);
82
87
  if (await alive(session)) {
@@ -126,5 +131,5 @@ export function lifecycle({ stateRoot, host }) {
126
131
  throw error;
127
132
  } finally { fs.rmSync(readyPath, { force: true }); }
128
133
  }
129
- return { start, pair, status, stop };
130
- }
134
+ return { start, pair, status, stop, restartManagedAppServer };
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.6",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {