@walkhi/code-relax 0.1.0-beta.41 → 0.1.0-beta.43
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 +13 -3
- package/dist/bin/codex-remote.mjs +5 -3
- package/dist/build-platform.json +1 -1
- package/dist/get-shared-service-url.ps1 +12 -4
- package/dist/src/desktop-launcher.mjs +2 -2
- package/dist/src/dots-chat-server.mjs +1 -0
- package/dist/src/dots-room-activity.mjs +84 -8
- package/dist/src/managed-app-server.mjs +178 -50
- package/dist/src/managed-endpoint.mjs +25 -0
- package/dist/src/platform/linux/desktop-host.mjs +9 -5
- package/dist/src/platform/linux/process.mjs +14 -3
- package/dist/src/platform/linux/system.mjs +3 -2
- package/dist/src/platform/linux/user-environment.mjs +4 -2
- package/dist/src/platform/macos/desktop-host.mjs +9 -5
- package/dist/src/platform/macos/user-environment.mjs +4 -2
- package/dist/src/platform/windows/activate-desktop.ps1 +1 -0
- package/dist/src/platform/windows/desktop-host.mjs +11 -4
- package/dist/src/platform/windows/desktop-info.ps1 +25 -1
- package/dist/src/platform/windows/service-host.mjs +7 -3
- package/dist/src/platform/windows/user-environment.mjs +11 -9
- package/dist/src/runtime-discovery.mjs +1 -1
- package/dist/src/self-relay/device-info.mjs +1 -1
- package/dist/src/server.mjs +3 -2
- package/dist/src/shared-recovery.mjs +2 -4
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npm i -g @walkhi/code-relax@latest --allow-scripts=@walkhi/code-relax --foregrou
|
|
|
14
14
|
|
|
15
15
|
以下目录与环境变量配置说明适用于 Windows;Mac/Linux 见下方跨平台入口。首次安装前先保存工作,并从 Codex 菜单或 Windows 托盘完全退出 Codex。更新无需先停止正在运行的桌面服务;安装器会短暂停止、替换并恢复服务。npm 全局安装会自动执行 `setup` 和 Skill 安装。`setup` 将完整服务部署到 `%LOCALAPPDATA%\Programs\CodexRemote\service`,记录安装所用 Node 的绝对路径,不复制 Node。配置、日志和二维码以 `installation.json.stateRoot` 为准,通常位于 `%LOCALAPPDATA%\CodexRemote`,MSIX 环境可能解析到包缓存目录;附件位于 `%ProgramData%\CodexRemote\attachments`。
|
|
16
16
|
|
|
17
|
-
`setup`
|
|
17
|
+
`setup` 立即启动桌面服务和自动选用可用回环端口的 Codex 内核,并启用当前用户登录自启;自启不打开 Codex。Codex 内核健康后配置当前用户 `CODEX_APP_SERVER_WS_URL`,之后正常启动的 Desktop 自动接入。若安装时 Desktop 仍未接入,安装器会关闭并重新打开 Desktop;安装前应保存工作。普通更新无接入或启动链迁移需求时复用现有内核。
|
|
18
18
|
|
|
19
19
|
## Linux 源码测试
|
|
20
20
|
|
|
@@ -32,9 +32,9 @@ relax diag # 输出供客服或技术人员使用的详细诊断报告
|
|
|
32
32
|
relax kernel-restart --confirm # 重启内核并启用已选更新,等待新内核就绪
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Codex 内核由官方 `codex app-server --listen ws://127.0.0.1
|
|
35
|
+
Codex 内核由官方 `codex app-server --listen ws://127.0.0.1:<实际端口>` 提供执行服务,Desktop 和桌面服务均可连接;它只监听回环地址。桌面服务独立报告内核健康、Desktop 接入和手机可写状态;Desktop 退出后,手机仍可操作内核中的任务。同一 thread 已被另一个实例持有时,手机只读且可重新检测。
|
|
36
36
|
|
|
37
|
-
发现待启用内核更新时,手机“重启并更新”和 `relax kernel-restart --confirm` 复用同一受控停启流程:先验证候选,再停止旧内核,确认新内核协议就绪后恢复 Desktop,并按清单清理不再运行的旧版保留目录。手机工作站详情可确认让 Desktop 接入Codex 内核,独立进程逐步执行并保留桌面服务/中继连接,见[启用流程](../docs/shared-recovery.md)。本机恢复命令为 `relax desktop-recover --confirm`,查看恢复记录用 `relax diag`。桌面服务状态匹配且健康时直接复用;未知进程占用 45831
|
|
37
|
+
发现待启用内核更新时,手机“重启并更新”和 `relax kernel-restart --confirm` 复用同一受控停启流程:先验证候选,再停止旧内核,确认新内核协议就绪后恢复 Desktop,并按清单清理不再运行的旧版保留目录。手机工作站详情可确认让 Desktop 接入Codex 内核,独立进程逐步执行并保留桌面服务/中继连接,见[启用流程](../docs/shared-recovery.md)。本机恢复命令为 `relax desktop-recover --confirm`,查看恢复记录用 `relax diag`。桌面服务状态匹配且健康时直接复用;未知进程占用 Bridge 端口 45831 时拒绝接管;内核默认尝试 45839,被占用则自动选择其他回环端口,不结束占用者。
|
|
38
38
|
|
|
39
39
|
Codex 内核通过隐藏控制台启动,使后续控制台子进程继承隐藏窗口。手机的项目、任务列表和新建任务均使用Codex 内核,不依赖 Desktop 工具管道;项目和任务 ID 保持原生值。
|
|
40
40
|
|
|
@@ -93,3 +93,13 @@ registry 默认安装使用 `@walkhi/code-relax@latest`,主 CLI 为 `relax`,
|
|
|
93
93
|
安装和启动成功后只显示结果与 `relax pair` 入口;配对成功显示二维码文件。`relax doctor` 报告工作站健康及实际问题,独立的 `relax diag` 默认用可读文本输出系统、Node、服务进程、逐项检查和 Desktop 接入排障记录;常规命令不再提供 `--detail`;`--json` 保留给安装器等现有程序调用。服务未运行或内核未就绪判为失败;未连接局域网只提示,不阻断中继使用。手机连通性不作为 CLI 检查项,doctor 不创建配对邀请。
|
|
94
94
|
|
|
95
95
|
`doctor` 默认逐项说明安装文件、工作站服务和 Codex 内核的当前状态,问题集中给出处理方法。上次 Desktop 接入记录仅由 `diag` 读取,并明确标注排障用途;常规 `doctor` 不读取该记录,不参与当前健康判定,不拼接互相矛盾的阶段消息与最终错误。
|
|
96
|
+
|
|
97
|
+
### 内核端口自动恢复(beta.43)
|
|
98
|
+
|
|
99
|
+
无需手工设置端口。先复用记录中身份、监听与协议均验证通过的内核;没有可用内核时尝试上次端口/默认 45839,冲突后选择系统分配的空闲回环端口。端口抢占导致刚启动的进程退出时最多尝试五次;已启动但未就绪的内核保留身份记录,不创建第二个实例。
|
|
100
|
+
|
|
101
|
+
只有进程身份、监听所有者、HTTP 就绪和 WebSocket 初始化全部通过后,才公布实际地址,并同步 Windows 当前用户的 `CODEX_APP_SERVER_WS_URL`。原值由 Code Relax 管理时自动更新;用户另行设置的值保留并明确报错。Mac/Linux 继续通过启动时环境传递实际地址,不修改 shell 配置。正常登录自启同样自动选端口。
|
|
102
|
+
|
|
103
|
+
从旧版恢复可运行 `npm install -g @walkhi/code-relax@beta`,其安装流程自动部署并运行 setup;需要重试安装时运行 `relax setup`。不需要重启电脑或结束未知 PID。`relax status --json` 的 `managedAppServer.url` 显示实际地址。已经打开的终端和 Desktop 不能被外部原地修改环境;setup 沿用既有受控 Desktop 接入流程,新启动的进程使用更新后的值。
|
|
104
|
+
|
|
105
|
+
实现依据与验证范围见 [动态端口审阅](../docs/managed-kernel-dynamic-port.md)。
|
|
@@ -22,7 +22,7 @@ import { resolveCodexRuntime } from '../src/platform/codex-runtime.mjs';
|
|
|
22
22
|
import { controlRequest } from '../src/service-control.mjs';
|
|
23
23
|
import { processAlive } from '../src/background-process.mjs';
|
|
24
24
|
import { prepareNativePackage } from '../src/platform/native-package.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { readManagedEndpoint } from '../src/managed-endpoint.mjs';
|
|
26
26
|
import { powerConfigPath, powerEnabled } from '../src/service-power.mjs';
|
|
27
27
|
import { readDotsTasks } from '../src/dots-task-catalog.mjs';
|
|
28
28
|
import { installDesktopHelper, desktopHelperStatus, removeDesktopHelper } from '../src/platform/windows/desktop-helper.mjs';
|
|
@@ -96,7 +96,9 @@ async function main() {
|
|
|
96
96
|
return;
|
|
97
97
|
}
|
|
98
98
|
if (command === 'dots-tasks') {
|
|
99
|
-
const
|
|
99
|
+
const url = readManagedEndpoint(runtimePaths().stateRoot);
|
|
100
|
+
if (!url) throw new Error('Codex 内核尚未就绪,请先运行 relax start。');
|
|
101
|
+
const result = await readDotsTasks(url);
|
|
100
102
|
const statuses = { completed: '完成', inProgress: '进行中', failed: '失败', interrupted: '已中断' };
|
|
101
103
|
console.log(json ? JSON.stringify(result) : result.tasks.length
|
|
102
104
|
? result.tasks.map(task => `${task.title || task.id} — ${statuses[task.latestTurn?.status] || task.status || '暂无轮次'}\n${task.cwd || ''}`).join('\n\n')
|
|
@@ -313,7 +315,7 @@ async function main() {
|
|
|
313
315
|
throw new Error(bridge.managedAppServer?.error || '桌面服务已安装,但 Codex 内核未就绪;未写入 Desktop 环境变量。');
|
|
314
316
|
}
|
|
315
317
|
const environment = desktopBeforeSetup.desktop
|
|
316
|
-
? await configureManagedEnvironment(physicalStateRoot) : { configured: false, reason: desktopHost.desktopIntegration === false ? 'desktop-integration-unsupported' : 'desktop-not-installed' };
|
|
318
|
+
? await configureManagedEnvironment(physicalStateRoot, bridge.managedAppServer.url) : { configured: false, reason: desktopHost.desktopIntegration === false ? 'desktop-integration-unsupported' : 'desktop-not-installed' };
|
|
317
319
|
let managedAppServerMigrated = false;
|
|
318
320
|
const managedAppServerMigration = {
|
|
319
321
|
required: bridge.managedAppServer.launcherRestartRequired === true || bridge.managedAppServer.updatePending === true,
|
package/dist/build-platform.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"layout":"npm-platform-packages","version":"0.1.0-beta.
|
|
1
|
+
{"layout":"npm-platform-packages","version":"0.1.0-beta.43"}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
$
|
|
1
|
+
param([Parameter(Mandatory=$true)][string]$AppServerUrl)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
+
if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
|
|
5
|
+
$port = [int]$Matches[1]
|
|
6
|
+
$package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
7
|
+
if (-not $package) { exit }
|
|
8
|
+
$desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
|
|
9
|
+
# Match an installed Desktop process connected to the selected loopback port.
|
|
10
|
+
$connections = @(Get-NetTCPConnection -RemotePort $port -State Established -ErrorAction SilentlyContinue | Where-Object {
|
|
3
11
|
$_.RemoteAddress -eq '127.0.0.1' -and
|
|
4
|
-
(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).
|
|
12
|
+
(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Path -eq $desktop
|
|
5
13
|
})
|
|
6
|
-
if ($connections.Count) {
|
|
14
|
+
if ($connections.Count) { $AppServerUrl }
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
4
|
import { readRecord } from './service-lifecycle.mjs';
|
|
5
|
-
import { ManagedAppServer,
|
|
5
|
+
import { ManagedAppServer, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
|
|
6
6
|
export { portAvailable };
|
|
7
7
|
export async function launchDesktop({ stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
|
|
8
8
|
fs.mkdirSync(stateRoot, { recursive: true });
|
|
@@ -31,7 +31,7 @@ export async function launchDesktop({ stateRoot, host, environment = process.env
|
|
|
31
31
|
if (!await managed.start()) throw new Error(managed.snapshot().error || 'Codex 内核未就绪。');
|
|
32
32
|
const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
|
|
33
33
|
if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
|
|
34
|
-
childEnv.CODEX_APP_SERVER_WS_URL =
|
|
34
|
+
childEnv.CODEX_APP_SERVER_WS_URL = managed.snapshot().url;
|
|
35
35
|
progress('starting-desktop');
|
|
36
36
|
await host.startDesktop(info.desktop, childEnv);
|
|
37
37
|
const deadline = Date.now() + timeout;
|
|
@@ -242,6 +242,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
242
242
|
if (record.changeBytes > 8 * 1024 * 1024) { record.recoverAfter = sequence; record.changes.clear(); record.changeBytes = 0; }
|
|
243
243
|
this.emit('turn', { thread: record.thread, turn, sequence, immediate: true });
|
|
244
244
|
}
|
|
245
|
+
this.chatActivity.completeReply(threadId, changed);
|
|
245
246
|
this.emit('notification', { method: 'thread/status/changed', params: { threadId, status: record.thread.status,
|
|
246
247
|
updatedAt: record.thread.updatedAt, chatRevision: record.thread.chatRevision, chatMessageRevision: record.thread.chatMessageRevision } });
|
|
247
248
|
this.saveHistoryWindow(threadId);
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
|
|
3
|
-
// Desktop
|
|
3
|
+
// Desktop 26.1007.9446.0: room-live activity and responding heartbeats are
|
|
4
|
+
// independent. Heartbeats expire after 10s; Dot reply handoff lasts at most 5s.
|
|
5
|
+
const typingTimeout = 10000, handoffTimeout = 5000;
|
|
6
|
+
const statusLabels = {
|
|
7
|
+
working: '正在处理…', searching: '正在搜索…', creating: '正在创建…',
|
|
8
|
+
payment: '等待支付', 'needs input': '等待你的输入', paused: '已暂停',
|
|
9
|
+
};
|
|
10
|
+
const waitingStates = new Set(['payment', 'needs input', 'paused']);
|
|
11
|
+
|
|
4
12
|
export class DotsRoomActivity extends EventEmitter {
|
|
5
13
|
constructor(server) {
|
|
6
14
|
super(); this.server = server; this.watches = new Map();
|
|
7
15
|
this.message = (topic, event) => {
|
|
8
|
-
if (event?.type !== 'calpico-room-live') return;
|
|
9
16
|
for (const watch of this.watches.values()) {
|
|
17
|
+
if (topic === 'calpico-chatgpt-messaging') { this.roomEvent(watch, event); continue; }
|
|
18
|
+
if (event?.type !== 'calpico-room-live') continue;
|
|
10
19
|
const value = event.payload;
|
|
11
20
|
if (watch.topic !== topic || watch.roomId !== value?.room_id || value.version !== 1) continue;
|
|
12
21
|
if (!watch.subscriptionId) {
|
|
@@ -37,6 +46,57 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
37
46
|
watch.activity = activity;
|
|
38
47
|
for (const listener of watch.listeners) listener({ threadId: watch.threadId, activity });
|
|
39
48
|
}
|
|
49
|
+
refresh(watch) {
|
|
50
|
+
const activity = watch.typing ? { kind: 'replying', label: '正在回复…', spinning: true }
|
|
51
|
+
: watch.liveActivity || (watch.handoffTimer ? { kind: 'replying', label: '正在回复…', spinning: true }
|
|
52
|
+
: watch.unavailable ? { kind: 'unavailable', label: '活动状态暂不可用', spinning: false } : null);
|
|
53
|
+
this.publish(watch, activity);
|
|
54
|
+
}
|
|
55
|
+
clearReply(watch) {
|
|
56
|
+
clearTimeout(watch.typingTimer); clearTimeout(watch.handoffTimer);
|
|
57
|
+
watch.typingTimer = null; watch.handoffTimer = null; watch.typing = false; watch.handoffMessageId = null;
|
|
58
|
+
}
|
|
59
|
+
handoff(watch, messageId = null) {
|
|
60
|
+
const wasTyping = watch.typing;
|
|
61
|
+
clearTimeout(watch.typingTimer); watch.typingTimer = null; watch.typing = false;
|
|
62
|
+
if (messageId && (wasTyping || watch.handoffTimer)) watch.handoffMessageId = messageId;
|
|
63
|
+
if (wasTyping && !watch.handoffTimer) {
|
|
64
|
+
watch.handoffTimer = setTimeout(() => {
|
|
65
|
+
watch.handoffTimer = null; watch.handoffMessageId = null;
|
|
66
|
+
if (this.watches.get(watch.threadId) === watch) this.refresh(watch);
|
|
67
|
+
}, handoffTimeout);
|
|
68
|
+
watch.handoffTimer.unref();
|
|
69
|
+
}
|
|
70
|
+
this.refresh(watch);
|
|
71
|
+
}
|
|
72
|
+
completeReply(threadId, turns) {
|
|
73
|
+
const watch = this.watches.get(threadId);
|
|
74
|
+
if (!watch?.handoffMessageId || !turns.some(turn => turn.id === watch.handoffMessageId
|
|
75
|
+
&& turn.items.some(item => item.type === 'agentMessage' && !item.dotsStreaming))) return;
|
|
76
|
+
// The matching formal message has entered the existing chat stream.
|
|
77
|
+
clearTimeout(watch.handoffTimer); watch.handoffTimer = null; watch.handoffMessageId = null;
|
|
78
|
+
this.refresh(watch);
|
|
79
|
+
}
|
|
80
|
+
roomEvent(watch, event) {
|
|
81
|
+
if (!this.server.rooms.ready || !watch.roomId || event?.payload?.room_id !== watch.roomId) return;
|
|
82
|
+
const payload = event.payload, heartbeat = event.type === 'calpico-is-responding-heartbeat';
|
|
83
|
+
if (!heartbeat && event.type !== 'calpico-message-add') return;
|
|
84
|
+
const room = this.server.bindings.get(watch.threadId);
|
|
85
|
+
const sender = heartbeat ? payload.source : payload.message;
|
|
86
|
+
if (room?.id !== watch.roomId || !room.aeon_id || heartbeat && sender?.role !== 'user'
|
|
87
|
+
|| !sender?.account_user_id
|
|
88
|
+
|| !room.members.some(member => member.account_user_id === sender.account_user_id && member.aeon_id === room.aeon_id)) return;
|
|
89
|
+
if (!heartbeat || payload.typing === false) {
|
|
90
|
+
this.handoff(watch, heartbeat ? null : payload.message.id); return;
|
|
91
|
+
}
|
|
92
|
+
if (payload.typing != null && payload.typing !== true) return;
|
|
93
|
+
this.clearReply(watch); watch.typing = true;
|
|
94
|
+
watch.typingTimer = setTimeout(() => {
|
|
95
|
+
watch.typingTimer = null;
|
|
96
|
+
if (this.watches.get(watch.threadId) === watch) this.handoff(watch);
|
|
97
|
+
}, typingTimeout);
|
|
98
|
+
watch.typingTimer.unref(); this.refresh(watch);
|
|
99
|
+
}
|
|
40
100
|
apply(watch, value) {
|
|
41
101
|
if (value.subscription_id !== watch.subscriptionId || !Number.isSafeInteger(value.revision) || value.revision <= watch.revision) return;
|
|
42
102
|
watch.revision = value.revision;
|
|
@@ -45,10 +105,23 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
45
105
|
}
|
|
46
106
|
if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
|
|
47
107
|
const current = value.threads[0];
|
|
48
|
-
|
|
49
|
-
watch
|
|
108
|
+
watch.attempts = 0; watch.unavailable = false; watch.liveActivity = null;
|
|
109
|
+
if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
|
|
110
|
+
if (current.turn_status === 'failed') {
|
|
111
|
+
this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
|
|
112
|
+
this.refresh(watch); return;
|
|
113
|
+
}
|
|
114
|
+
if (current.turn_status !== 'in_progress') { this.refresh(watch); return; }
|
|
115
|
+
const status = typeof current.activity_status?.status_type === 'string' ? current.activity_status.status_type.toLowerCase() : '';
|
|
50
116
|
const label = current.activity_status?.status_label;
|
|
51
|
-
|
|
117
|
+
if (status !== 'ready') {
|
|
118
|
+
const waiting = waitingStates.has(status);
|
|
119
|
+
const fallback = Object.hasOwn(statusLabels, status) ? statusLabels[status] : null;
|
|
120
|
+
if (waiting) this.clearReply(watch);
|
|
121
|
+
watch.liveActivity = { kind: waiting ? 'waiting' : fallback ? 'operation' : 'thinking',
|
|
122
|
+
label: typeof label === 'string' && label.trim() ? label : fallback || '正在思考…', spinning: !waiting };
|
|
123
|
+
}
|
|
124
|
+
this.refresh(watch);
|
|
52
125
|
}
|
|
53
126
|
async start(watch) {
|
|
54
127
|
const generation = ++watch.generation, controller = new AbortController();
|
|
@@ -77,15 +150,18 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
77
150
|
} catch (error) { if (current()) this.fail(watch, error, ![401, 403, 404].includes(error.status)); }
|
|
78
151
|
}
|
|
79
152
|
fail(watch, error, retry) {
|
|
80
|
-
this.stop(watch); this.emit('unavailable', error);
|
|
153
|
+
this.stop(watch, true); watch.unavailable = true; this.refresh(watch); this.emit('unavailable', error);
|
|
81
154
|
if (retry && watch.listeners.size && this.server.ready && watch.attempts < 5) {
|
|
82
155
|
watch.timer = setTimeout(() => { watch.timer = null; void this.start(watch); }, Math.min(30000, 1000 * 2 ** watch.attempts++));
|
|
83
156
|
watch.timer.unref();
|
|
84
157
|
}
|
|
85
158
|
}
|
|
86
|
-
stop(watch) {
|
|
159
|
+
stop(watch, keepReply = false) {
|
|
87
160
|
watch.generation++; clearTimeout(watch.timer); watch.timer = null;
|
|
88
|
-
watch.controller?.abort(); watch.controller = null; watch.pending?.clear();
|
|
161
|
+
watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
|
|
162
|
+
watch.subscriptionId = null; watch.revision = -1;
|
|
163
|
+
if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
|
|
164
|
+
if (!keepReply) this.publish(watch, null);
|
|
89
165
|
// The SSE cancellation ends the server watch. The shared topic remains
|
|
90
166
|
// subscribed while this account socket lives, avoiding unsubscribe races.
|
|
91
167
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { executableKey } from './platform/path-identity.mjs';
|
|
3
3
|
import net from 'node:net';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { EventEmitter } from 'node:events';
|
|
6
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -8,31 +9,47 @@ import { readRecord, writeRecord } from './service-lifecycle.mjs';
|
|
|
8
9
|
import { SharedAppServer } from './shared-app-server.mjs';
|
|
9
10
|
import { pruneRetainedDesktopRuntimes, resolveCodexRuntime, runtimeFilesAvailable } from './platform/codex-runtime.mjs';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
import { defaultManagedPort, managedProcessUrl } from './managed-endpoint.mjs';
|
|
13
|
+
import { configureManagedEnvironment } from './platform/user-environment.mjs';
|
|
13
14
|
export const managedAppServerLauncherRevision = 3;
|
|
14
15
|
const retryDelays = [1_000, 2_000, 5_000, 10_000, 20_000];
|
|
15
16
|
|
|
16
|
-
export function managedPortAvailable() {
|
|
17
|
+
export function managedPortAvailable(port = defaultManagedPort) {
|
|
17
18
|
return new Promise(resolve => {
|
|
18
19
|
const server = net.createServer();
|
|
19
20
|
server.once('error', () => resolve(false));
|
|
20
|
-
server.listen(
|
|
21
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function availableManagedPort() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const server = net.createServer();
|
|
27
|
+
server.once('error', reject);
|
|
28
|
+
server.listen(0, '127.0.0.1', () => {
|
|
29
|
+
const port = server.address().port;
|
|
30
|
+
server.close(error => error ? reject(error) : resolve(port));
|
|
31
|
+
});
|
|
21
32
|
});
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
export class ManagedAppServer extends EventEmitter {
|
|
25
36
|
constructor({ stateRoot, host, environment = process.env, available = managedPortAvailable,
|
|
26
|
-
wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(
|
|
37
|
+
wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(`ws://127.0.0.1:${defaultManagedPort}`),
|
|
38
|
+
selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime }) {
|
|
27
39
|
super();
|
|
28
40
|
this.stateRoot = stateRoot;
|
|
29
41
|
this.host = host;
|
|
30
42
|
this.environment = environment;
|
|
31
43
|
this.available = available;
|
|
44
|
+
this.selectPort = selectPort;
|
|
45
|
+
this.configureEnvironment = configureEnvironment;
|
|
46
|
+
this.resolveRuntime = resolveRuntime;
|
|
32
47
|
this.wait = wait;
|
|
33
48
|
this.timeout = timeout;
|
|
34
49
|
this.now = now;
|
|
35
50
|
this.recordPath = path.join(stateRoot, 'managed-app-server.json');
|
|
51
|
+
this.pendingPath = path.join(stateRoot, 'managed-app-server.pending.json');
|
|
52
|
+
this.lockPath = path.join(stateRoot, 'managed-app-server.lock');
|
|
36
53
|
this.client = client;
|
|
37
54
|
this.state = { state: 'absent', ready: false, error: null };
|
|
38
55
|
this.client.diagnosticIdentity = () => ({ kernelPid: this.state.pid ?? null, kernelCreated: this.state.created ?? null });
|
|
@@ -42,7 +59,7 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
42
59
|
this.retryAttempt = 0;
|
|
43
60
|
this.retryAfter = 0;
|
|
44
61
|
this.client.on('disconnected', () => {
|
|
45
|
-
if (!this.stopped && !this.restarting) this.failed('Codex 内核连接断开。');
|
|
62
|
+
if (!this.stopped && !this.restarting && !this.work) this.failed('Codex 内核连接断开。');
|
|
46
63
|
});
|
|
47
64
|
}
|
|
48
65
|
|
|
@@ -66,49 +83,83 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
66
83
|
return this.work;
|
|
67
84
|
}
|
|
68
85
|
|
|
69
|
-
async ensureNow() {
|
|
86
|
+
async ensureNow(lockHeld = false) {
|
|
70
87
|
// Keep an already verified instance available while the periodic identity
|
|
71
88
|
// check runs; publishing "starting" every three seconds makes health flicker.
|
|
72
89
|
if (!this.state.ready) this.publish({ state: 'starting', ready: false, error: null });
|
|
73
90
|
try {
|
|
74
91
|
fs.mkdirSync(this.stateRoot, { recursive: true });
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
92
|
+
const release = lockHeld ? () => {} : await this.acquireLock();
|
|
93
|
+
if (!release) return false;
|
|
94
|
+
try {
|
|
95
|
+
const pending = readRecord(this.pendingPath);
|
|
96
|
+
if (pending?.phase === 'spawning') throw new Error('上次内核启动的进程交接未完成,已停止自动启动以避免重复内核;请检查内核启动日志与 managed-app-server.pending.json。');
|
|
97
|
+
let record = pending || readRecord(this.recordPath);
|
|
98
|
+
let actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
99
|
+
if (actual && !sameManagedProcess(record, actual)) {
|
|
100
|
+
// Stale PID now belongs to someone else: leave it untouched and select
|
|
101
|
+
// another available endpoint rather than adopting or stopping it.
|
|
102
|
+
actual = null;
|
|
103
|
+
record = null;
|
|
104
|
+
}
|
|
79
105
|
const startedHere = !actual;
|
|
80
106
|
const savedRuntime = actual ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) : null;
|
|
81
107
|
const runtime = actual && savedRuntime?.executable && runtimeFilesAvailable(savedRuntime.executable)
|
|
82
108
|
? savedRuntime
|
|
83
|
-
: await
|
|
109
|
+
: await this.resolveRuntime(this.stateRoot, { refresh: !actual, environment: this.environment });
|
|
84
110
|
if (actual && !runtimeFilesAvailable(actual.executable)) {
|
|
85
|
-
throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable}
|
|
111
|
+
throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};请确认重启内核。`);
|
|
86
112
|
}
|
|
87
113
|
if (!actual) {
|
|
88
|
-
|
|
89
|
-
const childEnvironment = { ...this.environment };
|
|
90
|
-
childEnvironment.CODEX_HOME = runtime.codexHome;
|
|
91
|
-
// Desktop's inherited INFO tracing can emit enter/exit on every poll.
|
|
92
|
-
// The managed background kernel retains warnings and errors only.
|
|
93
|
-
childEnvironment.RUST_LOG = 'warn';
|
|
114
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
115
|
+
const childEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, RUST_LOG: 'warn' };
|
|
94
116
|
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
|
|
95
117
|
for (const key of Object.keys(childEnvironment)) {
|
|
96
118
|
if (/^npm_/iu.test(key) || key.toUpperCase() === 'INIT_CWD') delete childEnvironment[key];
|
|
97
119
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
// A probe cannot reserve a port for another process. Retry only when our
|
|
121
|
+
// previous child has definitely exited, never on a live unready kernel.
|
|
122
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
123
|
+
const preferred = attempt === 0 ? Number(managedProcessUrl(record)?.split(':').at(-1)) || defaultManagedPort : 0;
|
|
124
|
+
const port = preferred && await this.available(preferred) ? preferred : await this.selectPort();
|
|
125
|
+
const url = `ws://127.0.0.1:${port}`;
|
|
126
|
+
// Commit intent before spawning: if the launcher dies before it can
|
|
127
|
+
// record the child PID, a successor must fail closed, not duplicate it.
|
|
128
|
+
writeRecord(this.pendingPath, { phase: 'spawning', url, executable: runtime.executable, startedAt: new Date().toISOString() });
|
|
129
|
+
const pid = await this.host.start(runtime.executable, ['app-server', '--listen', url], childEnvironment,
|
|
130
|
+
[path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
|
|
131
|
+
const started = await this.host.process(pid);
|
|
132
|
+
if (!started) {
|
|
133
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
134
|
+
if (attempt < 4) continue;
|
|
135
|
+
throw new Error('Codex 内核启动后退出,动态端口尝试已用完。');
|
|
136
|
+
}
|
|
137
|
+
record = { ...started, url, launcherRevision: managedAppServerLauncherRevision };
|
|
138
|
+
if (!sameManagedProcess(record, started) || executableKey(started.executable) !== executableKey(runtime.executable)) {
|
|
139
|
+
throw new Error('Codex 内核启动后进程身份无法确认,未启动第二个实例。');
|
|
140
|
+
}
|
|
141
|
+
// Private ownership journal is not a published endpoint. It prevents
|
|
142
|
+
// duplicate children even if the launcher crashes before readiness.
|
|
143
|
+
writeRecord(this.pendingPath, record);
|
|
144
|
+
actual = started;
|
|
145
|
+
try { await this.connectReady(record); break; }
|
|
146
|
+
catch (error) {
|
|
147
|
+
const remaining = await this.host.process(pid);
|
|
148
|
+
if (remaining || attempt === 4) throw error;
|
|
149
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
150
|
+
actual = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} else await this.connectReady(record);
|
|
154
|
+
const url = managedProcessUrl(record);
|
|
155
|
+
// Do not publish a guessed port or update Desktop before ownership,
|
|
156
|
+
// readiness and protocol initialization have all succeeded.
|
|
157
|
+
if (this.publishedUrl !== url) await this.configureEnvironment(this.stateRoot, url);
|
|
158
|
+
this.environment.CODEX_APP_SERVER_WS_URL = url;
|
|
159
|
+
process.env.CODEX_APP_SERVER_WS_URL = url;
|
|
160
|
+
if (this.publishedUrl !== url || readRecord(this.pendingPath)) writeRecord(this.recordPath, { ...record, url });
|
|
161
|
+
this.publishedUrl = url;
|
|
162
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
112
163
|
if (startedHere) {
|
|
113
164
|
try { await pruneRetainedDesktopRuntimes(this.stateRoot, actual.executable); }
|
|
114
165
|
catch (error) { console.error('旧版 Codex 内核文件清理失败:', error); }
|
|
@@ -122,21 +173,93 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
122
173
|
created: actual.created, executable: actual.executable, preferredExecutable,
|
|
123
174
|
updatePending, launcherRevision, selectedVersion: runtime.version, source: runtime.source,
|
|
124
175
|
launcherRestartRequired: launcherRevision !== managedAppServerLauncherRevision,
|
|
125
|
-
url
|
|
176
|
+
url });
|
|
126
177
|
return true;
|
|
178
|
+
} finally { release(); }
|
|
127
179
|
} catch (error) {
|
|
128
180
|
this.failed(error instanceof Error ? error.message : String(error));
|
|
129
181
|
return false;
|
|
130
182
|
}
|
|
131
183
|
}
|
|
132
184
|
|
|
185
|
+
async acquireLock() {
|
|
186
|
+
const token = randomUUID();
|
|
187
|
+
const temporary = `${this.lockPath}.${token}.tmp`;
|
|
188
|
+
const owner = await this.host.process(process.pid);
|
|
189
|
+
if (!owner) throw new Error('启动器进程身份无法确认。');
|
|
190
|
+
try {
|
|
191
|
+
// Publish a fully written record atomically; a crash cannot leave an
|
|
192
|
+
// empty lock that has no identifiable owner.
|
|
193
|
+
fs.writeFileSync(temporary, JSON.stringify({ ...owner, token }), { flag: 'wx', mode: 0o600 });
|
|
194
|
+
try { fs.linkSync(temporary, this.lockPath); }
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error.code !== 'EEXIST') throw error;
|
|
197
|
+
const previous = readRecord(this.lockPath);
|
|
198
|
+
if (!previous?.token) throw new Error('内核启动锁无法识别,请检查 managed-app-server.lock。');
|
|
199
|
+
const actual = await this.host.process(previous.pid);
|
|
200
|
+
if (!actual || actual.created !== previous.created || executableKey(actual.executable) !== executableKey(previous.executable)) {
|
|
201
|
+
// One permanent tombstone per abandoned generation means two stale
|
|
202
|
+
// observers cannot delete a replacement owner's lock (ABA race).
|
|
203
|
+
if (await this.claimRecovery(previous, this.lockPath, temporary)) {
|
|
204
|
+
if (readRecord(this.lockPath)?.token === previous.token) fs.rmSync(this.lockPath, { force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return () => { if (readRecord(this.lockPath)?.token === token) fs.rmSync(this.lockPath, { force: true }); };
|
|
210
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async claimRecovery(previous, target, ownerFile, depth = 0) {
|
|
214
|
+
if (depth > 16) throw new Error('内核启动锁多次异常中断,请检查启动器日志。');
|
|
215
|
+
const recovered = `${this.lockPath}.${previous.token}.recovered`;
|
|
216
|
+
try { fs.linkSync(ownerFile, recovered); return true; }
|
|
217
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
218
|
+
const claimant = readRecord(recovered);
|
|
219
|
+
if (!claimant?.token) throw new Error('内核启动锁回收记录无法识别。');
|
|
220
|
+
const actual = await this.host.process(claimant.pid);
|
|
221
|
+
if (actual && actual.created === claimant.created && executableKey(actual.executable) === executableKey(claimant.executable)) return false;
|
|
222
|
+
// A reclaimer can itself crash. Keep an append-only ownership chain so its
|
|
223
|
+
// successor can complete cleanup without allowing two live reclaimers.
|
|
224
|
+
return this.claimRecovery(claimant, recovered, ownerFile, depth + 1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async connectReady(expected) {
|
|
228
|
+
const url = managedProcessUrl(expected);
|
|
229
|
+
if (!url) throw new Error('Codex 内核地址无效。');
|
|
230
|
+
await this.waitUntilReady(expected);
|
|
231
|
+
if (this.client.url !== url) {
|
|
232
|
+
await this.disconnectClient();
|
|
233
|
+
this.client.url = url;
|
|
234
|
+
}
|
|
235
|
+
await this.client.connect();
|
|
236
|
+
if (!this.client.ready || !sameManagedProcess(expected, await this.host.process(expected.pid))
|
|
237
|
+
|| !await this.host.ownsListener(expected, url)) {
|
|
238
|
+
this.client.close();
|
|
239
|
+
throw new Error('Codex 内核协议初始化或监听进程验证未完成。');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async disconnectClient() {
|
|
244
|
+
const socket = this.client.socket;
|
|
245
|
+
this.client.close();
|
|
246
|
+
if (socket && socket.readyState !== 3) {
|
|
247
|
+
await new Promise(resolve => {
|
|
248
|
+
const timer = setTimeout(() => { socket.terminate(); }, 1000);
|
|
249
|
+
socket.once('close', () => { clearTimeout(timer); resolve(); });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
this.client.ready = false;
|
|
253
|
+
}
|
|
254
|
+
|
|
133
255
|
async waitUntilReady(expected) {
|
|
256
|
+
const url = managedProcessUrl(expected);
|
|
134
257
|
const deadline = this.now() + this.timeout;
|
|
135
258
|
while (true) {
|
|
136
259
|
const actual = await this.host.process(expected.pid);
|
|
137
260
|
if (!sameManagedProcess(expected, actual)) throw new Error('Codex 内核在就绪前退出。');
|
|
138
|
-
if (await this.host.ready(
|
|
139
|
-
if (this.now() >= deadline) throw new Error('Codex 内核未在 30
|
|
261
|
+
if (await this.host.ownsListener(expected, url) && await this.host.ready(url.replace('ws:', 'http:') + '/readyz')) return;
|
|
262
|
+
if (this.now() >= deadline) throw new Error('Codex 内核未在 30 秒内就绪;保留已启动实例,不会另起重复内核。');
|
|
140
263
|
await this.wait(250);
|
|
141
264
|
}
|
|
142
265
|
}
|
|
@@ -158,33 +281,37 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
158
281
|
|
|
159
282
|
async restartNow() {
|
|
160
283
|
this.restarting = true;
|
|
284
|
+
await this.work;
|
|
161
285
|
const previous = this.state;
|
|
286
|
+
let release;
|
|
162
287
|
this.publish({ ...previous, state: 'restarting', ready: false, error: null });
|
|
163
288
|
try {
|
|
164
|
-
|
|
289
|
+
release = await this.acquireLock();
|
|
290
|
+
if (!release) throw new Error('另一个内核启动操作正在进行,请稍后重试。');
|
|
291
|
+
const pending = readRecord(this.pendingPath);
|
|
292
|
+
if (pending?.phase === 'spawning') throw new Error('上次内核启动交接未完成,无法安全重启未知进程;请检查内核启动日志。');
|
|
293
|
+
const record = pending || readRecord(this.recordPath);
|
|
165
294
|
const actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
166
295
|
if (actual && !sameManagedProcess(record, actual)) {
|
|
167
296
|
throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
|
|
168
297
|
}
|
|
169
|
-
if (!actual && !await this.available()) throw new Error('45839 被未识别进程占用,未结束任何进程。');
|
|
170
298
|
this.restartStage('stopping');
|
|
171
299
|
// Resolve the replacement before stopping the current working process.
|
|
172
|
-
const runtime = await
|
|
300
|
+
const runtime = await this.resolveRuntime(this.stateRoot, { refresh: true, environment: this.environment });
|
|
173
301
|
const desktop = await this.host.prepareKernelRestart?.();
|
|
174
|
-
this.
|
|
302
|
+
await this.disconnectClient();
|
|
175
303
|
if (actual) {
|
|
176
304
|
await this.host.stop(actual.pid, actual);
|
|
177
305
|
await this.waitUntilStopped(actual);
|
|
178
|
-
} else if (!await this.available()) {
|
|
179
|
-
throw new Error('45839 被未识别进程占用,未结束任何进程。');
|
|
180
306
|
}
|
|
181
|
-
fs.rmSync(this.
|
|
307
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
308
|
+
this.publishedUrl = null;
|
|
182
309
|
this.retryAttempt = 0;
|
|
183
310
|
this.retryAfter = 0;
|
|
184
311
|
this.restartStage('starting');
|
|
185
|
-
const ready = await this.ensureNow();
|
|
312
|
+
const ready = await this.ensureNow(true);
|
|
186
313
|
if (!ready) throw new Error(this.state.error || 'Codex 内核未能重新启动。');
|
|
187
|
-
const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL:
|
|
314
|
+
const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL: this.state.url };
|
|
188
315
|
delete desktopEnvironment.CODEX_APP_SERVER_FORCE_CLI;
|
|
189
316
|
delete desktopEnvironment.CODEX_APP_TOOLS_PIPE_PATH;
|
|
190
317
|
await this.host.resumeAfterKernelRestart?.(desktop, desktopEnvironment);
|
|
@@ -198,6 +325,7 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
198
325
|
completedAt: new Date(this.now()).toISOString() };
|
|
199
326
|
this.emit('state', this.snapshot());
|
|
200
327
|
} finally {
|
|
328
|
+
release?.();
|
|
201
329
|
this.restarting = false;
|
|
202
330
|
}
|
|
203
331
|
}
|
|
@@ -211,7 +339,7 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
211
339
|
const deadline = this.now() + 10_000;
|
|
212
340
|
while (true) {
|
|
213
341
|
const actual = await this.host.process(expected.pid);
|
|
214
|
-
if (!sameManagedProcess(expected, actual)
|
|
342
|
+
if (!sameManagedProcess(expected, actual)) return;
|
|
215
343
|
if (this.now() >= deadline) throw new Error('Codex 内核未在 10 秒内退出;不会启动第二个实例。');
|
|
216
344
|
await this.wait(100);
|
|
217
345
|
}
|
|
@@ -238,8 +366,8 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
238
366
|
export function sameManagedProcess(record, actual) {
|
|
239
367
|
if (!record || !actual || record.pid !== actual.pid || record.created !== actual.created) return false;
|
|
240
368
|
if (!record.executable || !actual.executable || executableKey(record.executable) !== executableKey(actual.executable)) return false;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
369
|
+
const url = managedProcessUrl(record);
|
|
370
|
+
if (!url || managedProcessUrl(actual) !== url) return false;
|
|
371
|
+
return Array.isArray(actual.args) ? JSON.stringify(record.args) === JSON.stringify(actual.args)
|
|
372
|
+
: record.command === actual.command;
|
|
245
373
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readRecord } from './service-lifecycle.mjs';
|
|
3
|
+
|
|
4
|
+
export const defaultManagedPort = 45839;
|
|
5
|
+
export function managedEndpointUrl(value) {
|
|
6
|
+
if (typeof value !== 'string' || !/^ws:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(value)) return null;
|
|
7
|
+
const port = Number(value.slice(value.lastIndexOf(':') + 1));
|
|
8
|
+
return port <= 65535 ? `ws://127.0.0.1:${port}` : null;
|
|
9
|
+
}
|
|
10
|
+
export function managedProcessUrl(record) {
|
|
11
|
+
if (!record) return null;
|
|
12
|
+
let value;
|
|
13
|
+
if (Array.isArray(record.args)) {
|
|
14
|
+
if (record.args.length !== 4 || record.args[1] !== 'app-server' || record.args[2] !== '--listen') return null;
|
|
15
|
+
value = record.args[3];
|
|
16
|
+
} else {
|
|
17
|
+
const match = /^\s*(?:"[^"]+"|\S+)\s+app-server\s+--listen\s+(?:"(ws:\/\/127\.0\.0\.1:[0-9]+)"|(ws:\/\/127\.0\.0\.1:[0-9]+))\s*$/i.exec(record.command || '');
|
|
18
|
+
value = match?.[1] || match?.[2];
|
|
19
|
+
}
|
|
20
|
+
const url = managedEndpointUrl(value);
|
|
21
|
+
return url && (!record.url || record.url === url) ? url : null;
|
|
22
|
+
}
|
|
23
|
+
export function readManagedEndpoint(stateRoot) {
|
|
24
|
+
return managedProcessUrl(readRecord(path.join(stateRoot, 'managed-app-server.json')));
|
|
25
|
+
}
|
|
@@ -8,7 +8,7 @@ import { runtimePaths } from '../runtime-paths.mjs';
|
|
|
8
8
|
import { readRecord } from '../../service-lifecycle.mjs';
|
|
9
9
|
export { residentStartup } from './resident-startup.mjs';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
import { managedEndpointUrl, readManagedEndpoint } from '../../managed-endpoint.mjs';
|
|
12
12
|
export function createDesktopHost({ request = systemRequest, wait = delay, now = Date.now, timeout = 10000,
|
|
13
13
|
stateRoot = () => runtimePaths().stateRoot, fingerprint = file => {
|
|
14
14
|
const stat = fs.statSync(path.join(file, 'resources/linux-package-metadata.json'));
|
|
@@ -17,20 +17,24 @@ export function createDesktopHost({ request = systemRequest, wait = delay, now =
|
|
|
17
17
|
return {
|
|
18
18
|
desktopIntegration: true,
|
|
19
19
|
async inspect() {
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const url = readManagedEndpoint(stateRoot());
|
|
21
|
+
const info = await request({ operation: 'desktop-info', url });
|
|
22
|
+
return { ...info, managedUrl: url, desktopIdentity: info.desktop ? `${info.desktop}:${info.version}:${info.build}:${fingerprint(info.desktop)}` : null };
|
|
22
23
|
},
|
|
23
24
|
process: pid => request({ operation: 'process', pid }),
|
|
25
|
+
ownsListener: (expected, url) => request({ operation: 'owns-listener', expected, url }),
|
|
24
26
|
start: startBackground,
|
|
25
27
|
async stop(pid, expected) {
|
|
26
28
|
if (!expected || expected.pid !== pid) throw new Error('停止内核前缺少进程身份,未结束任何进程。');
|
|
27
29
|
return request({ operation: 'stop', expected });
|
|
28
30
|
},
|
|
29
31
|
async sharedServiceUrl() {
|
|
30
|
-
|
|
32
|
+
const info = await this.inspect();
|
|
33
|
+
return info.connectedToManagedServer ? info.managedUrl : '';
|
|
31
34
|
},
|
|
32
35
|
async startDesktop(bundlePath, environment) {
|
|
33
|
-
|
|
36
|
+
const managedUrl = managedEndpointUrl(environment.CODEX_APP_SERVER_WS_URL);
|
|
37
|
+
if (!managedUrl || managedUrl !== readManagedEndpoint(stateRoot())) throw new Error('Desktop 内核地址与工作站不一致。');
|
|
34
38
|
const runtime = readRecord(path.join(stateRoot(), 'codex-runtime.json'));
|
|
35
39
|
const codexHome = environment.CODEX_HOME || runtime?.codexHome;
|
|
36
40
|
if (!codexHome || !path.isAbsolute(codexHome)) throw new Error('缺少已选内核的 CODEX_HOME,请先运行 relax setup。');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { managedEndpointUrl } from '../../managed-endpoint.mjs';
|
|
2
3
|
|
|
3
4
|
const gone = error => ['ENOENT', 'ESRCH'].includes(error.code);
|
|
4
5
|
let bootId;
|
|
@@ -27,7 +28,10 @@ export function stopProcess(pid, expected) {
|
|
|
27
28
|
process.kill(pid, 'SIGTERM');
|
|
28
29
|
return { stopped: true };
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
function hasManagedSocket(pid, url, listener) {
|
|
32
|
+
const endpoint = managedEndpointUrl(url);
|
|
33
|
+
if (!endpoint) return false;
|
|
34
|
+
const port = Number(endpoint.slice(endpoint.lastIndexOf(':') + 1)).toString(16).toUpperCase().padStart(4, '0');
|
|
31
35
|
try {
|
|
32
36
|
const sockets = new Set(fs.readdirSync(`/proc/${pid}/fd`).flatMap(fd => {
|
|
33
37
|
try { return /^socket:\[(\d+)\]$/.exec(fs.readlinkSync(`/proc/${pid}/fd/${fd}`))?.slice(1) || []; }
|
|
@@ -35,8 +39,15 @@ export function connectedToManagedServer(pid) {
|
|
|
35
39
|
}));
|
|
36
40
|
return ['tcp', 'tcp6'].some(table => fs.readFileSync(`/proc/${pid}/net/${table}`, 'utf8').trim().split('\n').slice(1).some(line => {
|
|
37
41
|
const fields = line.trim().split(/\s+/);
|
|
38
|
-
return fields[3] === '01' && sockets.has(fields[9])
|
|
39
|
-
&& [
|
|
42
|
+
return fields[3] === (listener ? '0A' : '01') && sockets.has(fields[9])
|
|
43
|
+
&& [`0100007F:${port}`, `0000000000000000FFFF00000100007F:${port}`].includes(fields[listener ? 1 : 2]);
|
|
40
44
|
}));
|
|
41
45
|
} catch (error) { if (gone(error)) return false; throw error; }
|
|
42
46
|
}
|
|
47
|
+
|
|
48
|
+
export const connectedToManagedServer = (pid, url) => hasManagedSocket(pid, url, false);
|
|
49
|
+
export function ownsManagedListener(expected, url) {
|
|
50
|
+
return !!expected && sameProcess(expected, processInfo(expected.pid))
|
|
51
|
+
&& hasManagedSocket(expected.pid, url, true)
|
|
52
|
+
&& sameProcess(expected, processInfo(expected.pid));
|
|
53
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { installedDesktop, verifyDesktopPackage } from './desktop-installation.mjs';
|
|
4
|
-
import { processInfo, sameProcess, stopProcess, connectedToManagedServer } from './process.mjs';
|
|
4
|
+
import { processInfo, sameProcess, stopProcess, connectedToManagedServer, ownsManagedListener } from './process.mjs';
|
|
5
5
|
import { startBackground } from '../../background-process.mjs';
|
|
6
6
|
|
|
7
7
|
// Package upgrades unlink the old executable while its process stays alive.
|
|
@@ -10,6 +10,7 @@ import { startBackground } from '../../background-process.mjs';
|
|
|
10
10
|
const desktopExecutableMatches = (actual, installed) => actual === installed || actual === `${installed} (deleted)`;
|
|
11
11
|
|
|
12
12
|
export async function systemRequest(request) {
|
|
13
|
+
if (request.operation === 'owns-listener') return ownsManagedListener(request.expected, request.url);
|
|
13
14
|
if (request.operation === 'process') return processInfo(request.pid);
|
|
14
15
|
if (request.operation === 'stop') return stopProcess(request.expected?.pid, request.expected);
|
|
15
16
|
const installed = installedDesktop();
|
|
@@ -25,7 +26,7 @@ export async function systemRequest(request) {
|
|
|
25
26
|
}
|
|
26
27
|
if (running.length > 1) throw new Error('发现多个 ChatGPT 桌面实例,请先关闭多余实例。');
|
|
27
28
|
const info = running[0] || null;
|
|
28
|
-
const connected = info ? connectedToManagedServer(info.pid) : false;
|
|
29
|
+
const connected = info ? connectedToManagedServer(info.pid, request.url) : false;
|
|
29
30
|
if (info && !sameProcess(info, processInfo(info.pid))) throw new Error('核对期间桌面进程发生变化。');
|
|
30
31
|
return { ...(installed || { desktop: null, version: '', build: '' }), running: !!info,
|
|
31
32
|
finishedLaunching: connected, connectedToManagedServer: connected, process: info };
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { managedEndpointUrl } from '../../managed-endpoint.mjs';
|
|
1
2
|
// Only the new Desktop process receives the managed connection settings.
|
|
2
3
|
// Do not mutate shell profiles or the login session's global environment.
|
|
3
|
-
export async function configureManagedEnvironment() {
|
|
4
|
+
export async function configureManagedEnvironment(stateRoot, url) {
|
|
5
|
+
if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址。');
|
|
4
6
|
return { configured: true, changed: false, owned: false, persistent: false,
|
|
5
|
-
scope: 'application-launch', value:
|
|
7
|
+
scope: 'application-launch', value: url };
|
|
6
8
|
}
|
|
7
9
|
export async function clearManagedEnvironment() { return { removed: false, preserved: false, reason: 'application-launch-only' }; }
|
|
@@ -8,7 +8,7 @@ import { runtimePaths } from '../runtime-paths.mjs';
|
|
|
8
8
|
import { readRecord } from '../../service-lifecycle.mjs';
|
|
9
9
|
export { residentStartup } from './resident-startup.mjs';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
import { managedEndpointUrl, readManagedEndpoint } from '../../managed-endpoint.mjs';
|
|
12
12
|
export function createDesktopHost({ request = systemRequest, wait = delay, now = Date.now, timeout = 10000,
|
|
13
13
|
stateRoot = () => runtimePaths().stateRoot, fingerprint = file => {
|
|
14
14
|
const stat = fs.statSync(path.join(file, 'Contents/Info.plist'));
|
|
@@ -17,20 +17,24 @@ export function createDesktopHost({ request = systemRequest, wait = delay, now =
|
|
|
17
17
|
return {
|
|
18
18
|
desktopIntegration: true,
|
|
19
19
|
async inspect() {
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const url = readManagedEndpoint(stateRoot());
|
|
21
|
+
const info = await request({ operation: 'desktop-info', url });
|
|
22
|
+
return { ...info, managedUrl: url, desktopIdentity: info.desktop ? `${info.desktop}:${info.version}:${info.build}:${fingerprint(info.desktop)}` : null };
|
|
22
23
|
},
|
|
23
24
|
process: pid => request({ operation: 'process', pid }),
|
|
25
|
+
ownsListener: (expected, url) => request({ operation: 'owns-listener', expected, url }),
|
|
24
26
|
start: startBackground,
|
|
25
27
|
async stop(pid, expected) {
|
|
26
28
|
if (!expected || expected.pid !== pid) throw new Error('停止内核前缺少进程身份,未结束任何进程。');
|
|
27
29
|
return request({ operation: 'stop', expected });
|
|
28
30
|
},
|
|
29
31
|
async sharedServiceUrl() {
|
|
30
|
-
|
|
32
|
+
const info = await this.inspect();
|
|
33
|
+
return info.connectedToManagedServer ? info.managedUrl : '';
|
|
31
34
|
},
|
|
32
35
|
async startDesktop(bundlePath, environment) {
|
|
33
|
-
|
|
36
|
+
const managedUrl = managedEndpointUrl(environment.CODEX_APP_SERVER_WS_URL);
|
|
37
|
+
if (!managedUrl || managedUrl !== readManagedEndpoint(stateRoot())) throw new Error('Desktop 内核地址与工作站不一致。');
|
|
34
38
|
const runtime = readRecord(path.join(stateRoot(), 'codex-runtime.json'));
|
|
35
39
|
const codexHome = environment.CODEX_HOME || runtime?.codexHome;
|
|
36
40
|
if (!codexHome || !path.isAbsolute(codexHome)) throw new Error('缺少已选内核的 CODEX_HOME,请先运行 relax setup。');
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { managedEndpointUrl } from '../../managed-endpoint.mjs';
|
|
1
2
|
// Launch Services receives these settings for one new application instance.
|
|
2
3
|
// Do not mutate shell profiles or the login session's global environment.
|
|
3
|
-
export async function configureManagedEnvironment() {
|
|
4
|
+
export async function configureManagedEnvironment(stateRoot, url) {
|
|
5
|
+
if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址。');
|
|
4
6
|
return { configured: true, changed: false, owned: false, persistent: false,
|
|
5
|
-
scope: 'application-launch', value:
|
|
7
|
+
scope: 'application-launch', value: url };
|
|
6
8
|
}
|
|
7
9
|
export async function clearManagedEnvironment() { return { removed: false, preserved: false, reason: 'application-launch-only' }; }
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
)
|
|
5
5
|
$ErrorActionPreference = 'Stop'
|
|
6
6
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
7
|
+
if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
|
|
7
8
|
$package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
8
9
|
if (-not $package -or $Executable -ne (Join-Path $package.InstallLocation 'app/ChatGPT.exe')) {
|
|
9
10
|
throw 'Desktop executable identity changed; activation stopped.'
|
|
@@ -3,6 +3,8 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { managedEndpointUrl, readManagedEndpoint } from '../../managed-endpoint.mjs';
|
|
7
|
+
import { runtimePaths } from './runtime-paths.mjs';
|
|
6
8
|
import { sharedServiceUrl } from './service-host.mjs';
|
|
7
9
|
const exec = promisify(execFile);
|
|
8
10
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -15,15 +17,20 @@ export const desktopHost = {
|
|
|
15
17
|
inspect: async () => JSON.parse(await runScript('desktop-info.ps1')),
|
|
16
18
|
process: async pid => JSON.parse(await runScript('desktop-info.ps1', ['-ProcessId', String(pid)])),
|
|
17
19
|
sharedServiceUrl,
|
|
20
|
+
ownsListener: async (expected, url) => JSON.parse(await runScript('desktop-info.ps1', ['-ProcessId', String(expected.pid), '-Expected', JSON.stringify(expected), '-AppServerUrl', url, '-CheckListener'])),
|
|
18
21
|
start: startWindowsProcess,
|
|
19
|
-
startDesktop: (executable, environment) =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
startDesktop: (executable, environment) => {
|
|
23
|
+
const url = managedEndpointUrl(environment.CODEX_APP_SERVER_WS_URL);
|
|
24
|
+
if (!url || url !== readManagedEndpoint(runtimePaths().stateRoot)) throw new Error('Desktop 内核地址与工作站不一致。');
|
|
25
|
+
return runScript('activate-desktop.ps1', [
|
|
26
|
+
'-Executable', executable, '-AppServerUrl', url,
|
|
27
|
+
]);
|
|
28
|
+
},
|
|
22
29
|
async desktopReady() {
|
|
23
30
|
return (await this.inspect()).hasWindow === true && Boolean(await this.sharedServiceUrl());
|
|
24
31
|
},
|
|
25
32
|
closeDesktop: executable => runScript('stop-desktop.ps1', ['-Executable', executable]),
|
|
26
|
-
stop: pid =>
|
|
33
|
+
stop: (pid, expected) => runScript('desktop-info.ps1', ['-ProcessId', String(pid), '-Expected', JSON.stringify(expected), '-StopManagedProcess']),
|
|
27
34
|
async prepareKernelRestart() {
|
|
28
35
|
const info = await this.inspect();
|
|
29
36
|
if (!info.running || !await this.sharedServiceUrl()) return null;
|
|
@@ -1,6 +1,30 @@
|
|
|
1
|
-
param([int]$ProcessId = 0)
|
|
1
|
+
param([int]$ProcessId = 0, [string]$Expected, [string]$AppServerUrl, [switch]$CheckListener, [switch]$StopManagedProcess)
|
|
2
2
|
$ErrorActionPreference = 'Stop'
|
|
3
3
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
+
function Read-Identity([int]$Id) {
|
|
5
|
+
$item = Get-CimInstance Win32_Process -Filter "ProcessId=$Id"
|
|
6
|
+
if ($item) { return @{ pid=$item.ProcessId; executable=$item.ExecutablePath; command=$item.CommandLine; created=$item.CreationDate.ToUniversalTime().ToString('o') } }
|
|
7
|
+
return $null
|
|
8
|
+
}
|
|
9
|
+
function Same-Identity($a, $b) {
|
|
10
|
+
return $null -ne $a -and $null -ne $b -and $a.pid -eq $b.pid -and $a.executable -ceq $b.executable -and $a.command -ceq $b.command -and $a.created -ceq $b.created
|
|
11
|
+
}
|
|
12
|
+
if ($CheckListener -or $StopManagedProcess) {
|
|
13
|
+
if ($ProcessId -le 0 -or -not $Expected) { throw 'Missing managed process identity.' }
|
|
14
|
+
$identity = $Expected | ConvertFrom-Json
|
|
15
|
+
if ($identity.pid -ne $ProcessId) { throw 'Process identity mismatch.' }
|
|
16
|
+
$actual = Read-Identity $ProcessId
|
|
17
|
+
if (-not (Same-Identity $identity $actual)) {
|
|
18
|
+
if ($StopManagedProcess) { throw 'Process identity changed; nothing stopped.' }
|
|
19
|
+
'false'; exit
|
|
20
|
+
}
|
|
21
|
+
if ($StopManagedProcess) { Stop-Process -Id $ProcessId -ErrorAction Stop; '{"stopped":true}'; exit }
|
|
22
|
+
if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
|
|
23
|
+
$port = [int]$Matches[1]
|
|
24
|
+
$listeners = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalAddress -eq '127.0.0.1' -and $_.OwningProcess -eq $ProcessId })
|
|
25
|
+
[bool]($listeners.Count -gt 0 -and (Same-Identity $identity (Read-Identity $ProcessId))) | ConvertTo-Json -Compress
|
|
26
|
+
exit
|
|
27
|
+
}
|
|
4
28
|
if ($ProcessId -gt 0) {
|
|
5
29
|
$item = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId"
|
|
6
30
|
if ($item) { @{ pid=$item.ProcessId; executable=$item.ExecutablePath; command=$item.CommandLine; created=$item.CreationDate.ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress }
|
|
@@ -5,19 +5,23 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import { runtimePaths } from './runtime-paths.mjs';
|
|
7
7
|
import { readRecord } from '../../service-lifecycle.mjs';
|
|
8
|
+
import { readManagedEndpoint } from '../../managed-endpoint.mjs';
|
|
8
9
|
import { lanNetworks } from './network-info.mjs';
|
|
9
10
|
|
|
10
11
|
const exec = promisify(execFile);
|
|
11
12
|
const bridgeRoot = fileURLToPath(new URL('../../../', import.meta.url));
|
|
12
|
-
async function detect(script) {
|
|
13
|
+
async function detect(script, args = []) {
|
|
13
14
|
const executable = path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
14
15
|
const { stdout } = await exec(executable, ['-NoProfile', '-NonInteractive', '-File',
|
|
15
|
-
path.join(bridgeRoot, script)], { windowsHide: true, encoding: 'utf8' });
|
|
16
|
+
path.join(bridgeRoot, script), ...args], { windowsHide: true, encoding: 'utf8' });
|
|
16
17
|
return stdout.trim();
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
// Windows-only network metadata stays here, outside the lifecycle implementation.
|
|
20
|
-
export const sharedServiceUrl = () =>
|
|
21
|
+
export const sharedServiceUrl = () => {
|
|
22
|
+
const url = readManagedEndpoint(runtimePaths().stateRoot);
|
|
23
|
+
return url ? detect('get-shared-service-url.ps1', ['-AppServerUrl', url]) : '';
|
|
24
|
+
};
|
|
21
25
|
export const lanAddress = async () => (await lanNetworks())[0]?.address || '';
|
|
22
26
|
export { lanNetworks };
|
|
23
27
|
export async function bridgeEnvironment() {
|
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
|
|
4
4
|
import { runScript } from './desktop-host.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { managedEndpointUrl } from '../../managed-endpoint.mjs';
|
|
6
6
|
|
|
7
7
|
const ownershipFile = 'managed-environment.json';
|
|
8
8
|
|
|
@@ -10,22 +10,24 @@ export async function readManagedEnvironment() {
|
|
|
10
10
|
return JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Read']));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export async function configureManagedEnvironment(stateRoot) {
|
|
13
|
+
export async function configureManagedEnvironment(stateRoot, url) {
|
|
14
14
|
const current = await readManagedEnvironment();
|
|
15
|
+
if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址,未修改环境变量。');
|
|
15
16
|
const ownershipPath = path.join(stateRoot, ownershipFile);
|
|
16
17
|
const ownership = readRecord(ownershipPath);
|
|
17
18
|
if (current.forceCliUser === '1' || current.forceCliMachine === '1') {
|
|
18
19
|
throw new Error('CODEX_APP_SERVER_FORCE_CLI=1 会让 Codex 忽略当前内核地址;未修改该配置。');
|
|
19
20
|
}
|
|
20
|
-
if (current.userValue && current.userValue !==
|
|
21
|
+
if (current.userValue && current.userValue !== url && current.userValue !== ownership?.value) {
|
|
21
22
|
throw new Error(`用户已有 CODEX_APP_SERVER_WS_URL=${current.userValue};未覆盖该配置。`);
|
|
22
23
|
}
|
|
23
|
-
if (current.userValue ===
|
|
24
|
-
const owned = ownership?.value ===
|
|
24
|
+
if (current.userValue === url) {
|
|
25
|
+
const owned = ownership?.value === url;
|
|
25
26
|
return { configured: true, value: current.userValue, changed: false, owned };
|
|
26
27
|
}
|
|
27
|
-
const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value',
|
|
28
|
-
|
|
28
|
+
const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value', url]));
|
|
29
|
+
if (applied.userValue !== url) throw new Error('CODEX_APP_SERVER_WS_URL 写入后读回不一致。');
|
|
30
|
+
writeRecord(ownershipPath, { name: 'CODEX_APP_SERVER_WS_URL', value: url,
|
|
29
31
|
configuredAt: new Date().toISOString() });
|
|
30
32
|
return { configured: true, value: applied.userValue, changed: true, owned: true };
|
|
31
33
|
}
|
|
@@ -38,10 +40,10 @@ export async function clearManagedEnvironment(stateRoot) {
|
|
|
38
40
|
fs.rmSync(ownershipPath, { force: true });
|
|
39
41
|
return { removed: false, preserved: false, reason: 'already-empty' };
|
|
40
42
|
}
|
|
41
|
-
if (ownership?.value
|
|
43
|
+
if (!managedEndpointUrl(ownership?.value) || current.userValue !== ownership.value) {
|
|
42
44
|
fs.rmSync(ownershipPath, { force: true });
|
|
43
45
|
return { removed: false, preserved: true, value: current.userValue,
|
|
44
|
-
reason: ownership?.value
|
|
46
|
+
reason: managedEndpointUrl(ownership?.value) ? 'user-modified' : 'not-owned' };
|
|
45
47
|
}
|
|
46
48
|
const result = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Clear']));
|
|
47
49
|
if (result.userValue) throw new Error('CODEX_APP_SERVER_WS_URL 清理后读回仍不为空。');
|
|
@@ -29,7 +29,7 @@ export class RuntimeDiscovery {
|
|
|
29
29
|
return this.state;
|
|
30
30
|
}
|
|
31
31
|
const desktop = await this.host.inspect();
|
|
32
|
-
const attached = desktop.running && await this.host.sharedServiceUrl() === this.url;
|
|
32
|
+
const attached = desktop.running && await this.host.sharedServiceUrl() === (typeof this.url === 'function' ? this.url() : this.url);
|
|
33
33
|
this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
|
|
34
34
|
installed: Boolean(desktop.desktop), attachedToManagedServer: Boolean(attached) },
|
|
35
35
|
desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function deviceInfo(value = {}) {
|
|
2
2
|
const info = {};
|
|
3
3
|
if (value.platform !== undefined && value.platform !== null) {
|
|
4
|
-
if (!['android', 'harmony'].includes(value.platform)) throw new Error('Invalid mobile platform');
|
|
4
|
+
if (!['android', 'harmony', 'ios'].includes(value.platform)) throw new Error('Invalid mobile platform');
|
|
5
5
|
info.platform = value.platform;
|
|
6
6
|
}
|
|
7
7
|
if (value.deviceName !== undefined && value.deviceName !== null) {
|
package/dist/src/server.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import fileMentions from '../shared/file-mentions.cjs';
|
|
|
13
13
|
import fileCitations from '../shared/file-citations.cjs';
|
|
14
14
|
import { validateInlineImage } from './message-images.mjs';
|
|
15
15
|
import { ThreadWriterConflictError } from './shared-app-server.mjs';
|
|
16
|
-
import { ManagedAppServer
|
|
16
|
+
import { ManagedAppServer } from './managed-app-server.mjs';
|
|
17
17
|
import { RuntimeDiscovery } from './runtime-discovery.mjs';
|
|
18
18
|
import { streamSharedThread, threadListNotificationMethods } from './shared-thread-stream.mjs';
|
|
19
19
|
import appServerEvents from '../shared/app-server-events.cjs';
|
|
@@ -118,7 +118,7 @@ const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfile
|
|
|
118
118
|
const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
|
|
119
119
|
cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_ATTACHMENT_BYTES });
|
|
120
120
|
const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot });
|
|
121
|
-
const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url:
|
|
121
|
+
const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: () => managedAppServer.snapshot().url });
|
|
122
122
|
const relayManager = createRelayManager(stateRoot);
|
|
123
123
|
let sharedCatalog = null, executionMode = 'ordinary';
|
|
124
124
|
function selectSharedExecution(enabled) {
|
|
@@ -1892,3 +1892,4 @@ function boundedInteger(value, fallback, minimum, maximum) {
|
|
|
1892
1892
|
const parsed = Number.parseInt(value || '', 10);
|
|
1893
1893
|
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
|
|
1894
1894
|
}
|
|
1895
|
+
|
|
@@ -8,7 +8,7 @@ import { startBackground } from './background-process.mjs';
|
|
|
8
8
|
import { desktopHost } from './platform/desktop-host.mjs';
|
|
9
9
|
import * as serviceHost from './platform/service-host.mjs';
|
|
10
10
|
import { launchDesktop, portAvailable } from './desktop-launcher.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { sameManagedProcess } from './managed-app-server.mjs';
|
|
12
12
|
|
|
13
13
|
const script = fileURLToPath(import.meta.url);
|
|
14
14
|
const recordPath = root => path.join(root, 'shared-recovery.json');
|
|
@@ -45,10 +45,9 @@ export async function planRecovery(stateRoot, host, available = portAvailable) {
|
|
|
45
45
|
if (!info.desktop) throw new Error('未找到 Codex Desktop;CLI 用户无需执行 Desktop 接入。');
|
|
46
46
|
const record = readRecord(path.join(stateRoot, 'managed-app-server.json'));
|
|
47
47
|
const actual = record ? await host.process(record.pid) : null;
|
|
48
|
-
if (actual &&
|
|
48
|
+
if (actual && !sameManagedProcess(record, actual)) {
|
|
49
49
|
throw new Error('Codex 内核进程身份与记录不符,未结束任何进程。');
|
|
50
50
|
}
|
|
51
|
-
if (!actual && !await available()) throw new Error('45839 被未识别进程占用,未关闭 Desktop 或结束任何进程。');
|
|
52
51
|
return { desktop: info.desktop, action };
|
|
53
52
|
}
|
|
54
53
|
|
|
@@ -102,7 +101,6 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
|
|
|
102
101
|
try {
|
|
103
102
|
stage('checking');
|
|
104
103
|
const planned = await planRecovery(stateRoot, host, available);
|
|
105
|
-
await configureManagedEnvironment(stateRoot);
|
|
106
104
|
record = { ...record, ...planned };
|
|
107
105
|
if (fs.existsSync(path.join(stateRoot, 'desktop-launch.lock'))) throw new Error('Desktop 正在启动,未与启动操作并行恢复。');
|
|
108
106
|
stage('stopping-desktop'); await host.closeDesktop(record.desktop);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@walkhi/code-relax",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.43",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"tag": "beta"
|
|
42
42
|
},
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@walkhi/code-relax-native-win32-x64": "0.1.0-beta.
|
|
45
|
-
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.
|
|
46
|
-
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.
|
|
44
|
+
"@walkhi/code-relax-native-win32-x64": "0.1.0-beta.43",
|
|
45
|
+
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.43",
|
|
46
|
+
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.43"
|
|
47
47
|
}
|
|
48
48
|
}
|