cofluxd 0.10.0 → 0.12.0

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
@@ -22,6 +22,11 @@ cofluxd down # 停止
22
22
  cofluxd uninstall [--purge] # 卸载(--purge 连二进制/配置/凭证一并删)
23
23
  ```
24
24
 
25
+ 从 `cofluxd@0.12.0` 起,远端安装/更新会用 npm 包内置的 ed25519 公钥同时验证
26
+ supervisor 与 worker 的 version/target/size/sha256/release statement;两个文件全部通过后才替换。
27
+ CLI 还会取自身与 worker 的持久 release floor 较大值拒绝远端降级。`--bin-dir` 仍是本机管理员
28
+ 显式信任本地产物的开发/救援入口。
29
+
25
30
  默认连公共服务 `wss://api.coflux.dev/daemon`(自托管用 `--server` 改;已保存的地址继续生效,非默认时会有醒目提示)。
26
31
 
27
32
  ## 给 agent 用的命令
package/cofluxd.mjs CHANGED
@@ -12,11 +12,25 @@ import dns from "node:dns/promises";
12
12
  import net from "node:net";
13
13
  import tls from "node:tls";
14
14
  import crypto from "node:crypto";
15
+ import {
16
+ MAX_RELEASE_ARTIFACT_BYTES,
17
+ assertReleaseVersion,
18
+ compareReleaseVersions,
19
+ createReleasePublicKey,
20
+ installStagedPair,
21
+ parseReleaseManifestEntry,
22
+ verifyReleaseArtifact,
23
+ } from "./release-trust.mjs";
15
24
 
16
25
  // 默认中心服务(公共 SaaS);自托管用 --server 覆盖。
17
26
  const DEFAULT_SERVER = "wss://api.coflux.dev/daemon";
18
27
 
19
28
  const REPO = "myWsq/coflux";
29
+ const RELEASE_API_BASE = (process.env.COFLUX_RELEASE_API_BASE || "https://api.github.com").replace(/\/+$/, "");
30
+ const RELEASE_DOWNLOAD_BASE = (
31
+ process.env.COFLUX_RELEASE_DOWNLOAD_BASE || `https://github.com/${REPO}/releases/download`
32
+ ).replace(/\/+$/, "");
33
+ const MAX_RELEASE_METADATA_BYTES = 1024 * 1024;
20
34
  const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
21
35
  const BIN_DIR = join(HOME, "bin");
22
36
  const SETTINGS = join(HOME, "settings.json"); // 用户配置(serverUrl/deviceName/shell)→ daemon 直接读;600 权限防同机其他用户窥探
@@ -28,6 +42,8 @@ const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // gateway key/ori
28
42
  const FDA_STATUS = join(HOME, "fda-status"); // supervisor 启动时探测落盘(仅 macOS,见 crates/supervisor/src/fda.rs)
29
43
  const SUP_BIN = join(BIN_DIR, "coflux-supervisor");
30
44
  const WRK_BIN = join(BIN_DIR, "coflux-worker");
45
+ const CLI_RELEASE_FLOOR = join(HOME, "cofluxd.release-floor");
46
+ const WORKER_RELEASE_FLOOR = join(HOME, "worker.release-floor");
31
47
  const IS_MAC = platform() === "darwin";
32
48
  const IS_LINUX = platform() === "linux";
33
49
  const PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.daemon.plist");
