cofluxd 0.11.0 → 0.13.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 用的命令
@@ -38,6 +43,8 @@ cofluxd ports # 端口 + 可直接
38
43
 
39
44
  不需要任何凭证:daemon 用调用方 pid 反查进程树确认它属于哪个会话,**coflux 会话之外的进程一律拒绝**,权限也天然限定在该会话所属的工作区内。
40
45
 
46
+ 每个 coflux 开出来的 PTY 会话里还注入了一组 `COFLUX_*` 环境变量(由 supervisor 组装,中心只下发 id):`COFLUX_DEVICE_ID` / `COFLUX_PROJECT_ID`(目录工作区为空串)/ `COFLUX_WORKSPACE_ID` / `COFLUX_TASK_ID` / `COFLUX_SESSION_ID` / `COFLUX_MCP_URL`。agent 读它们就知道自己在哪台设备、哪个项目/工作区/终端,值与中心 MCP `list_*` 返回的 id 完全一致,可直接传给 MCP tools。本地命令与 MCP 的分工:本工作区内的开终端/读/等/输入/播报/叫人/端口用上面的零凭证命令;开子工作区、跨工作区/跨设备读写、或从 coflux 之外接入,用中心的 `coflux` MCP(`claude mcp add --transport http coflux "$COFLUX_MCP_URL"`,一次 OAuth 授权)。supervisor 不走热升级,旧机器要 `cofluxd update && cofluxd restart` 之后会话里才有这些变量;skill 里写了变量为空时的降级分支。
47
+
41
48
  配套的 skill 在 `skills/coflux/SKILL.md`(随包分发),装给 Claude Code:
42
49
 
43
50
  ```sh
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;
132
199
  }
133
200
 
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 });
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);
138
214
  }
215
+
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
+ }
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));
308
+ }
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 });
180
370
  }
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("✓");
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 直接读)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "0.11.0",
3
+ "version": "0.13.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
+ }
@@ -1,17 +1,49 @@
1
1
  ---
2
2
  name: coflux
3
- description: 当你运行在 coflux 终端里时,把长任务、并行工作和求助外化成用户在 coflux web/手机上看得见、能随时接管的真实终端。适用于跑测试/构建/dev server 等耗时命令、需要用户接管或决策、想给用户一个可点开的预览 URL 的场景。
3
+ description: 当你运行在 coflux 终端里时,把长任务、并行工作和求助外化成用户在 coflux web/手机上看得见、能随时接管的真实终端;读 COFLUX_* 环境变量知道自己在哪台设备/项目/工作区/终端,本工作区内用本地 cofluxd 命令,开子工作区或跨工作区/跨设备操作用中心的 coflux MCP。适用于跑测试/构建/dev server 等耗时命令、需要用户接管或决策、想给用户一个可点开的预览 URL、要在隔离的子工作区并行干活的场景。
4
4
  ---
5
5
 
6
6
  # 在 coflux 里工作
7
7
 
8
8
  你可能正跑在 coflux 的一个终端里。coflux 让用户在浏览器和手机上盯着各台机器上的
9
- agent 干活,随时接管。这套命令让你把自己的工作**变成用户看得见的东西**。
9
+ agent 干活,随时接管。这个 skill 让你把自己的工作**变成用户看得见的东西**,并在需要时
10
+ 操作账号下的其它工作区和设备。
10
11
 
11
- 先判断自己在不在 coflux 里:任意一条命令返回「不在 coflux 终端里」就说明不在,
12
- 这时忘掉这个 skill,照常用你自己的工具。
12
+ 有两条轨道,分工固定:
13
13
 
14
- ## 什么时候用
14
+ | 轨道 | 凭证 | 能触达的范围 | 用在 |
15
+ |---|---|---|---|
16
+ | 本地命令 `cofluxd terminal/progress/notify/ports` | 零凭证(daemon 按你的进程树认身份) | 只有**你所在的工作区** | 本工作区内开终端、读、等、输入、播报、叫人、拿预览 URL——最省事的路径 |
17
+ | 中心 MCP `coflux`(14 个 tools) | 用户在宿主里做一次 OAuth 授权 | **整个账号**:所有设备、项目、工作区、终端 | 开子工作区(git worktree)、跨工作区/跨设备读写、从 coflux 之外接入 |
18
+
19
+ ## 先判断自己在哪
20
+
21
+ 先看环境变量:
22
+
23
+ ```sh
24
+ env | grep '^COFLUX_'
25
+ ```
26
+
27
+ - **`COFLUX_WORKSPACE_ID` 非空** → 你在 coflux 终端里,且 daemon 已是新版。下面的变量就是
28
+ 你的坐标,MCP tools 要的 id 直接从这里拿,不用去 `list_*` 里猜:
29
+
30
+ | 变量 | 含义 |
31
+ |---|---|
32
+ | `COFLUX_DEVICE_ID` | 你所在机器的设备 id(`list_devices` 的 id) |
33
+ | `COFLUX_PROJECT_ID` | 所属项目 id;无仓库的目录工作区为空串 |
34
+ | `COFLUX_WORKSPACE_ID` | 所属工作区 id(`list_workspaces` 的 id) |
35
+ | `COFLUX_TASK_ID` | 你这个终端的 id(`list_terminals` / `read_terminal` 用的 terminalId) |
36
+ | `COFLUX_SESSION_ID` | 你这个 PTY 会话 id |
37
+ | `COFLUX_MCP_URL` | 中心 MCP 地址,用户配 MCP 时就用它 |
38
+
39
+ - **变量为空或不存在** → 跑一条 `cofluxd terminal list`:
40
+ - 返回「不在 coflux 终端里」→ 你不在 coflux 里,忘掉这个 skill,照常用你自己的工具
41
+ (除非用户已经在宿主里配了 coflux MCP,那时 MCP tools 照用,只是没有「我在哪」的坐标)。
42
+ - 能列出终端 → 你在 coflux 里,但 daemon 还没升级到注入环境变量的版本:本地命令全部可用,
43
+ 只是你不知道自己的 id;要用 MCP 就先 `list_devices` / `list_workspaces` 按路径对一下
44
+ `pwd`,并顺手告诉用户 `cofluxd update && cofluxd restart` 之后就有环境变量了。
45
+
46
+ ## 什么时候用本地命令
15
47
 
16
48
  **用 `cofluxd terminal new` 而不是自己后台起进程**——只要这条命令满足任一条:
17
49
 
@@ -26,7 +58,7 @@ agent 干活,随时接管。这套命令让你把自己的工作**变成用户
26
58
  **不要用**在一次性的快命令上(`ls`、`grep`、`git status`、读文件)——你自己的工具更快,
27
59
  给用户开一堆一秒就结束的终端只是噪音。
28
60
 
29
- ## 命令
61
+ ## 本地命令
30
62
 
31
63
  ### 开终端跑命令
32
64
 
@@ -44,6 +76,8 @@ cofluxd terminal new --title "跑单测" --cmd "pnpm -C tests test"
44
76
  关掉颜色和进度条。极少数程序在非 tty 下行为不同(比如不输出进度、切成 CI 模式),如果你
45
77
  依赖那种行为,自己在 Bash 里跑。
46
78
 
79
+ 新开的终端里同样有 `COFLUX_*` 变量(指向它自己的 task/session id,工作区与你相同)。
80
+
47
81
  ### 看跑到哪了
48
82
 
49
83
  ```sh
