@coclaw/openclaw-coclaw 0.26.1 → 0.26.3

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/index.js CHANGED
@@ -11,8 +11,7 @@ import { createSessionManager } from './src/session-manager/manager.js';
11
11
  import { TopicManager } from './src/topic-manager/manager.js';
12
12
  import { ChatHistoryManager, classifyChatHistorySessionKey } from './src/chat-history-manager/manager.js';
13
13
  import { generateTitle } from './src/topic-manager/title-gen.js';
14
- import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
15
- import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
14
+ import { AutoUpgradeScheduler, getLoadedPluginVersion } from './src/auto-upgrade/updater.js';
16
15
  import { createFileHandler } from './src/file-manager/handler.js';
17
16
  import { abortAgentRun } from './src/agent-abort.js';
18
17
  import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
@@ -208,10 +207,10 @@ const plugin = {
208
207
  // 防"写配置触发重启"时的反复重启)。升级补了新模型靠这条让老用户重启后自动同步。
209
208
  // config-mutation 字面量 specifier 必须出现在本入口源码里(loader 只扫入口识别 jiti alias)。
210
209
  const portalSyncP = import('openclaw/plugin-sdk/config-mutation')
211
- .then(({ mutateConfigFile }) => reconcilePortalModels({ getConfig: getClawConfig, mutateConfigFile }))
212
- .then((r) => { if (r.changed) logger.info?.('[coclaw] minimax-portal model list synced from plugin catalog'); })
210
+ .then(({ mutateConfigFile }) => reconcilePortalModels({ getConfig: getClawConfig, mutateConfigFile, logger, remoteLog }))
213
211
  .catch((err) => {
214
212
  logger.warn?.(`[coclaw] minimax-portal model reconcile failed: ${String(err?.message ?? err)}`);
213
+ remoteLog(`providerAuth.portalReconcile.failed reason=${String(err?.message ?? err)}`);
215
214
  });
216
215
  __pluginInitDone = Promise.all([topicLoadP, chatHistoryLoadP, portalSyncP]);
217
216
 
@@ -742,10 +741,13 @@ const plugin = {
742
741
  }
743
742
  });
744
743
 
