cicy-desktop 2.1.370 → 2.1.372

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.
@@ -39,7 +39,7 @@
39
39
  setTimeout(check, 5000);
40
40
  })();
41
41
  </script>
42
- <script type="module" crossorigin src="./assets/index-BMeWzitz.js"></script>
42
+ <script type="module" crossorigin src="./assets/index-MVf_tDfV.js"></script>
43
43
  <link rel="stylesheet" crossorigin href="./assets/index-CVk0uc2g.css">
44
44
  </head>
45
45
  <body>
@@ -506,7 +506,18 @@ async function logout() {
506
506
  return { ok: true };
507
507
  }
508
508
 
509
+ // 担保登录:本机 desktop 已登录 hub 时,用它自己的会话给同一账号下的另一个实例(本机团队容器里的
510
+ // cicy-code)签发 hub 凭据,不走邮件验证码。hub 侧 /api/enroll 只允许扩自己账号、不能碰别人的实例。
511
+ async function enroll({ instanceId, name, platform } = {}) {
512
+ const a = readAuth();
513
+ if (!a) return { ok: false, error: "not_signed_in" };
514
+ const res = await hubFetch("/api/enroll", { method: "POST", token: a.token, body: { instanceId, name, platform } });
515
+ if (!res.ok || !res.json || !res.json.token) return { ok: false, status: res.status, error: errorOf(res, "enroll_failed") };
516
+ return { ok: true, token: res.json.token, owner: res.json.owner || a.owner || "", instanceId: res.json.instanceId || instanceId, origin: hubOrigin() };
517
+ }
518
+
509
519
  module.exports = {
520
+ enroll,
510
521
  status,
511
522
  loginStart,
512
523
  loginCode,
@@ -161,6 +161,14 @@ async function ensureVm({ emit } = {}) {
161
161
  let restarted = false;
162
162
  for (let round = 0; round < 2; round++) {
163
163
  s = await vmStatus();
164
+ // 来宾内核崩了(Oops / RCU stall …):QEMU 进程还在,但里面什么都不会再响应 —— 直接重开,别等 SSH 超时。
165
+ // (2026-10-23:10 台里 5 台在 IDE 光驱 PIO 上 Oops,已用 -nodefaults 根治,这里兜底任何别的崩溃。)
166
+ if (s.running && s.crashed) {
167
+ log.warn(`[qemu-docker] guest kernel crashed (${s.crashed}) → restarting VM`);
168
+ e({ phase: "install-docker", status: "running", message: "虚拟机内核崩溃,正在重启虚拟机…" });
169
+ await ctl("stop", [], 150000);
170
+ s = await vmStatus();
171
+ }
164
172
  if (!s.running) {
165
173
  e({ phase: "install-docker", status: "running", message: "启动 Debian 虚拟机…" });
166
174
  const r = await ctl("start");
@@ -202,11 +210,15 @@ async function dockerInstalled() { try { await vmRun("command -v docker >/dev/nu
202
210
  async function dockerEngineUp() { try { await vmRun("docker info >/dev/null 2>&1", { timeout: 20000 }); return true; } catch { return false; } }
203
211
 
204
212
  // apt 装 docker.io(Debian 自带包)。deb.debian.org 在 CN 很慢 → 5 秒探不通就换 TUNA。
213
+ // apt 源:多镜像按实测速度排序、全部写进 mirror+file 列表,apt 遇到失败/超时自动换下一个。
214
+ // 为什么不是「挑一个」:国内同一台机器上,deb.debian.org 小文件 250 kB/s 但大包会卡在 0(xs-1001 2026-10-24),
215
+ // TUNA 在虚拟机里直接连不上(xs-1001/xs-3008 实测 0),阿里云/中科大/华为云各有快慢 —— 单选哪个都会在某些机器上挂。
216
+ // Debian 云镜像的 debian.sources 本就指向 /etc/apt/mirrors/*.list;若是直写 URI 的也改成指向列表。
217
+ const APT_MIRRORS_SH = "# apt \u591a\u6e90\u56de\u9000:\u6d4b\u5404\u955c\u50cf\u901f\u5ea6,\u4e22\u6389\u4e0d\u901a\u7684,\u6309\u5feb\u6162\u5168\u90e8\u5199\u8fdb mirror+file \u5217\u8868(apt \u9047\u5230\u5931\u8d25/\u8d85\u65f6\u81ea\u52a8\u6362\u4e0b\u4e00\u4e2a)\npick(){ suffix=$1; out=\"\"; for m in https://deb.debian.org https://mirrors.aliyun.com https://mirrors.ustc.edu.cn https://mirrors.huaweicloud.com https://mirrors.tuna.tsinghua.edu.cn; do\n s=$(curl -fsS -m 8 -o /dev/null -w '%{speed_download}' \"$m/$suffix/dists/trixie$2/InRelease\" 2>/dev/null | cut -d. -f1); s=${s:-0}\n [ \"$s\" -gt 0 ] && out=\"$out$s $m/$suffix\n\"; done; printf '%s' \"$out\" | sort -rn | awk '{print $2}'; }\nL=$(pick debian \"\"); [ -n \"$L\" ] || L=https://deb.debian.org/debian\nSL=$(pick debian-security \"-security\"); [ -n \"$SL\" ] || SL=https://deb.debian.org/debian-security\nmkdir -p /etc/apt/mirrors; printf '%s\\n' \"$L\" > /etc/apt/mirrors/debian.list; printf '%s\\n' \"$SL\" > /etc/apt/mirrors/debian-security.list\nsed -i 's#^URIs: https\\?://[^ ]*/debian-security$#URIs: mirror+file:///etc/apt/mirrors/debian-security.list#; s#^URIs: https\\?://[^ ]*/debian$#URIs: mirror+file:///etc/apt/mirrors/debian.list#' /etc/apt/sources.list.d/debian.sources 2>/dev/null\necho \"debian mirrors: $(echo $L)\"; echo \"security mirrors: $(echo $SL)\"";
218
+ const APT_OPTS = "-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=3";
205
219
  async function installDockerEngine({ emit } = {}) {
206
220
  emit && emit({ phase: "install-docker", status: "running", message: "在虚拟机里安装 Docker 引擎(apt install docker.io)…" });
207
- const mirror = "if ! curl -fsS -m 5 -o /dev/null https://deb.debian.org/debian/dists/trixie/Release; then " +
208
- "sed -i 's#http[s]*://deb.debian.org#https://mirrors.tuna.tsinghua.edu.cn#g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; fi";
209
- await vmRunStream(`${mirror}; export DEBIAN_FRONTEND=noninteractive; apt-get update -q && apt-get install -y -q docker.io && systemctl enable --now docker && usermod -aG docker cicy`, { emit, phase: "install-docker", timeout: 1800000 });
221
+ await vmRunStream(`${APT_MIRRORS_SH}\nexport DEBIAN_FRONTEND=noninteractive; apt-get ${APT_OPTS} update -q && apt-get ${APT_OPTS} install -y -q docker.io && systemctl enable --now docker && usermod -aG docker cicy`, { emit, phase: "install-docker", timeout: 1800000 });
210
222
  }
211
223
 
212
224
  // ---- 镜像 ----------------------------------------------------------------
@@ -350,6 +362,50 @@ async function insideHealthy(container, port) {
350
362
  catch { return false; }
351
363
  }
352
364
 
365
+ // ---- 本机团队自动登录 hub ------------------------------------------------
366
+ // 容器里的 cicy-code 只有拿到 hub 凭据(~/cicy-ai/db/cloud-device.json)才会出现在首页「CiCy Hub」里;
367
+ // 新容器(新装机 / 从 WSL 迁过来时从空开始)没有。desktop 自己登录了 hub 的话,就用它当担保
368
+ // (hub-client.enroll → /api/enroll)给容器签一个,名字用车队里的机器名(global.json desktopTeam,如 xs-1003)。
369
+ // 已经有凭据就什么都不做。失败不影响本机团队本身,10 分钟后再试。
370
+ const CRED_PATH = "/home/cicy/cicy-ai/db/cloud-device.json";
371
+ let _enrollAt = 0, _enrollBusy = false;
372
+ function fleetName() {
373
+ try {
374
+ const c = JSON.parse(fs.readFileSync(path.join(os.homedir(), "cicy-ai", "global.json"), "utf8"));
375
+ const n = String((c && c.desktopTeam) || "").replace(/^desktop-/, "").trim();
376
+ if (n) return n;
377
+ } catch {}
378
+ return (os.hostname() || "desktop").split(".")[0];
379
+ }
380
+ async function containerHasHubCred(container) {
381
+ try { await vmRun(`docker exec ${container} sh -c ${shQuote(`test -s ${CRED_PATH} && grep -q '"token"' ${CRED_PATH}`)}`, { timeout: 20000 }); return true; }
382
+ catch { return false; }
383
+ }
384
+ async function ensureHubEnrollment({ container, force = false } = {}) {
385
+ if (_enrollBusy || (!force && Date.now() - _enrollAt < 10 * 60 * 1000)) return { ok: false, skipped: "throttled" };
386
+ _enrollBusy = true; _enrollAt = Date.now();
387
+ try {
388
+ container = container || `cicy-code-docker-${process.env.CICY_DOCKER_APP_PORT || 8009}`;
389
+ if (await containerHasHubCred(container)) return { ok: true, already: true };
390
+ let hub; try { hub = require("../backends/hub-client"); } catch (e) { return { ok: false, error: "no_hub_client" }; }
391
+ const name = fleetName();
392
+ const instanceId = "code-" + require("crypto").randomBytes(16).toString("hex");
393
+ const r = await hub.enroll({ instanceId, name, platform: "linux/docker" });
394
+ if (!r.ok) { log.info(`[qemu-docker] hub enroll skipped: ${r.error}`); return r; }
395
+ const cred = { email: r.owner, instance_id: r.instanceId, team_id: name, token: r.token, cloud_origin: r.origin, mode: "hub", updated_at: new Date().toISOString() };
396
+ const { Readable } = require("stream");
397
+ // 凭据走 stdin,不进命令行;写完 sync —— 虚拟机万一紧接着崩,文件也已落盘(xs-3008 吃过空文件的亏)。
398
+ await vmRun(`docker exec -i -u cicy ${container} sh -c ${shQuote(`umask 077; cat > ${CRED_PATH}.new && mv ${CRED_PATH}.new ${CRED_PATH}`)} && sync`,
399
+ { stdin: Readable.from([Buffer.from(JSON.stringify(cred, null, 2) + "\n")]), timeout: 30000 });
400
+ try { await vmRun(`docker exec ${container} supervisorctl -c /etc/supervisor/supervisord.conf restart cicy-code`, { timeout: 60000 }); } catch {}
401
+ log.info(`[qemu-docker] 本机团队 enrolled into hub as "${name}" (owner ${r.owner})`);
402
+ return { ok: true, enrolled: true, name };
403
+ } catch (e) {
404
+ log.warn(`[qemu-docker] hub enroll failed: ${e.message}`);
405
+ return { ok: false, error: e.message };
406
+ } finally { _enrollBusy = false; }
407
+ }
408
+
353
409
  // ---- 状态 / bootstrap ----------------------------------------------------
354
410
 
355
411
  // 与 wsl status 同形:wsl → 虚拟机已装(QEMU+镜像在),distro → 同,engineUp → 虚拟机里 dockerd 在。
@@ -360,6 +416,7 @@ async function status(port) {
360
416
  if (!s.ok) return { wsl: false, distro: false, engineUp: false, running: healthy, healthy, unknown: !healthy };
361
417
  const installed = vmInstalled(s);
362
418
  const engineUp = !!(s.running && installed && (await dockerEngineUp()));
419
+ if (healthy && engineUp) ensureHubEnrollment({}).catch(() => {}); // 不等它,10 分钟节流在里面
363
420
  return { wsl: installed, distro: installed, engineUp, running: healthy, healthy, accel: s.accel || "", vmRunning: !!s.running };
364
421
  }
365
422
 
@@ -410,6 +467,7 @@ async function _bootstrap({ onProgress, port, container, volume, env = {}, extra
410
467
  const healthy = await docker.waitUntil(() => probeHealth(port), { totalMs: (v.accel === "tcg" ? 600 : 180) * 1000, everyMs: 3000 });
411
468
  if (!healthy) return fail("health_timeout", `服务起来了但 127.0.0.1:${port} 还没响应——稍等或点「重试」`);
412
469
  log.info(`[qemu-docker] bootstrap DONE ${((Date.now() - T0) / 1000).toFixed(1)}s accel=${v.accel}`);
470
+ ensureHubEnrollment({ container, force: true }).catch(() => {});
413
471
  emit({ phase: "done", status: "done", message: "Docker cicy-code 已就绪 🎉" });
414
472
  return { ok: true, container };
415
473
  }
@@ -530,5 +588,5 @@ module.exports = {
530
588
  bootstrap, status, restart, stop, dockerRestart, recreate, update, upgrade, runContainer, readContainerToken,
531
589
  insideHealthy, probeHealth, ensureAutostart, startExisting, containerState, hasGatewayKey,
532
590
  readMihomoConfig, readMihomoSelections, parseMihomoSelections,
533
- vmRun, ensureVm, ensureForwards, vmStatus, dockerEngineUp, imagePresent, loadImage,
591
+ vmRun, ensureVm, ensureForwards, vmStatus, dockerEngineUp, imagePresent, loadImage, ensureHubEnrollment, fleetName, APT_MIRRORS_SH,
534
592
  };
@@ -262,6 +262,10 @@ function run() {
262
262
  writeJson(F.state, Object.assign(readJson(F.state) || s, { bootProvision: PROVISION }));
263
263
  const srv = seedServer(s.token);
264
264
  const args = [
265
+ // -nodefaults:不要 QEMU 默认附带的设备(尤其是空 IDE 光驱)。2026-10-23 xs-1003 的虚拟机跑了 27 小时后
266
+ // 在 libata 的 IDE PIO 传输里 Oops(Bad pagetable)整机卡死 —— WHPX 下模拟老式端口串操作不稳。
267
+ // 我们需要的设备下面都显式给了(virtio 盘 / virtio 网卡 / 串口日志 / QMP / 无显示),不依赖默认设备。
268
+ "-nodefaults",
265
269
  "-name", "cicy-debian",
266
270
  "-accel", "whpx,kernel-irqchip=off", "-accel", "tcg,thread=multi",
267
271
  "-m", String(mem), "-smp", String(smp),
@@ -313,6 +317,14 @@ function accelOf() {
313
317
  return /falling back to tcg|failed to initialize whpx/i.test(t) ? "tcg" : "whpx";
314
318
  }
315
319
  function whpxFeature() { return WIN && fs.existsSync(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WinHvPlatform.dll")); }
320
+ // 来宾内核崩了没有:控制台日志每次开机都清空(start 里 writeFileSync ""),所以里面出现的崩溃都属于本次开机。
321
+ // 崩了的虚拟机 QEMU 进程还活着、端口也还开着,外面只看到「连不上」—— 不看这里就会干等 15 分钟。
322
+ const CRASH_RE = /\] (Oops:|Kernel panic|BUG: |general protection fault|rcu: INFO: rcu_\w+ detected stalls|INFO: task .* blocked for more than)/;
323
+ function crashLine() {
324
+ const t = tailOf(F.console, 256 * 1024).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
325
+ const m = t.match(new RegExp("[^\\n]*" + CRASH_RE.source + "[^\\n]*"));
326
+ return m ? m[0].trim().slice(0, 160) : "";
327
+ }
316
328
  // 串口日志最后一行可读的话 = 开机进度(给卡片「启动中」下面那一行小字)
317
329
  function bootLine() {
318
330
  const t = tailOf(F.console, 8 * 1024).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && /[A-Za-z]/.test(l));
@@ -331,7 +343,7 @@ async function status() {
331
343
  phase: inst && inst.phase, bytes, total: inst && inst.total, src: inst && inst.src,
332
344
  installError: inst && !installing && inst.error ? [inst.error, inst.detail].filter(Boolean).join(" · ") : "",
333
345
  running, ready, accel: running ? accelOf() : "", whpxFeature: whpxFeature(),
334
- boot: running && !ready ? bootLine() : "", ...vmSize(), sshPort: SSH_PORT, qmpPort: QMP_PORT,
346
+ boot: running && !ready ? bootLine() : "", crashed: running ? crashLine() : "", ...vmSize(), sshPort: SSH_PORT, qmpPort: QMP_PORT,
335
347
  provision: running ? (readJson(F.state) || {}).bootProvision || 0 : 0, key: F.key,
336
348
  };
337
349
  }
@@ -0,0 +1,51 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { execFileSync } = require("child_process");
7
+
8
+ const CTL = path.join(__dirname, "..", "src", "sidecar", "qemu-vm.cjs");
9
+
10
+ // qemu-vm.cjs 是命令行脚本:给它一个临时 HOME,伪造「虚拟机在跑」(qemu.pid 指向测试进程自己)和控制台日志。
11
+ function ctlStatus(consoleText) {
12
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "vmctl-"));
13
+ const vm = path.join(home, "cicy-ai", "vm", "debian"), logs = path.join(home, "cicy-ai", "logs");
14
+ fs.mkdirSync(vm, { recursive: true }); fs.mkdirSync(logs, { recursive: true });
15
+ fs.writeFileSync(path.join(vm, "qemu.pid"), String(process.pid));
16
+ fs.writeFileSync(path.join(logs, "debian-console.log"), consoleText);
17
+ const out = execFileSync(process.execPath, [CTL, "status"], { env: { ...process.env, HOME: home, USERPROFILE: home, LOCALAPPDATA: path.join(home, "lad") }, encoding: "utf8" });
18
+ return JSON.parse(out.trim().split("\n").pop());
19
+ }
20
+
21
+ test("a guest kernel Oops in the console is reported as crashed (2026-10-23 IDE PIO crash)", () => {
22
+ const s = ctlStatus("[ 7.4] systemd[1]: Started cicy-ttyd.service\n[99293.49] Oops: Bad pagetable: 0008 [#1] PREEMPT SMP PTI\n[99293.50] Call Trace:\n");
23
+ assert.strictEqual(s.running, true);
24
+ assert.match(s.crashed, /Oops: Bad pagetable/);
25
+ });
26
+
27
+ test("an RCU stall (hung guest) is reported as crashed", () => {
28
+ assert.match(ctlStatus("[33268.26] rcu: INFO: rcu_preempt detected stalls on CPUs/tasks:\n").crashed, /rcu_preempt detected stalls/);
29
+ });
30
+
31
+ test("a normal boot log is not a crash", () => {
32
+ assert.strictEqual(ctlStatus("[ 1.0] Linux version 6.12\n[ 7.4] systemd[1]: Reached target multi-user.target\n").crashed, "");
33
+ });
34
+
35
+ test("the VM launch line drops QEMU's default devices (no IDE CD-ROM)", () => {
36
+ assert.match(fs.readFileSync(CTL, "utf8"), /"-nodefaults",/);
37
+ });
38
+
39
+ test("fleetName: global.json desktopTeam without the desktop- prefix, else hostname", () => {
40
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "fleetname-"));
41
+ fs.mkdirSync(path.join(home, "cicy-ai"), { recursive: true });
42
+ const saved = os.homedir; os.homedir = () => home;
43
+ try {
44
+ delete require.cache[require.resolve("../src/sidecar/qemu-docker")];
45
+ const qd = require("../src/sidecar/qemu-docker");
46
+ fs.writeFileSync(path.join(home, "cicy-ai", "global.json"), JSON.stringify({ desktopTeam: "desktop-xs-1003" }));
47
+ assert.strictEqual(qd.fleetName(), "xs-1003");
48
+ fs.writeFileSync(path.join(home, "cicy-ai", "global.json"), JSON.stringify({}));
49
+ assert.strictEqual(qd.fleetName(), os.hostname().split(".")[0]);
50
+ } finally { os.homedir = saved; }
51
+ });
@@ -1175,7 +1175,10 @@ export default function App() {
1175
1175
  const showCustom = tab === "all" || tab === "custom";
1176
1176
  const showCloud = false; // 首页只用 CiCy Hub 账号,云端团队不再展示
1177
1177
  const showHub = tab === "all" || tab === "hub";
1178
- const hubCount = hub.instances ? hub.instances.length : 0;
1178
+ // 只数能打开的(在线/可达)—— 和下面渲染卡片的过滤同一口径。以前数全部实例,离线的旧记录
1179
+ // (WSL 时代的、换机前的)也算进去,标签显示 33 下面却只有十几张卡。
1180
+ const hubLive = (hub.instances || []).filter((it) => it.reachable || it.online);
1181
+ const hubCount = hubLive.length;
1179
1182
  // 首次打开:本地或云端团队任一还没拉到(为 null)→ grid 显示 skeleton 占位卡,直到两边都
1180
1183
  // resolve(出错也 resolve 成 []),再显示真实内容 —— 避免一个先回来另一个还空的露馅。
1181
1184
  const firstLoading = localTeams === null || teams === null;
@@ -1288,7 +1291,7 @@ export default function App() {
1288
1291
  : (hub.error ? hubErrText(hub.error) : tr("cicyHub.empty", "这个账号下还没有 cicy-code 实例。在 cicy-code 的「CiCy 账号」里用同一个邮箱登录 Hub 即可出现。"))}
1289
1292
  </div>
1290
1293
  )}
1291
- {!firstLoading && showHub && hub.loggedIn && (hub.instances || []).filter((it) => it.reachable || it.online).map((it) => (
1294
+ {!firstLoading && showHub && hub.loggedIn && hubLive.map((it) => (
1292
1295
  <HubInstanceCard key={"hub:" + it.id} inst={it} onOpen={(next, title) => hub.open(it, next, title)} />
1293
1296
  ))}
1294
1297
  {!firstLoading && showLocal && localList.map((t) => (