@@ -117,12 +151,72 @@ cofluxd ports
117
151
  列出本工作区所有监听端口和对应的公网预览 URL。起了 dev server 之后用它拿 URL 直接
118
152
  告诉用户,他点开就能看,不用自己去翻。
119
153
 
154
+ ## 中心 MCP:跨出本工作区
155
+
156
+ 本地命令只看得见你所在的工作区。要做下面这些事,用宿主里名为 `coflux` 的 MCP server:
157
+
158
+ - **开一个隔离的子工作区并行干活**:`create_workspace`(项目 id 用 `$COFLUX_PROJECT_ID`)
159
+ 在设备上真的 `git worktree add`,然后 `create_terminal` 在那里跑命令。
160
+ - **看/操作别的工作区、别的设备上的终端**:`list_*` → `read_terminal` / `send_terminal_input`。
161
+ - **不在 coflux 终端里**(比如用户在自己电脑上开的 Claude Code)时接入账号下的一切。
162
+
163
+ ### 没配 MCP 时
164
+
165
+ 先 `claude mcp list`(Codex:`codex mcp list`)看有没有 `coflux`。没有就告诉用户一行接入,
166
+ 地址用 `$COFLUX_MCP_URL`(它就是中心公网地址 + `/mcp`):
167
+
168
+ ```sh
169
+ claude mcp add --transport http coflux "$COFLUX_MCP_URL" # Claude Code
170
+ codex mcp add coflux --url "$COFLUX_MCP_URL" # Codex
171
+ ```
172
+
173
+ 之后宿主会引导用户在浏览器完成一次 OAuth 授权(Claude Code 里是 `/mcp`)。授权是用户的事,
174
+ 你只需要把地址和命令给他;没配好之前本工作区内的活照样用本地命令干。
175
+
176
+ ### 14 个 tools
177
+
178
+ id 优先从 `COFLUX_*` 环境变量拿;跨出本工作区的 id 用 `list_*` 查。
179
+
180
+ | tool | 用途 |
181
+ |---|---|
182
+ | `list_devices` | 账号下的设备(跑着 daemon 的机器):id、名称、在线、版本 |
183
+ | `list_projects` | 导入的项目(git 仓库):id、所在设备、仓库路径、默认分支 |
184
+ | `list_workspaces` | 工作区(主工作区 = 仓库本身,其余是 worktree;目录工作区 projectId 为 null),可按 projectId 筛 |
185
+ | `list_terminals` | 终端:id、工作区、标题、状态 idle/running/exited、退出码,可按 workspaceId 筛 |
186
+ | `read_terminal` | 读终端纯文本(去 ANSI,默认尾 200 行):source=log 是命令终端的完整日志(退出后仍可读),snapshot 是当前画面,checkpoint 是设备离线时中心的最近快照 |
187
+ | `list_ports` | 终端里检测到的监听端口 + 可直接打开的预览 URL |
188
+ | `create_workspace` | 在项目下新建 git worktree 工作区(可新建分支),设备真在磁盘上建目录 |
189
+ | `rename_workspace` | 改工作区名(纯展示) |
190
+ | `remove_workspace` | 删 worktree 工作区:先关其下所有终端再 `git worktree remove --force`(未提交改动会丢);主工作区不可删 |
191
+ | `create_terminal` | 在某工作区开真实终端跑一条命令(用户可接管、侧栏可见),跑完带退出码;输出落日志供 `read_terminal` |
192
+ | `send_terminal_input` | 往运行中的终端写文本(默认追加回车) |
193
+ | `wait_terminal` | 有界等待终端退出并拿退出码(默认 30 秒、上限 50 秒) |
194
+ | `stop_terminal` | 结束终端会话(等价 web 上的停止) |
195
+ | `remove_terminal` | 删终端记录;运行中的必须先 `stop_terminal` |
196
+
197
+ ### 用 MCP 的三条纪律
198
+
199
+ 1. **人类优先**:`send_terminal_input` 在用户正在接管那个终端时会被拒,错误里写明
200
+ 「用户正在接管」——把交互留给用户,不要重试;要沟通用本工作区的 `cofluxd notify`。
201
+ `send` 之前先 `read_terminal` 看清它在等什么;回执超时先 `read` 再决定要不要重发。
202
+ 2. **有界等待**:`wait_terminal` 最多等 50 秒,到期返回 `timedOut=true` 不是错误——需要更久
203
+ 就再调一次,**别自己写轮询循环去 `read_terminal`**。`create_workspace` / `create_terminal`
204
+ 最多等 30 秒启动回执,到期返回「已提交」并附 id,稍后用 `list_*` 查。
205
+ 3. **「需要升级」就停**:写 tools 在目标设备的 daemon 太旧时立即返回「该设备的 daemon 需要
206
+ 升级」——这是能力门禁,不是暂时故障。转告用户在那台机器上 `cofluxd update && cofluxd restart`,
207
+ 别重试也别换 tool 绕。
208
+
209
+ 本地命令的纪律(先 read 再 send、被拒即停、`progress` 与 `notify` 分界、用 `wait` 别轮询)
210
+ 在 MCP 里同样成立。
211
+
120
212
  ## 边界
121
213
 
122
214
  - 你能开、读、等、输入,但**输入是人类优先的受限写权**:用户正在接管的终端你写不进去
123
215
  (会被明确拒绝),用户随时接管也会把你顶掉。别和人抢终端。
124
- - 能看到的只有**你自己所在的工作区**,别的工作区和别的机器都看不见也碰不到。
216
+ - 本地命令只看得见**你自己所在的工作区**;别的工作区和别的机器要经 MCP,且限于同一账号。
125
217
  - 一个工作区同时活着的终端有上限(默认 8,含用户自己开的)。撞上限先 `list` 看看,
126
218
  多半是有跑完没收的;真是用户占满了,就 `notify` 告诉他,别硬试。
127
- - 这些命令都要 daemon 连着中心才能用——毕竟「让用户看见」就是它们的全部意义。
219
+ - 这些命令与 tools 都要 daemon 连着中心才能用——毕竟「让用户看见」就是它们的全部意义。
128
220
  连不上时会明确报错,不会默默降级。
221
+ - `COFLUX_*` 变量只在 coflux 开出来的 PTY 里有;你自己 `export` 或改它们没有任何效果,
222
+ 中心只认它自己下发的 id。