745
- api.registerGatewayMethod('coclaw.upgradeHealth', async ({ respond }) => {
744
+ // 升级健康检查:返回模块加载时刻钉住的版本快照(即当前进程真正加载的代码),
745
+ // 禁止改回调用时读磁盘 package.json——重启命令失败被吞、旧 gateway 仍活着时
746
+ // 磁盘可能已是未加载的新版本,按磁盘回答会让 worker verify 假阳性通过并删掉
747
+ // 回滚备份。快照不可得时返回 { version: null },verify 侧按"未达标"保守处理。
748
+ api.registerGatewayMethod('coclaw.upgradeHealth', ({ respond }) => {
746
749
  try {
747
- const { version } = await getPackageInfo();
748
- respond(true, { version });
750
+ respond(true, { version: getLoadedPluginVersion() });
749
751
  }
750
752
  catch (err) {
751
753
  respondError(respond, err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
@@ -6,6 +6,10 @@
6
6
  * 1. runtime.state.resolveStateDir()(gateway 进程内)
7
7
  * 2. OPENCLAW_STATE_DIR 环境变量(worker 子进程,由 spawner 传入)
8
8
  * 3. ~/.openclaw(兜底默认值)
9
+ *
10
+ * 锁策略:state 文件的 read-modify-write 统一走 stateMutex;纯读不加锁。
11
+ * 锁只能护住同进程内并发(gateway 与 worker 跨进程仍各写各的),但 worker /
12
+ * scheduler 各自内部是串行流,进程内互斥已足够。
9
13
  */
10
14
  import fs from 'node:fs/promises';
11
15
  import nodePath from 'node:path';
@@ -13,12 +17,18 @@ import os from 'node:os';
13
17
 
14
18
  import { getRuntime } from '../runtime.js';
15
19
  import { atomicWriteFile } from '../utils/atomic-write.js';
20
+ import { createMutex } from '../utils/mutex.js';
16
21
 
17
22
  const CHANNEL_ID = 'coclaw';
18
23
  const STATE_FILENAME = 'upgrade-state.json';
19
24
  const LOG_FILENAME = 'upgrade-log.jsonl';
20
25
  const LOG_MAX_LINES = 200;
21
26
  const LOG_KEEP_LINES = 100;
27
+ // lastUpgrade.error 截断上限:远端上报行不宜过长;jsonl 保留完整 error
28
+ const ERROR_MAX_CHARS = 500;
29
+
30
+ const stateMutex = createMutex();
31
+ const logMutex = createMutex();
22
32
 
23
33
  export function resolveStateDir() {
24
34
  const rt = getRuntime();
@@ -38,11 +48,8 @@ export function getLogPath() {
38
48
  return nodePath.join(resolveStateDir(), CHANNEL_ID, LOG_FILENAME);
39
49
  }
40
50
 
41
- /**
42
- * 读取 upgrade-state.json
43
- * @returns {Promise<{ skippedVersions?: string[], lastCheck?: string, lastUpgrade?: object }>}
44
- */
45
- export async function readState() {
51
+ /** 不加锁的裸读,仅供本模块在 withLock 内复用(避免嵌套同把锁死锁) */
52
+ async function readStateRaw() {
46
53
  const filePath = getStatePath();
47
54
  try {
48
55
  const raw = await fs.readFile(filePath, 'utf8');
@@ -56,13 +63,26 @@ export async function readState() {
56
63
  }
57
64
  }
58
65
 
66
+ /** 不加锁的裸写,仅供本模块在 withLock 内复用 */
67
+ async function writeStateRaw(state) {
68
+ const filePath = getStatePath();
69
+ await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`);
70
+ }
71
+
72
+ /**
73
+ * 读取 upgrade-state.json(纯读不加锁,最多读到略旧快照)
74
+ * @returns {Promise<{ skippedVersions?: string[], lastCheck?: string, lastUpgrade?: object, inflight?: object }>}
75
+ */
76
+ export async function readState() {
77
+ return readStateRaw();
78
+ }
79
+
59
80
  /**
60
81
  * 写入 upgrade-state.json(完整覆盖)
61
82
  * @param {object} state
62
83
  */
63
84
  export async function writeState(state) {
64
- const filePath = getStatePath();
65
- await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`);
85
+ await stateMutex.withLock(() => writeStateRaw(state));
66
86
  }
67
87
 
68
88
  /**
@@ -70,22 +90,31 @@ export async function writeState(state) {
70
90
  * @param {string} version
71
91
  */
72
92
  export async function addSkippedVersion(version) {
73
- const state = await readState();
93
+ await stateMutex.withLock(async () => {
94
+ const state = await readStateRaw();
95
+ appendSkippedTo(state, version);
96
+ await writeStateRaw(state);
97
+ });
98
+ }
99
+
100
+ /** 在 state 对象上原地追加 skippedVersions(去重) */
101
+ function appendSkippedTo(state, version) {
74
102
  const skipped = Array.isArray(state.skippedVersions) ? state.skippedVersions : [];
75
103
  if (!skipped.includes(version)) {
76
104
  skipped.push(version);
77
105
  }
78
106
  state.skippedVersions = skipped;
79
- await writeState(state);
80
107
  }
81
108
 
82
109
  /**
83
110
  * 更新 lastCheck 时间戳
84
111
  */
85
112
  export async function updateLastCheck() {
86
- const state = await readState();
87
- state.lastCheck = new Date().toISOString();
88
- await writeState(state);
113
+ await stateMutex.withLock(async () => {
114
+ const state = await readStateRaw();
115
+ state.lastCheck = new Date().toISOString();
116
+ await writeStateRaw(state);
117
+ });
89
118
  }
90
119
 
91
120
  /**
@@ -93,21 +122,107 @@ export async function updateLastCheck() {
93
122
  * @param {{ from: string, to: string, result: string }} info
94
123
  */
95
124
  export async function updateLastUpgrade(info) {
96
- const state = await readState();
97
- state.lastUpgrade = { ...info, ts: new Date().toISOString() };
98
- await writeState(state);
125
+ await stateMutex.withLock(async () => {
126
+ const state = await readStateRaw();
127
+ state.lastUpgrade = { ...info, ts: new Date().toISOString() };
128
+ await writeStateRaw(state);
129
+ });
130
+ }
131
+
132
+ /**
133
+ * 读取 inflight 标记(纯读不加锁)
134
+ * @returns {Promise<object|null>}
135
+ */
136
+ export async function readInflight() {
137
+ const state = await readStateRaw();
138
+ /* c8 ignore next -- ?? fallback */
139
+ return state.inflight ?? null;
140
+ }
141
+
142
+ /**
143
+ * 写入 inflight 标记(worker 进 update 前调用;整体覆盖并附加 ts)。
144
+ * worker 若没活到终态记账(典型:被自己触发的网关重启杀死),scheduler
145
+ * 下轮据此对账补记终态。
146
+ * @param {{ from: string, to: string, verifyTarget: string, pluginDir: string, phase: string }} info
147
+ */
148
+ export async function writeInflight(info) {
149
+ await stateMutex.withLock(async () => {
150
+ const state = await readStateRaw();
151
+ state.inflight = { ...info, ts: new Date().toISOString() };
152
+ await writeStateRaw(state);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * 合并更新 inflight 字段(phase 推进 / verifyTarget 修正)。
158
+ * inflight 不存在时 no-op——终态已清账后,迟到的更新不应复活账目。
159
+ * @param {object} patch
160
+ */
161
+ export async function updateInflight(patch) {
162
+ await stateMutex.withLock(async () => {
163
+ const state = await readStateRaw();
164
+ if (!state.inflight) return;
165
+ state.inflight = { ...state.inflight, ...patch };
166
+ await writeStateRaw(state);
167
+ });
168
+ }
169
+
170
+ /** lastUpgrade.error 截断:保尾部(子命令真因通常在输出尾部) */
171
+ function truncateErrorTail(text) {
172
+ const s = String(text);
173
+ return s.length > ERROR_MAX_CHARS ? s.slice(-ERROR_MAX_CHARS) : s;
174
+ }
175
+
176
+ /**
177
+ * 原子记录升级终态:同一把锁内一次读改写完成
178
+ * "写 lastUpgrade + 清 inflight + 可选 addSkippedVersion"。
179
+ * 终态写失败时 inflight 保留 → scheduler 下轮对账可见,不丢账。
180
+ * upgrade-log.jsonl 追加放锁后 best-effort——终态已落盘,日志失败不回滚账目。
181
+ *
182
+ * @param {object} params
183
+ * @param {string} params.from
184
+ * @param {string} params.to
185
+ * @param {string} params.result - ok / noop-skip / rollback / rollback-failed / interrupted
186
+ * @param {string} [params.error] - lastUpgrade 内截断保存,jsonl 保留完整
187
+ * @param {string} [params.phase] - 中断时刻所处阶段(interrupted 账目用)
188
+ * @param {string} [params.skipVersion] - 需加入 skippedVersions 的版本(可缺)
189
+ */
190
+ export async function recordUpgradeTerminal({ from, to, result, error, phase, skipVersion }) {
191
+ await stateMutex.withLock(async () => {
192
+ const state = await readStateRaw();
193
+ if (skipVersion) {
194
+ appendSkippedTo(state, skipVersion);
195
+ }
196
+ const last = { from, to, result };
197
+ if (error) last.error = truncateErrorTail(error);
198
+ if (phase) last.phase = phase;
199
+ state.lastUpgrade = { ...last, ts: new Date().toISOString() };
200
+ delete state.inflight;
201
+ await writeStateRaw(state);
202
+ });
203
+ try {
204
+ const entry = { from, to, result };
205
+ if (error) entry.error = error;
206
+ if (phase) entry.phase = phase;
207
+ await appendLog(entry);
208
+ }
209
+ catch {
210
+ // best-effort:jsonl 只是诊断日志,追加失败不影响终态
211
+ }
99
212
  }
100
213
 
101
214
  /**
102
215
  * 追加升级日志
103
- * @param {{ from: string, to: string, result: string, error?: string }} entry
216
+ * @param {{ from: string, to: string, result?: string, error?: string, phase?: string, event?: string }} entry
104
217
  */
105
218
  export async function appendLog(entry) {
106
- const filePath = getLogPath();
107
- await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
108
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
109
- await fs.appendFile(filePath, `${line}\n`, 'utf8');
110
- await trimLog(filePath);
219
+ await logMutex.withLock(async () => {
220
+ const filePath = getLogPath();
221
+ await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
222
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
223
+ await fs.appendFile(filePath, `${line}\n`, 'utf8');
224
+ await trimLog(filePath);
225
+ });
111
226
  }
112
227
 
113
228
  /**
@@ -54,6 +54,51 @@ export async function getLatestVersion(pkgName, opts) {
54
54
  });
55
55
  }
56
56
 
57
+ // inspect 走独立 CLI 子进程,对齐 npm view / worker runCmd 先例的 execFile 选项
58
+ const INSPECT_TIMEOUT_MS = 30_000;
59
+
60
+ /**
61
+ * 经官方 CLI 读取本插件的权威安装记录(`openclaw plugins inspect <id> --json`)。
62
+ *
63
+ * 账本文件直读已废弃(上游 ≥2026.6.1 迁 SQLite,旧 JSON 停更);inspect 是
64
+ * 三代稳定的官方契约,输出 JSON 顶层 `install` 字段即原始 install record。
65
+ * gateway 的 L1 来源门禁与 worker 的 L2 结局核对共用本函数。
66
+ *
67
+ * 归一化返回、永不抛:
68
+ * - `{ ok: true, install }`:inspect 成功;无 install 记录(如 load.paths 装置)时 install 为 null
69
+ * - `{ ok: false, reason }`:CLI 退出非 0 / 输出非 JSON
70
+ *
71
+ * @param {string} pluginId
72
+ * @param {object} [opts]
73
+ * @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
74
+ * @returns {Promise<{ ok: true, install: object|null } | { ok: false, reason: string }>}
75
+ */
76
+ export async function inspectPluginInstall(pluginId, opts) {
77
+ /* c8 ignore next -- ?./?? fallback */
78
+ const doExecFile = opts?.execFileFn ?? nodeExecFile;
79
+ return new Promise((resolve) => {
80
+ doExecFile('openclaw', ['plugins', 'inspect', pluginId, '--json'], {
81
+ timeout: INSPECT_TIMEOUT_MS,
82
+ shell: process.platform === 'win32',
83
+ }, (err, stdout) => {
84
+ if (err) {
85
+ resolve({ ok: false, reason: `inspect failed: ${err.message}` });
86
+ return;
87
+ }
88
+ let payload;
89
+ try {
90
+ payload = JSON.parse(String(stdout));
91
+ }
92
+ catch {
93
+ resolve({ ok: false, reason: 'inspect output is not valid JSON' });
94
+ return;
95
+ }
96
+ const install = payload?.install && typeof payload.install === 'object' ? payload.install : null;
97
+ resolve({ ok: true, install });
98
+ });
99
+ });
100
+ }
101
+
57
102
  /**
58
103
  * 简易 semver 比较:a > b 返回 true
59
104
  * @param {string} a
@@ -77,12 +122,25 @@ export function isNewerVersion(a, b) {
77
122
  return false;
78
123
  }
79
124
 
125
+ /**
126
+ * 判断版本是否含 prerelease 段。
127
+ * semver 语义:build metadata(`+` 之后)不算 prerelease——必须先剥掉再看 `-`,
128
+ * 否则 `1.0.0+exp-sha.5` 这类纯 build 后缀会被误判(裸 indexOf('-') 的坑)。
129
+ * 核心段 X.Y.Z 不含 `-`,剥掉 build 后出现 `-` 即为 prerelease。
130
+ * @param {string} version
131
+ * @returns {boolean}
132
+ */
133
+ export function isPrereleaseVersion(version) {
134
+ const core = String(version).split('+')[0];
135
+ return core.includes('-');
136
+ }
137
+
80
138
  /**
81
139
  * 检查是否有可用更新
82
140
  * @param {object} [opts]
83
141
  * @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
84
142
  * @param {string} [opts.pluginDir] - 插件目录
85
- * @returns {Promise<{ available: boolean, currentVersion: string, latestVersion?: string, pkgName: string }>}
143
+ * @returns {Promise<{ available: boolean, currentVersion: string, latestVersion?: string, pkgName: string, skipped?: boolean, prerelease?: boolean }>}
86
144
  */
87
145
  export async function checkForUpdate(opts) {
88
146
  const { name: pkgName, version: currentVersion } = await getPackageInfo(opts?.pluginDir);
@@ -94,6 +152,13 @@ export async function checkForUpdate(opts) {
94
152
  return { available: false, currentVersion, pkgName };
95
153
  }
96
154
 
155
+ // prerelease 闸:上游 plugins update 对裸 spec 一律拒装 prerelease,放行会进
156
+ // "spawn → 拒装 → 回滚 → 重启" 的每周期循环。此处一票否决:不 spawn、
157
+ // 不写 skip / lastUpgrade(latest 回到正式版本后自然恢复,无持久状态)
158
+ if (isPrereleaseVersion(latestVersion)) {
159
+ return { available: false, currentVersion, latestVersion, pkgName, prerelease: true };
160
+ }
161
+
97
162
  // 检查是否在 skippedVersions 中(曾升级失败并回滚的版本)
98
163
  const state = await readState();
99
164
  const skipped = Array.isArray(state.skippedVersions) ? state.skippedVersions : [];
@@ -1,8 +1,10 @@
1
- import { spawn as nodeSpawn } from 'node:child_process';
1
+ import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process';
2
2
  import nodePath from 'node:path';
3
3
  import { resolveStateDir } from './state.js';
4
4
 
5
5
  const WORKER_FILENAME = 'worker.js';
6
+ // 探针毫秒级完成;短超时防 systemd-run 异常挂起拖住 __check
7
+ const PROBE_TIMEOUT_MS = 3_000;
6
8
 
7
9
  /**
8
10
  * 获取 worker.js 的路径
@@ -12,6 +14,49 @@ export function getWorkerPath() {
12
14
  return nodePath.join(import.meta.dirname, WORKER_FILENAME);
13
15
  }
14
16
 
17
+ /**
18
+ * 是否值得尝试 systemd scope 脱逃。
19
+ * gateway 跑在 systemd service 形态(KillMode=control-group)时,worker 虽
20
+ * detached 仍在同一 cgroup,自己触发的 `gateway restart` 会把整组杀掉——
21
+ * 重启后的验证/回滚/记账全部不可达。仅 linux 且疑似 systemd 环境(unit 注入
22
+ * 的环境变量在场)才探针,其它形态零改动。
23
+ * @param {string} platform
24
+ * @param {NodeJS.ProcessEnv} env
25
+ * @returns {boolean}
26
+ */
27
+ export function shouldAttemptScopeEscape(platform, env) {
28
+ return platform === 'linux' && Boolean(env.OPENCLAW_SYSTEMD_UNIT || env.INVOCATION_ID);
29
+ }
30
+
31
+ /**
32
+ * 探针:试跑 `systemd-run [--user] --scope --quiet --collect -- /bin/true`,
33
+ * 先 --user 变体(user service 推荐形态),失败再试无 --user(system service)。
34
+ * @param {object} [opts]
35
+ * @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
36
+ * @returns {Promise<string[]|null>} 可用变体的附加参数(['--user'] 或 []);都不可用返回 null
37
+ */
38
+ export async function probeSystemdScopeArgs(opts) {
39
+ /* c8 ignore next -- ?./?? fallback */
40
+ const doExecFile = opts?.execFileFn ?? nodeExecFile;
41
+ const tryVariant = (variant) => new Promise((resolve) => {
42
+ try {
43
+ doExecFile(
44
+ 'systemd-run',
45
+ [...variant, '--scope', '--quiet', '--collect', '--', '/bin/true'],
46
+ { timeout: PROBE_TIMEOUT_MS },
47
+ (err) => resolve(!err),
48
+ );
49
+ }
50
+ catch {
51
+ // 注入实现同步抛错(如 systemd-run 不存在的极端实现)按失败处理
52
+ resolve(false);
53
+ }
54
+ });
55
+ if (await tryVariant(['--user'])) return ['--user'];
56
+ if (await tryVariant([])) return [];
57
+ return null;
58
+ }
59
+
15
60
  /**
16
61
  * 以 detached 进程方式启动 upgrade worker
17
62
  *
@@ -19,37 +64,78 @@ export function getWorkerPath() {
19
64
  * detached + unref 确保 gateway 进程不会等待 worker。
20
65
  * 通过 -- 命名参数传递业务数据,worker 使用 util.parseArgs 解析。
21
66
  *
67
+ * systemd 环境下探针通过则包成 `systemd-run [--user] --scope --quiet --collect
68
+ * -- <node> worker.js ...`:scope 是 service 的兄弟 cgroup,KillMode=control-group
69
+ * 清场打不到;--scope 自挪 cgroup 后 exec、不产生 wrapper 进程,child.pid 即
70
+ * worker 真 pid → 锁机制零改动。探针失败 → 现行裸 spawn(降级=现状),由调用方
71
+ * 据 escapeFailed 发去重信号。真 worker 永远只 spawn 一次,没有失败重拉。
72
+ *
22
73
  * @param {object} params
23
74
  * @param {string} params.pluginDir - 插件安装目录
24
75
  * @param {string} params.fromVersion - 当前版本
25
76
  * @param {string} params.toVersion - 目标版本
77
+ * @param {string} [params.baselineVersion] - 升级前权威安装记录的版本(L2 结局核对基线;可缺)
26
78
  * @param {string} params.pluginId - 插件 ID
27
79
  * @param {string} params.pkgName - npm 包名
28
80
  * @param {object} [params.opts]
29
81
  * @param {Function} [params.opts.spawnFn] - 可注入的 spawn(测试用)
82
+ * @param {Function} [params.opts.execFileFn] - 可注入的 execFile(探针,测试用)
83
+ * @param {string} [params.opts.platform] - 平台覆盖(测试用)
84
+ * @param {NodeJS.ProcessEnv} [params.opts.scopeEnv] - 探针启用判定的 env 覆盖(测试用)
30
85
  * @param {object} [params.logger] - 需提供 .info() 方法(如 pino/gateway logger)
31
- * @returns {{ child: object }}
86
+ * @returns {Promise<{ child: object, escapeFailed: boolean }>} escapeFailed:疑似 systemd
87
+ * 环境但两个探针变体都失败(降级裸 spawn),调用方据此发信号
32
88
  */
33
- export function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, pluginId, pkgName, opts, logger }) {
89
+ export async function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, baselineVersion, pluginId, pkgName, opts, logger }) {
34
90
  /* c8 ignore next -- ?./?? fallback */
35
91
  const doSpawn = opts?.spawnFn ?? nodeSpawn;
92
+ /* c8 ignore next -- ?./?? fallback */
93
+ const platform = opts?.platform ?? process.platform;
94
+ /* c8 ignore next -- ?./?? fallback */
95
+ const scopeEnv = opts?.scopeEnv ?? process.env;
36
96
  const workerPath = getWorkerPath();
37
97
 
38
98
  logger?.info?.(`[spawner] Spawning upgrade worker: ${fromVersion} → ${toVersion}`);
39
99
 
100
+ let scopeArgs = null;
101
+ let escapeFailed = false;
102
+ if (shouldAttemptScopeEscape(platform, scopeEnv)) {
103
+ scopeArgs = await probeSystemdScopeArgs(opts);
104
+ if (scopeArgs) {
105
+ logger?.info?.(`[spawner] systemd scope escape enabled (${scopeArgs.includes('--user') ? 'user' : 'system'} variant)`);
106
+ }
107
+ else {
108
+ escapeFailed = true;
109
+ logger?.warn?.('[spawner] systemd scope probe failed, falling back to bare spawn (worker may die on gateway restart)');
110
+ }
111
+ }
112
+
40
113
  // 将 state dir 传递给 worker,确保 worker 写入正确的路径
41
114
  const stateDir = resolveStateDir();
42
115
  const env = { ...process.env };
43
116
  if (stateDir) env.OPENCLAW_STATE_DIR = stateDir;
44
117
 
45
- const child = doSpawn(process.execPath, [
118
+ const args = [
46
119
  workerPath,
47
120
  '--pluginDir', pluginDir,
48
121
  '--fromVersion', fromVersion,
49
122
  '--toVersion', toVersion,
50
123
  '--pluginId', pluginId,
51
124
  '--pkgName', pkgName,
52
- ], {
125
+ ];
126
+ // 基线缺失时不传 flag,worker 按"基线不可得"退化处理
127
+ if (baselineVersion) {
128
+ args.push('--baselineVersion', baselineVersion);
129
+ }
130
+
131
+ let spawnCmd = process.execPath;
132
+ let spawnArgs = args;
133
+ if (scopeArgs) {
134
+ spawnCmd = 'systemd-run';
135
+ spawnArgs = [...scopeArgs, '--scope', '--quiet', '--collect', '--', process.execPath, ...args];
136
+ }
137
+
138
+ const child = doSpawn(spawnCmd, spawnArgs, {
53
139
  detached: true,
54
140
  stdio: 'ignore',
55
141
  env,
@@ -63,6 +149,5 @@ export function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, pluginId
63
149
  child.unref();
64
150
 
65
151
  logger?.info?.(`[spawner] Worker spawned (pid: ${child.pid})`);
66
- return { child };
152
+ return { child, escapeFailed };
67
153
  }
68
-