@@ -121,30 +137,113 @@ function fileSha256(path) {
121
137
 
122
138
  // macOS:新落盘的二进制带 com.apple.provenance,launchd 顶层 spawn 会被 AMFI 以
123
139
  // OS_REASON_CODESIGNING 静默杀死——即使产物是 Developer ID 签名 + 已公证(2026-07-25
124
- // v0.13.0 实测仍被连杀)。本地 ad-hoc 重签使其成为"本机产物"绕开该检查;签名威胁模型
125
- // 不受影响(防的是中心被攻破推恶意产物,靠下载后的 sha256+ed25519 验签,与此无关)。
140
+ // v0.13.0 实测仍被连杀)。本地 ad-hoc 重签使其成为"本机产物"绕开该检查。
141
+ // 远端产物必须先完成 release statement 验签,再允许走到这里;绝不能替任意下载内容重签。
126
142
  function resignMacBinaries(paths) {
127
143
  if (!IS_MAC) return;
128
144
  for (const p of paths) {
129
145
  const r = run("codesign", ["--force", "-s", "-", p]);
130
- if (r.status !== 0) console.warn(`⚠ 本地重签失败: ${p}(daemon 若被系统杀,手动 codesign --force -s - 该文件后 cofluxd restart)`);
146
+ if (r.status !== 0) {
147
+ throw new Error(`macOS 本地重签失败: ${p};保留当前 daemon 二进制`);
148
+ }
149
+ }
150
+ }
151
+
152
+ async function fetchBounded(url, maxBytes, label) {
153
+ const res = await fetch(url, {
154
+ redirect: "follow",
155
+ signal: AbortSignal.timeout(30_000),
156
+ });
157
+ if (!res.ok) throw new Error(`${label} 下载失败(HTTP ${res.status})`);
158
+ const declaredLength = Number(res.headers.get("content-length"));
159
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
160
+ throw new Error(`${label} 超过大小上限`);
161
+ }
162
+ if (!res.body) throw new Error(`${label} 响应没有 body`);
163
+ const chunks = [];
164
+ let total = 0;
165
+ for await (const chunk of res.body) {
166
+ const bytes = Buffer.from(chunk);
167
+ total += bytes.byteLength;
168
+ if (total > maxBytes) {
169
+ await res.body.cancel().catch(() => {});
170
+ throw new Error(`${label} 超过大小上限`);
171
+ }
172
+ chunks.push(bytes);
173
+ }
174
+ return Buffer.concat(chunks, total);
175
+ }
176
+
177
+ function loadReleasePublicKey() {
178
+ const publicKeyHex = process.env.COFLUX_WORKER_PUBKEY ||
179
+ fs.readFileSync(new URL("./release-pubkey.hex", import.meta.url), "utf8");
180
+ return createReleasePublicKey(publicKeyHex);
181
+ }
182
+
183
+ function readReleaseFloor(path, label) {
184
+ let metadata;
185
+ try { metadata = fs.lstatSync(path); }
186
+ catch (error) {
187
+ if (error?.code === "ENOENT") return undefined;
188
+ throw new Error(`${label} 元数据无法读取,拒绝远端升级`);
189
+ }
190
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 0 || metadata.size > 128) {
191
+ throw new Error(`${label} 不是安全且有界的普通文件,拒绝远端升级`);
131
192
  }
193
+ let value;
194
+ try { value = fs.readFileSync(path, "utf8").trim(); }
195
+ catch { throw new Error(`${label} 无法读取,拒绝远端升级`); }
196
+ try { assertReleaseVersion(value); }
197
+ catch { throw new Error(`${label} 已损坏,拒绝远端升级`); }
198
+ return value;
199
+ }
200
+
201
+ function currentReleaseFloor() {
202
+ const floors = [
203
+ readReleaseFloor(CLI_RELEASE_FLOOR, "cofluxd.release-floor"),
204
+ readReleaseFloor(WORKER_RELEASE_FLOOR, "worker.release-floor"),
205
+ ].filter(Boolean);
206
+ return floors.reduce((highest, candidate) => {
207
+ if (!highest) return candidate;
208
+ const order = compareReleaseVersions(candidate, highest);
209
+ if (order === 0 && candidate !== highest) {
210
+ throw new Error(`本机 release floor precedence 相同但身份冲突:${highest} / ${candidate}`);
211
+ }
212
+ return order > 0 ? candidate : highest;
213
+ }, undefined);
132
214
  }
133
215
 
134
- async function download(url, dest) {
135
- const res = await fetch(url, { redirect: "follow" });
136
- if (!res.ok) die(`下载失败 HTTP ${res.status}: ${url}\n(该版本/平台的 release 资产是否已发布?)`);
137
- fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()), { mode: 0o755 });
216
+ function persistCliReleaseFloor(version) {
217
+ const temp = join(HOME, `.cofluxd.release-floor.${process.pid}.${crypto.randomBytes(8).toString("hex")}`);
218
+ let file;
219
+ try {
220
+ file = fs.openSync(temp, "wx", 0o600);
221
+ fs.writeFileSync(file, `${version}\n`);
222
+ fs.fsyncSync(file);
223
+ fs.closeSync(file);
224
+ file = undefined;
225
+ fs.renameSync(temp, CLI_RELEASE_FLOOR);
226
+ const homeDir = fs.openSync(HOME, "r");
227
+ try { fs.fsyncSync(homeDir); }
228
+ finally { fs.closeSync(homeDir); }
229
+ } finally {
230
+ if (file !== undefined) {
231
+ try { fs.closeSync(file); } catch {}
232
+ }
233
+ try { fs.rmSync(temp, { force: true }); } catch {}
234
+ }
138
235
  }
236
+
139
237
  // 取最新 release tag(含 prerelease;GitHub 的 /releases/latest 跳转不含 prerelease,故走 API)。
140
238
  async function resolveLatestTag() {
141
239
  try {
142
- const r = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=1`, {
143
- headers: { "user-agent": "cofluxd", accept: "application/vnd.github+json" },
144
- });
145
- if (!r.ok) return null;
146
- const arr = await r.json();
147
- return Array.isArray(arr) && arr[0]?.tag_name ? arr[0].tag_name : null;
240
+ const body = await fetchBounded(
241
+ `${RELEASE_API_BASE}/repos/${REPO}/releases?per_page=1`,
242
+ MAX_RELEASE_METADATA_BYTES,
243
+ "GitHub release 元数据",
244
+ );
245
+ const arr = JSON.parse(body.toString("utf8"));
246
+ return Array.isArray(arr) && typeof arr[0]?.tag_name === "string" ? arr[0].tag_name : null;
148
247
  } catch {
149
248
  return null;
150
249
  }
@@ -155,13 +254,36 @@ async function resolveLatestTag() {
155
254
  async function ensureBinaries({ version, binDir, skipIfPresent }) {
156
255
  fs.mkdirSync(BIN_DIR, { recursive: true });
157
256
  if (binDir) {
158
- for (const b of ["coflux-supervisor", "coflux-worker"]) {
159
- const src = join(binDir, b);
160
- if (!fs.existsSync(src)) die(`本地产物缺失: ${src}(先 cargo build --release?)`);
161
- fs.copyFileSync(src, join(BIN_DIR, b));
162
- fs.chmodSync(join(BIN_DIR, b), 0o755);
257
+ const localArtifacts = ["coflux-supervisor", "coflux-worker"].map((name) => ({
258
+ name,
259
+ path: join(binDir, name),
260
+ }));
261
+ for (const artifact of localArtifacts) {
262
+ if (!fs.existsSync(artifact.path)) {
263
+ die(`本地产物缺失: ${artifact.path}(先 cargo build --release?)`);
264
+ }
265
+ }
266
+ const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-local-install-"));
267
+ let localFailure;
268
+ try {
269
+ const staged = [];
270
+ for (const artifact of localArtifacts) {
271
+ const destination = join(BIN_DIR, artifact.name);
272
+ const source = join(stageDir, artifact.name);
273
+ fs.copyFileSync(artifact.path, source);
274
+ fs.chmodSync(source, 0o755);
275
+ staged.push({ source, destination });
276
+ }
277
+ resignMacBinaries(staged.map(({ source }) => source));
278
+ installStagedPair(staged);
279
+ } catch (error) {
280
+ localFailure = error;
281
+ } finally {
282
+ fs.rmSync(stageDir, { recursive: true, force: true });
283
+ }
284
+ if (localFailure) {
285
+ die(`本地 daemon 安装失败:${localFailure instanceof Error ? localFailure.message : String(localFailure)}`);
163
286
  }
164
- resignMacBinaries([SUP_BIN, WRK_BIN]);
165
287
  console.log(`✓ 用本地二进制(${binDir})`);
166
288
  return;
167
289
  }
@@ -169,21 +291,86 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
169
291
  console.log(`✓ 二进制已存在(${BIN_DIR}),跳过下载(用 cofluxd update 升级)`);
170
292
  return;
171
293
  }
172
- const t = rustTarget();
173
- let base;
294
+ const target = rustTarget();
295
+ let releaseVersion;
174
296
  if (!version || version === "latest") {
175
297
  const tag = await resolveLatestTag();
176
- if (tag) console.log(`最新版本: ${tag}`);
177
- base = tag ? `https://github.com/${REPO}/releases/download/${tag}` : `https://github.com/${REPO}/releases/latest/download`;
298
+ if (!tag) die("无法取得最新 release 的精确版本,拒绝无版本身份的下载");
299
+ releaseVersion = tag;
300
+ console.log(`最新版本: ${releaseVersion}`);
178
301
  } else {
179
- base = `https://github.com/${REPO}/releases/download/${version}`;
302
+ releaseVersion = version;
303
+ }
304
+ try {
305
+ assertReleaseVersion(releaseVersion);
306
+ } catch (error) {
307
+ die(error instanceof Error ? error.message : String(error));
180
308
  }
181
- for (const b of ["coflux-supervisor", "coflux-worker"]) {
182
- process.stdout.write(`下载 ${b}-${t} … `);
183
- await download(`${base}/${b}-${t}`, join(BIN_DIR, b));
184
- console.log("✓");
309
+ let releaseFloor;
310
+ try {
311
+ releaseFloor = currentReleaseFloor();
312
+ const floorOrder = releaseFloor
313
+ ? compareReleaseVersions(releaseVersion, releaseFloor)
314
+ : 1;
315
+ if (floorOrder < 0 || (floorOrder === 0 && releaseVersion !== releaseFloor)) {
316
+ throw new Error(`远端 release ${releaseVersion} 不高于本机可信身份 ${releaseFloor},拒绝降级/重放`);
317
+ }
318
+ } catch (error) {
319
+ die(error instanceof Error ? error.message : String(error));
320
+ }
321
+
322
+ const base = `${RELEASE_DOWNLOAD_BASE}/${releaseVersion}`;
323
+ const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-release-install-"));
324
+ let failure;
325
+ try {
326
+ const manifestBytes = await fetchBounded(
327
+ `${base}/manifest.json`,
328
+ MAX_RELEASE_METADATA_BYTES,
329
+ "release manifest",
330
+ );
331
+ let manifest;
332
+ try {
333
+ manifest = JSON.parse(manifestBytes.toString("utf8"));
334
+ } catch {
335
+ throw new Error("release manifest 不是有效 JSON");
336
+ }
337
+ const publicKey = loadReleasePublicKey();
338
+ const staged = [];
339
+ for (const component of ["supervisor", "worker"]) {
340
+ const entry = parseReleaseManifestEntry(manifest, component, releaseVersion, target);
341
+ const artifactName = `coflux-${component}-${target}`;
342
+ process.stdout.write(`下载并验签 ${artifactName} … `);
343
+ const data = await fetchBounded(
344
+ `${base}/${artifactName}`,
345
+ Math.min(entry.size, MAX_RELEASE_ARTIFACT_BYTES),
346
+ artifactName,
347
+ );
348
+ verifyReleaseArtifact({ component, version: releaseVersion, entry, data, publicKey });
349
+ const source = join(stageDir, `coflux-${component}`);
350
+ fs.writeFileSync(source, data, { mode: 0o755 });
351
+ fs.chmodSync(source, 0o755);
352
+ staged.push({
353
+ source,
354
+ destination: component === "supervisor" ? SUP_BIN : WRK_BIN,
355
+ });
356
+ console.log("✓");
357
+ }
358
+ // 两个远端产物均通过同一发布根的验签后,才允许做本地平台变换与替换。
359
+ resignMacBinaries(staged.map(({ source }) => source));
360
+ // floor 先于 pair 提交:若其后进程崩溃,最多是旧二进制配更高 floor;重跑同版仍允许,
361
+ // 但任何旧的合法 release 都不能趁窗口降级。
362
+ if (!releaseFloor || compareReleaseVersions(releaseVersion, releaseFloor) > 0) {
363
+ persistCliReleaseFloor(releaseVersion);
364
+ }
365
+ installStagedPair(staged);
366
+ } catch (error) {
367
+ failure = error;
368
+ } finally {
369
+ fs.rmSync(stageDir, { recursive: true, force: true });
370
+ }
371
+ if (failure) {
372
+ die(`release 安装失败:${failure instanceof Error ? failure.message : String(failure)}`);
185
373
  }
186
- resignMacBinaries([SUP_BIN, WRK_BIN]);
187
374
  }
188
375
 
189
376
  // 写 settings.json(daemon 直接读)。
@@ -761,6 +948,11 @@ async function cmdHook() {
761
948
 
762
949
  const AGENT_TIMEOUT_MS = 30_000;
763
950
  const DEFAULT_READ_LINES = 200;
951
+ // wait 的循环必须在 CLI 侧:单次 agentPost 撑不起长等待(daemon 控制 WS 有自己的往返超时)。
952
+ // 默认 30 分钟——编码任务常跑很久;轮询走 terminal.list(status 来自 sessionExit 事件链,
953
+ // 不受快照 ~2s 延迟影响),3 秒一次对本机 loopback 是零负担。
954
+ const DEFAULT_WAIT_TIMEOUT_S = 1800;
955
+ const WAIT_POLL_MS = 3000;
764
956
 
765
957
  async function agentPost(body) {
766
958
  const portResult = localGatewayPort();
@@ -824,8 +1016,34 @@ async function cmdTerminal(values) {
824
1016
  console.log(`# ${result.status}${exit}`);
825
1017
  const text = tailLines(stripAnsi(result.ansi), lines);
826
1018
  console.log(text || "(暂无输出)");
1019
+ } else if (sub === "send") {
1020
+ const taskId = positionals[2];
1021
+ if (!taskId) die("terminal send 需要 <taskId>(用 cofluxd terminal list 查)");
1022
+ const text = values.text ?? "";
1023
+ if (!text && !values.enter) die(`terminal send 需要 --text "<文本>"(或至少 --enter 发一个回车)`);
1024
+ await agentPost({ action: "terminal.send", taskId, text, enter: Boolean(values.enter) });
1025
+ console.log(`已写入终端 ${taskId}(用 cofluxd terminal read ${taskId} 核对效果)`);
1026
+ } else if (sub === "wait") {
1027
+ const taskId = positionals[2];
1028
+ if (!taskId) die("terminal wait 需要 <taskId>(用 cofluxd terminal list 查)");
1029
+ const requested = Number(values.timeout);
1030
+ const timeoutSec = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_TIMEOUT_S;
1031
+ const deadline = Date.now() + timeoutSec * 1000;
1032
+ for (;;) {
1033
+ const { terminals } = await agentPost({ action: "terminal.list" });
1034
+ const t = terminals.find((x) => x.taskId === taskId);
1035
+ if (!t) die(`本工作区没有终端 ${taskId}(用 cofluxd terminal list 查)`);
1036
+ if (t.status === "exited") {
1037
+ const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
1038
+ return void console.log(`# exited${exit}`);
1039
+ }
1040
+ if (Date.now() >= deadline) {
1041
+ die(`等待超时(${timeoutSec}s):终端 ${taskId} 仍是 ${t.status}。可加大 --timeout,或 cofluxd terminal read ${taskId} 看现场`);
1042
+ }
1043
+ await sleep(WAIT_POLL_MS);
1044
+ }
827
1045
  } else {
828
- die(`terminal 需要子命令:new | list | read`);
1046
+ die(`terminal 需要子命令:new | list | read | wait | send`);
829
1047
  }
830
1048
  }
831
1049
 
@@ -836,6 +1054,13 @@ async function cmdNotify() {
836
1054
  console.log("已通知用户(工作区在侧栏转为「等待交互」)");
837
1055
  }
838
1056
 
1057
+ async function cmdProgress() {
1058
+ const message = positionals.slice(1).join(" ").trim();
1059
+ if (!message) die(`progress 需要一句话,例如:cofluxd progress "复现了,正在定位 relay 重连"`);
1060
+ await agentPost({ action: "progress", message });
1061
+ console.log("已更新进度(显示在工作区卡片上,被下一条覆盖)");
1062
+ }
1063
+
839
1064
  async function cmdPorts() {
840
1065
  const { ports } = await agentPost({ action: "ports" });
841
1066
  if (!ports.length) return void console.log("本工作区暂无监听端口");
@@ -864,7 +1089,12 @@ const HELP = `cofluxd —— coflux daemon 管理
864
1089
  cofluxd terminal list 列出本工作区的终端(含 status / 退出码)
865
1090
  cofluxd terminal read <taskId> [--lines N]
866
1091
  读某个终端的内容(纯文本,默认最后 200 行;终端已退出也能读)
1092
+ cofluxd terminal wait <taskId> [--timeout <秒>]
1093
+ 阻塞等到该终端退出,打印退出码(默认超时 30 分钟)
1094
+ cofluxd terminal send <taskId> --text "<文本>" [--enter]
1095
+ 往终端里输入文本(--enter 追加回车)。用户正在接管时会被拒
867
1096
  cofluxd notify "<一句话>" 叫人:工作区在侧栏转为「等待交互」并显示这句话
1097
+ cofluxd progress "<一句话>" 播报进度:显示在工作区卡片上,被下一条覆盖(不打扰用户)
868
1098
  cofluxd ports 列出本工作区的监听端口及可直接打开的预览 URL
869
1099
 
870
1100
  up flags: --server <ws://.../daemon> --name <名> --shell <路径>
@@ -887,6 +1117,9 @@ const { values, positionals } = parseArgs({
887
1117
  title: { type: "string" },
888
1118
  cmd: { type: "string" },
889
1119
  lines: { type: "string" },
1120
+ timeout: { type: "string" },
1121
+ text: { type: "string" },
1122
+ enter: { type: "boolean", default: false },
890
1123
  version: { type: "string" },
891
1124
  "bin-dir": { type: "string" },
892
1125
  "no-start": { type: "boolean", default: false },
@@ -900,7 +1133,7 @@ let cmd = positionals[0];
900
1133
  if (values.help || cmd === "help") { console.log(HELP); process.exit(0); }
901
1134
  if (!cmd) cmd = fs.existsSync(SETTINGS) ? "status" : "up"; // 首次裸跑 → 引导
902
1135
 
903
- const handlers = { up: cmdUp, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, ports: cmdPorts };
1136
+ const handlers = { up: cmdUp, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, progress: cmdProgress, ports: cmdPorts };
904
1137
  const h = handlers[cmd];
905
1138
  if (!h) die(`未知命令: ${cmd}${MIGRATED[cmd] ? `\n${MIGRATED[cmd]}` : ""}\n\n${HELP}`);
906
1139
  await h(values);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "coflux daemon 管理 CLI:装/起/停/升级 Rust daemon(supervisor + worker)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "cofluxd.mjs",
11
+ "release-pubkey.hex",
12
+ "release-trust.mjs",
11
13
  "README.md",
12
14
  "skills"
13
15
  ],
@@ -0,0 +1 @@
1
+ d5605555e704b65933a82f57cb648ac96884c237353b0e47f5445f09e745d7a1
@@ -0,0 +1,240 @@
1
+ import crypto from "node:crypto";
2
+ import { Buffer } from "node:buffer";
3
+ import fs from "node:fs";
4
+
5
+ export const WORKER_RELEASE_STATEMENT_DOMAIN = Buffer.from(
6
+ "coflux-worker-release-v1\0",
7
+ "utf8",
8
+ );
9
+ export const SUPERVISOR_RELEASE_STATEMENT_DOMAIN = Buffer.from(
10
+ "coflux-supervisor-release-v1\0",
11
+ "utf8",
12
+ );
13
+
14
+ const STRICT_RELEASE_VERSION = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
15
+ const SHA256_HEX = /^[0-9a-f]{64}$/i;
16
+ const ED25519_SIGNATURE_HEX = /^[0-9a-f]{128}$/i;
17
+ const ED25519_PUBLIC_KEY_HEX = /^[0-9a-f]{64}$/i;
18
+ export const MAX_RELEASE_ARTIFACT_BYTES = 128 * 1024 * 1024;
19
+
20
+ function parseReleaseVersion(version) {
21
+ const match = typeof version === "string" ? STRICT_RELEASE_VERSION.exec(version) : null;
22
+ if (!match) throw new Error(`release version 不是带 v 前缀的严格 SemVer: ${JSON.stringify(version)}`);
23
+ const prerelease = match[4]
24
+ ? match[4].split(".").map((identifier) => {
25
+ if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) {
26
+ throw new Error(`release version 的数字 prerelease 标识符含前导 0: ${JSON.stringify(version)}`);
27
+ }
28
+ return /^\d+$/.test(identifier)
29
+ ? { numeric: true, value: BigInt(identifier) }
30
+ : { numeric: false, value: identifier };
31
+ })
32
+ : [];
33
+ return {
34
+ raw: version,
35
+ core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
36
+ prerelease,
37
+ };
38
+ }
39
+
40
+ /** 与 Rust semver crate 一致的 release tag 子集:必须带 v,数字标识符禁止前导 0。 */
41
+ export function assertReleaseVersion(version) {
42
+ parseReleaseVersion(version);
43
+ return version;
44
+ }
45
+
46
+ /** SemVer precedence;build metadata 不参与比较。 */
47
+ export function compareReleaseVersions(leftVersion, rightVersion) {
48
+ const left = parseReleaseVersion(leftVersion);
49
+ const right = parseReleaseVersion(rightVersion);
50
+ for (let index = 0; index < 3; index += 1) {
51
+ if (left.core[index] < right.core[index]) return -1;
52
+ if (left.core[index] > right.core[index]) return 1;
53
+ }
54
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
55
+ if (left.prerelease.length === right.prerelease.length) return 0;
56
+ return left.prerelease.length === 0 ? 1 : -1;
57
+ }
58
+ const common = Math.min(left.prerelease.length, right.prerelease.length);
59
+ for (let index = 0; index < common; index += 1) {
60
+ const a = left.prerelease[index];
61
+ const b = right.prerelease[index];
62
+ if (a.numeric && b.numeric) {
63
+ if (a.value < b.value) return -1;
64
+ if (a.value > b.value) return 1;
65
+ } else if (a.numeric !== b.numeric) {
66
+ return a.numeric ? -1 : 1;
67
+ } else {
68
+ if (a.value < b.value) return -1;
69
+ if (a.value > b.value) return 1;
70
+ }
71
+ }
72
+ return Math.sign(left.prerelease.length - right.prerelease.length);
73
+ }
74
+
75
+ function lenPrefixed(value) {
76
+ const bytes = Buffer.from(value, "utf8");
77
+ const length = Buffer.allocUnsafe(4);
78
+ length.writeUInt32BE(bytes.length);
79
+ return [length, bytes];
80
+ }
81
+
82
+ function artifactReleaseStatement(domain, { version, target, sha256, size }) {
83
+ assertReleaseVersion(version);
84
+ if (typeof target !== "string" || !target || Buffer.byteLength(target) > 128) {
85
+ throw new Error("release target 非法");
86
+ }
87
+ if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) {
88
+ throw new Error("release sha256 必须是 32 字节 hex");
89
+ }
90
+ if (!Number.isSafeInteger(size) || size <= 0 || size > MAX_RELEASE_ARTIFACT_BYTES) {
91
+ throw new Error("release size 必须是有效且有界的正整数");
92
+ }
93
+ const sizeBytes = Buffer.allocUnsafe(8);
94
+ sizeBytes.writeBigUInt64BE(BigInt(size));
95
+ return Buffer.concat([
96
+ domain,
97
+ ...lenPrefixed(version),
98
+ ...lenPrefixed(target),
99
+ Buffer.from(sha256, "hex"),
100
+ sizeBytes,
101
+ ]);
102
+ }
103
+
104
+ /** worker 热升级沿用的 v1 transcript;不得改变 domain 或字段顺序。 */
105
+ export function workerReleaseStatement(metadata) {
106
+ return artifactReleaseStatement(WORKER_RELEASE_STATEMENT_DOMAIN, metadata);
107
+ }
108
+
109
+ /** supervisor 安装专用 transcript;独立 domain 防止合法 worker 签名被横向移植。 */
110
+ export function supervisorReleaseStatement(metadata) {
111
+ return artifactReleaseStatement(SUPERVISOR_RELEASE_STATEMENT_DOMAIN, metadata);
112
+ }
113
+
114
+ export function createReleasePublicKey(publicKeyHex) {
115
+ const normalized = typeof publicKeyHex === "string" ? publicKeyHex.trim() : "";
116
+ if (!ED25519_PUBLIC_KEY_HEX.test(normalized)) {
117
+ throw new Error("发布公钥必须是 32 字节 hex");
118
+ }
119
+ return crypto.createPublicKey({
120
+ format: "jwk",
121
+ key: {
122
+ kty: "OKP",
123
+ crv: "Ed25519",
124
+ x: Buffer.from(normalized, "hex").toString("base64url"),
125
+ },
126
+ });
127
+ }
128
+
129
+ function isRecord(value) {
130
+ return !!value && typeof value === "object" && !Array.isArray(value);
131
+ }
132
+
133
+ /**
134
+ * 从 schema 2 manifest 取指定 component/target。额外顶层字段允许滚动扩展;
135
+ * 但参与信任裁决的 version/target/size/hash/signature 全部严格校验。
136
+ */
137
+ export function parseReleaseManifestEntry(manifest, component, version, target) {
138
+ assertReleaseVersion(version);
139
+ if (component !== "worker" && component !== "supervisor") {
140
+ throw new Error(`未知 release component: ${JSON.stringify(component)}`);
141
+ }
142
+ if (!isRecord(manifest) || manifest.schemaVersion !== 2 || manifest.version !== version) {
143
+ throw new Error("release manifest schema/version 与请求不一致");
144
+ }
145
+ const entries = manifest[component];
146
+ const entry = isRecord(entries) ? entries[target] : undefined;
147
+ if (!isRecord(entry) || entry.target !== target) {
148
+ throw new Error(`release manifest 缺少匹配的 ${component}/${target}`);
149
+ }
150
+ if (
151
+ typeof entry.sha256 !== "string" ||
152
+ !SHA256_HEX.test(entry.sha256) ||
153
+ !Number.isSafeInteger(entry.size) ||
154
+ entry.size <= 0 ||
155
+ entry.size > MAX_RELEASE_ARTIFACT_BYTES ||
156
+ typeof entry.releaseSignature !== "string" ||
157
+ !ED25519_SIGNATURE_HEX.test(entry.releaseSignature)
158
+ ) {
159
+ throw new Error(`release manifest 的 ${component}/${target} 元数据非法`);
160
+ }
161
+ if (
162
+ component === "worker" &&
163
+ (typeof entry.signature !== "string" || !ED25519_SIGNATURE_HEX.test(entry.signature))
164
+ ) {
165
+ throw new Error(`release manifest 的 worker/${target} 缺少 legacy 签名`);
166
+ }
167
+ return {
168
+ target,
169
+ sha256: entry.sha256.toLowerCase(),
170
+ size: entry.size,
171
+ signature: component === "worker" ? entry.signature.toLowerCase() : undefined,
172
+ releaseSignature: entry.releaseSignature.toLowerCase(),
173
+ };
174
+ }
175
+
176
+ /** 校验实际 bytes 与 manifest 元数据、raw worker 签名及 component-separated release 签名。 */
177
+ export function verifyReleaseArtifact({ component, version, entry, data, publicKey }) {
178
+ if (!Buffer.isBuffer(data)) throw new Error("release 产物必须是 Buffer");
179
+ if (data.byteLength !== entry.size) {
180
+ throw new Error(`${component} 产物大小不匹配:期望 ${entry.size},实际 ${data.byteLength}`);
181
+ }
182
+ const sha256 = crypto.createHash("sha256").update(data).digest("hex");
183
+ if (sha256 !== entry.sha256) {
184
+ throw new Error(`${component} 产物 sha256 不匹配`);
185
+ }
186
+ if (
187
+ component === "worker" &&
188
+ !crypto.verify(null, data, publicKey, Buffer.from(entry.signature, "hex"))
189
+ ) {
190
+ throw new Error("worker 产物 legacy Ed25519 签名无效");
191
+ }
192
+ const metadata = { version, target: entry.target, sha256, size: data.byteLength };
193
+ const statement = component === "worker"
194
+ ? workerReleaseStatement(metadata)
195
+ : supervisorReleaseStatement(metadata);
196
+ if (!crypto.verify(null, statement, publicKey, Buffer.from(entry.releaseSignature, "hex"))) {
197
+ throw new Error(`${component} 产物 release Ed25519 签名无效`);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 两个已验证/本地显式信任的暂存文件一起进入替换阶段;任一 rename 失败都会恢复旧 pair。
203
+ * 单文件 rename 是原子的,pair 级失败用同文件系统内备份回滚,绝不把“只更新一半”当成功。
204
+ */
205
+ export function installStagedPair(staged) {
206
+ if (
207
+ !Array.isArray(staged) ||
208
+ staged.length !== 2 ||
209
+ staged.some(({ source, destination }) =>
210
+ typeof source !== "string" || !source || typeof destination !== "string" || !destination)
211
+ ) {
212
+ throw new Error("daemon 安装必须提供两个合法的暂存文件");
213
+ }
214
+ const installed = [];
215
+ const backups = [];
216
+ try {
217
+ for (const { source, destination } of staged) {
218
+ const backup = `${source}.previous`;
219
+ if (fs.existsSync(destination)) {
220
+ fs.renameSync(destination, backup);
221
+ backups.push({ backup, destination });
222
+ }
223
+ fs.renameSync(source, destination);
224
+ installed.push(destination);
225
+ }
226
+ } catch (error) {
227
+ for (const destination of installed.reverse()) {
228
+ try { fs.rmSync(destination, { force: true }); } catch {}
229
+ }
230
+ const restoreFailures = [];
231
+ for (const { backup, destination } of backups.reverse()) {
232
+ try { fs.renameSync(backup, destination); }
233
+ catch (restoreError) { restoreFailures.push(restoreError); }
234
+ }
235
+ if (restoreFailures.length > 0) {
236
+ throw new AggregateError([error, ...restoreFailures], "daemon 二进制替换失败且旧版本恢复不完整");
237
+ }
238
+ throw error;
239
+ }
240
+ }
@@ -56,7 +56,44 @@ cofluxd terminal read <taskId> --lines 50
56
56
  **终端已经退出也能 read**——「命令跑完了看输出」正是最常用的场景。
57
57
 
58
58
  内容有最多约 2 秒的延迟(来自中心的定期快照),所以刚 `new` 完立刻 `read` 可能是空的。
59
- 要等一条命令跑完,隔几秒 `list` 一次看它转没转 `exited`,别忙等。
59
+
60
+ ### 等命令跑完
61
+
62
+ ```sh
63
+ cofluxd terminal wait <taskId> # 阻塞到该终端退出,打印退出码(默认最长等 30 分钟)
64
+ cofluxd terminal wait <taskId> --timeout 300 # 自定超时(秒);超时会明确报错并非零退出
65
+ ```
66
+
67
+ 要等一条命令跑完就用 `wait`,**别自己写轮询循环**——它一条命令阻塞到位,退出码直接给你。
68
+ 超时不代表命令失败,只是还没跑完:`read` 看看现场再决定继续等还是处理。
69
+
70
+ ### 往终端里输入
71
+
72
+ ```sh
73
+ cofluxd terminal send <taskId> --text "y" --enter # 输入一行并回车
74
+ cofluxd terminal send <taskId> --enter # 只按一个回车
75
+ ```
76
+
77
+ 用在命令要交互确认(y/N、选项)、或想在跑完的同一 shell 里补一条命令的时候。纪律:
78
+
79
+ - **先 `read` 再 `send`**:看清终端现在在等什么再输入,别盲打。
80
+ - **用户正在接管时会被拒**——这不是错误,是设计:人永远优先。被拒就停手,
81
+ 要沟通用 `notify`,别重试。
82
+ - send 超时后**不要直接重发**:先 `read` 确认刚才那次到底进没进去,重复输入比丢输入更糟。
83
+
84
+ ### 播报进度
85
+
86
+ ```sh
87
+ cofluxd progress "复现了,正在定位 relay 重连的时序"
88
+ ```
89
+
90
+ 一句话告诉用户你干到哪了,显示在工作区卡片上,被下一条覆盖。在关键节点更新:复现了、
91
+ 定位到了、修完在验、卡在哪。它**不打扰用户**,和 `notify` 是两条信道:
92
+
93
+ - `progress` = 播报(用户扫一眼就知道进展,不需要回应)
94
+ - `notify` = 叫人(工作区转「等待交互」,用户该来看看了)
95
+
96
+ 拿不准用哪个:不需要用户做任何事就用 `progress`。
60
97
 
61
98
  ### 叫人
62
99
 
@@ -82,7 +119,8 @@ cofluxd ports
82
119
 
83
120
  ## 边界
84
121
 
85
- - 你**只能开和读**,不能往别人的终端里打字。要交互就 `notify` 让用户接管。
122
+ - 你能开、读、等、输入,但**输入是人类优先的受限写权**:用户正在接管的终端你写不进去
123
+ (会被明确拒绝),用户随时接管也会把你顶掉。别和人抢终端。
86
124
  - 能看到的只有**你自己所在的工作区**,别的工作区和别的机器都看不见也碰不到。
87
125
  - 一个工作区同时活着的终端有上限(默认 8,含用户自己开的)。撞上限先 `list` 看看,
88
126
  多半是有跑完没收的;真是用户占满了,就 `notify` 告诉他,别硬试。