cicy-desktop 2.1.281 → 2.1.285

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.281",
3
+ "version": "2.1.285",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>添加团队到 CiCy Desktop</title>
6
+ <style>
7
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0a0a;color:#e6e6e9;font:15px/1.5 system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
8
+ .card{width:420px;max-width:92vw;background:#141416;border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:26px 24px;box-shadow:0 20px 60px rgba(0,0,0,.5)}
9
+ h1{font-size:18px;margin:0 0 4px}
10
+ p.sub{margin:0 0 18px;color:#8b8b92;font-size:13px}
11
+ label{display:block;font-size:12px;color:#8b8b92;margin:10px 0 4px}
12
+ input{width:100%;box-sizing:border-box;height:36px;border:1px solid rgba(255,255,255,.1);border-radius:8px;background:#0a0a0a;color:#e6e6e9;padding:0 10px;font:13px ui-monospace,monospace;outline:0}
13
+ input:focus{border-color:#3b82f6}
14
+ button{margin-top:18px;width:100%;height:44px;border:0;border-radius:10px;background:#3b82f6;color:#fff;font-size:16px;font-weight:600;cursor:pointer}
15
+ button:hover{filter:brightness(1.08)}
16
+ .hint{margin-top:12px;font-size:12px;color:#8b8b92}
17
+ .hint code{color:#b4b4bb}
18
+ a{color:#9db0ff}
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <div class="card">
23
+ <h1>添加团队到 CiCy Desktop</h1>
24
+ <p class="sub">点一下按钮即可;需先安装并至少启动过一次 CiCy Desktop。</p>
25
+ <label>团队地址 URL</label>
26
+ <input id="url" value="http://127.0.0.1:8008" spellcheck="false">
27
+ <label>名称</label>
28
+ <input id="title" value="My Team" spellcheck="false">
29
+ <label>Token(可选)</label>
30
+ <input id="token" value="" spellcheck="false" placeholder="留空则不带">
31
+ <button id="go">添加到 CiCy Desktop</button>
32
+ <div class="hint" id="hint">未登录也可以:团队只存本地,会出现在「我的团队」里。</div>
33
+ </div>
34
+ <script>
35
+ // 用 ?url=&title=&token= 预填,方便做成固定链接分发
36
+ const q = new URLSearchParams(location.search);
37
+ for (const k of ["url","title","token"]) if (q.get(k)) document.getElementById(k).value = q.get(k);
38
+ function link(){
39
+ const url = document.getElementById("url").value.trim();
40
+ const title = document.getElementById("title").value.trim();
41
+ const token = document.getElementById("token").value.trim();
42
+ let s = "cicy-desktop://addTeam?title=" + encodeURIComponent(title) + "&url=" + encodeURIComponent(url);
43
+ if (token) s += "&token=" + encodeURIComponent(token);
44
+ return s;
45
+ }
46
+ document.getElementById("go").onclick = () => {
47
+ const s = link();
48
+ document.getElementById("hint").innerHTML = "已请求打开 <code>" + s.replace(/&token=[^&]*/, "&token=***") + "</code><br>没反应?说明 CiCy Desktop 还没安装或没启动过一次。";
49
+ location.href = s;
50
+ };
51
+ if (q.get("auto") === "1") document.getElementById("go").click();
52
+ </script>
53
+ </body>
54
+ </html>
@@ -308,10 +308,24 @@ async function openTeam(id, opts = {}) {
308
308
  // 共用同一份)—— 打开时**实时读 global.json**,cicy-code 轮换 token 也立刻跟得上,绝不吃 teams.json
309
309
  // 里可能已陈旧的快照(陈旧 → ?token= 旧值 → :8008 拒 → 卡登录/白屏)。opts.token(如 :8008 容器
310
310
  // 自己实时拿的)优先级最高;非本地团队仍用存的 node.api_token。
311
- let isLocalUrl = false;
312
- try { const h = new URL(baseUrl).hostname; isLocalUrl = h === "127.0.0.1" || h === "localhost" || h === "::1"; } catch {}
313
- const token = (opts && opts.token)
314
- || (isLocalUrl ? (readGlobal()?.api_token || node.api_token || "") : (node.api_token || ""));
311
+ let isLocalUrl = false, localPort = null;
312
+ try { const u = new URL(baseUrl); isLocalUrl = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1"; localPort = Number(u.port) || 8008; } catch {}
313
+ // Windows :8008 WSL Docker 容器,token 存在**容器卷里的 global.json**,和主机
314
+ // ~/cicy-ai/global.json 是两把不同的 token —— 绝不能 fallback 到主机的那把(会被容器拒,
315
+ // 卡在登录/白屏)。只有 mac/linux 的 native :8008 才与主机共用同一份 global.json。
316
+ const isWinDockerTeam = process.platform === "win32" && isLocalUrl
317
+ && (node.is_docker === true || localPort === Number(process.env.CICY_DOCKER_APP_PORT || 8008));
318
+ let token = (opts && opts.token) || "";
319
+ if (!token) {
320
+ if (isWinDockerTeam) {
321
+ try { token = String(await require("../sidecar/wsl-docker").readContainerToken(localPort) || "").trim(); } catch (e) { log.warn(`[local-teams] open ${id}: container token read failed: ${e.message}`); }
322
+ if (!token) return { ok: false, error: "container_token_unavailable", hint: "cicy-code 容器还没就绪(读不到容器内的 token)。稍等几秒或用卡片菜单「重启」。" };
323
+ } else if (isLocalUrl) {
324
+ token = readGlobal()?.api_token || node.api_token || "";
325
+ } else {
326
+ token = node.api_token || "";
327
+ }
328
+ }
315
329
  const url = token ? `${baseUrl}/?token=${encodeURIComponent(token)}` : baseUrl;
316
330
 
317
331
  // Compare by origin+pathname only — token + hash both vary per
@@ -139,6 +139,9 @@ function register({ sidecarLogPath } = {}) {
139
139
  // caches it (memory + file), AND auto-starts whatever is installed-but-down. The
140
140
  // docker:app-status handler just returns the cache — instant, never blocks.
141
141
  let _dockerStatusCache = null;
142
+ // 最近一次安装/启动失败的原因(reason + 人话 message),写进 docker-status.json 的
143
+ // lastError,卡片和排障都能看到"为什么装不上",而不是只看到 installed:false。
144
+ let _lastBootstrapError = null;
142
145
  let _dockerDaemonBusy = false;
143
146
  let _gatewayKeyMissingChecks = 0;
144
147
  let _autostartEnsured = false;
@@ -150,7 +153,7 @@ function register({ sidecarLogPath } = {}) {
150
153
  if (s.running) { try { ver = await require("../sidecar/version").running(APP_PORT); } catch {} }
151
154
  // installed: distro 装了 OR :8008 健康(WSL 抽风查不到 distro 但容器在跑 → 也算装了,
152
155
  // 否则卡片误显「下载安装」)。wslUnmanaged: 服务在跑但 WSL 管不到 → 卡片显式提示异常。
153
- _dockerStatusCache = { installed: !!s.distro || !!s.healthy, dockerRunning: !!s.engineUp || !!s.healthy, running: !!s.running, unknown: !!s.unknown, wslUnmanaged: !!s.wslUnmanaged, wslWedged: !!s.wslWedged, version: ver, port: APP_PORT, platform: process.platform, chromeProxy: chromeProxyEnabled(), chromeProxyRunning: hostMihomo.running(), ts: Date.now() };
156
+ _dockerStatusCache = { installed: !!s.distro || !!s.healthy, dockerRunning: !!s.engineUp || !!s.healthy, running: !!s.running, unknown: !!s.unknown, wslUnmanaged: !!s.wslUnmanaged, wslWedged: !!s.wslWedged, version: ver, port: APP_PORT, platform: process.platform, chromeProxy: chromeProxyEnabled(), chromeProxyRunning: hostMihomo.running(), lastError: (!s.running && _lastBootstrapError) ? _lastBootstrapError : null, ts: Date.now() };
154
157
  } catch (e) {
155
158
  _dockerStatusCache = { installed: false, dockerRunning: false, running: false, unknown: true, port: APP_PORT, platform: process.platform, error: e.message, ts: Date.now() };
156
159
  }
@@ -285,6 +288,11 @@ function register({ sidecarLogPath } = {}) {
285
288
  port: PORT,
286
289
  onProgress: (ev) => { try { e.sender.send("docker:bootstrap-progress", ev); } catch {} },
287
290
  });
291
+ if (result && result.ok) _lastBootstrapError = null;
292
+ else if (result) {
293
+ _lastBootstrapError = { reason: result.reason || result.error || "bootstrap_failed", message: result.message || "", ts: Date.now() };
294
+ log.error(`[docker] bootstrap failed reason=${_lastBootstrapError.reason}${_lastBootstrapError.message ? ` — ${_lastBootstrapError.message}` : ""}`);
295
+ }
288
296
  // Healthy local stack → make sure it shows up as a team ("本地团队就加
289
297
  // 上去了"). addTeam dedups by host:port, so re-runs are no-ops. The
290
298
  // api_token must be the CONTAINER's own (volume global.json) — the
@@ -301,6 +309,8 @@ function register({ sidecarLogPath } = {}) {
301
309
  }
302
310
  return result;
303
311
  } catch (err) {
312
+ _lastBootstrapError = { reason: "bootstrap_exception", message: err.message, ts: Date.now() };
313
+ log.error(`[docker] bootstrap threw: ${err.message}`);
304
314
  return { ok: false, error: err.message };
305
315
  }
306
316
  });
package/src/main.js CHANGED
@@ -638,7 +638,7 @@ ipcMain.handle("rpc:guarded", async (event, toolName, args) => {
638
638
  const originOk = await ensureOriginAuthorized(event);
639
639
  if (!originOk) {
640
640
  audit({ kind: "rpc", channel: "rpc:guarded", origin, tool: toolName, dangerous: danger, ok: false, error: "origin-unauthorized", args: argsPreview(toolName, args) });
641
- throw new Error(`未授权站点访问桌面 RPC(rpc:guarded:域名未加入白名单)`);
641
+ throw new Error(`未授权站点访问桌面 RPC(${origin} 未加入白名单或刚被拒绝;请在 CiCy Desktop 头像 → 受信任站点 中添加该域名,或稍后重试并在弹框中选择允许)`);
642
642
  }
643
643
  }
644
644
  if (danger) {
@@ -35,7 +35,7 @@ function normalizeHost(input) {
35
35
  }
36
36
  s = s.split("/")[0].split("?")[0].split("#")[0]; // drop path/query/fragment
37
37
  s = s.replace(/:\d+$/, ""); // drop :port
38
- if (!/^[a-z0-9.-]+$/.test(s)) return ""; // basic host charset
38
+ if (!/^[a-z0-9._-]+$/.test(s)) return ""; // basic host charset (underscore is legal in real-world hostnames, e.g. xs_master.example.com)
39
39
  if (s.startsWith(".") || s.endsWith(".") || s.includes("..")) return "";
40
40
  return s;
41
41
  }
@@ -48,11 +48,41 @@ function readRaw() {
48
48
  } catch { return []; }
49
49
  }
50
50
 
51
- function writeRaw(origins) {
51
+ function writeRaw(origins, dangerous) {
52
52
  fs.mkdirSync(path.dirname(STORE), { recursive: true });
53
- fs.writeFileSync(STORE, JSON.stringify({ origins }, null, 2), { mode: 0o600 });
53
+ const cur = readRawObj();
54
+ const next = { origins, dangerous: Array.isArray(dangerous) ? dangerous : (Array.isArray(cur.dangerous) ? cur.dangerous : []) };
55
+ fs.writeFileSync(STORE, JSON.stringify(next, null, 2), { mode: 0o600 });
54
56
  try { fs.chmodSync(STORE, 0o600); } catch {}
55
57
  }
58
+ function readRawObj() {
59
+ try { if (!fs.existsSync(STORE)) return {}; const j = JSON.parse(fs.readFileSync(STORE, "utf8")); return j && typeof j === "object" ? j : {}; } catch { return {}; }
60
+ }
61
+
62
+ // "此站点始终允许敏感操作":对已在白名单里的站点,持久跳过 exec/读写文件的逐次确认。
63
+ // 只有白名单站点可以加入(不在白名单 → 拒绝),从白名单移除时一并撤销。
64
+ function listDangerousAllowed() {
65
+ const set = new Set(listAll()); // built-ins (localhost/127.0.0.1) included — the local team page IS 127.0.0.1
66
+ return (Array.isArray(readRawObj().dangerous) ? readRawObj().dangerous : []).map(normalizeHost).filter((h) => h && set.has(h));
67
+ }
68
+ function isDangerousAllowed(host) {
69
+ const h = normalizeHost(host);
70
+ return !!h && listDangerousAllowed().includes(h);
71
+ }
72
+ function allowDangerous(input) {
73
+ const host = normalizeHost(input);
74
+ if (!host) return { ok: false, error: "无效的站点地址" };
75
+ if (!listAll().includes(host)) return { ok: false, error: "站点不在白名单中" };
76
+ const cur = listDangerousAllowed();
77
+ if (!cur.includes(host)) { writeRaw(listUser(), [...cur, host]); _audit({ kind: "auth", gate: "allowlist", host, decision: "dangerous-always-allow" }); }
78
+ return { ok: true };
79
+ }
80
+ function revokeDangerous(input) {
81
+ const host = normalizeHost(input);
82
+ const cur = listDangerousAllowed();
83
+ if (cur.includes(host)) { writeRaw(listUser(), cur.filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "dangerous-revoke" }); }
84
+ return { ok: true };
85
+ }
56
86
 
57
87
  // User-managed origins only (normalized, de-duped, built-ins excluded).
58
88
  function listUser() {
@@ -91,8 +121,8 @@ function remove(input) {
91
121
  const host = normalizeHost(input);
92
122
  if (BUILTIN.includes(host)) return { ok: false, error: "内置站点不可删除" };
93
123
  const cur = listUser();
94
- if (cur.includes(host)) { writeRaw(cur.filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-remove" }); }
124
+ if (cur.includes(host)) { writeRaw(cur.filter((h) => h !== host), listDangerousAllowed().filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-remove" }); }
95
125
  return { ok: true, origins: listForUi() };
96
126
  }
97
127
 
98
- module.exports = { STORE, BUILTIN, normalizeHost, listUser, listAll, listForUi, add, remove };
128
+ module.exports = { STORE, BUILTIN, normalizeHost, listUser, listAll, listForUi, add, remove, listDangerousAllowed, isDangerousAllowed, allowDangerous, revokeDangerous };
@@ -11,6 +11,7 @@
11
11
  // https://r2.deepfetch.de5.net/docker/cicy-code-latest.tar.gz
12
12
  //
13
13
  // The container maps :8008 and persists ~/cicy-ai in a named volume.
14
+ const log = require("electron-log"); // bootstrap preflight failures must land in main.log
14
15
  const { execFile, execFileSync, spawn } = require("child_process");
15
16
  const https = require("https");
16
17
  const http = require("http");
@@ -660,7 +661,36 @@ async function dismEnableFeature(feature, label, { emit } = {}) {
660
661
  // { ok } when already present, { needsReboot } after the two required Windows
661
662
  // features are verified-enabled (a Windows reboot is then needed before Docker
662
663
  // can use WSL2), or { failed } if a feature couldn't be enabled.
664
+ // CPU virtualization state (Windows). WSL2 cannot start when the firmware has
665
+ // VT-x/AMD-V disabled — and nothing in software can flip that. Detect it up
666
+ // front so the failure is EXPLAINED (log + card) instead of a mute install loop.
667
+ // hypervisorPresent=true means a hypervisor already runs (Hyper-V/WSL2 active)
668
+ // → virtualization is necessarily on, regardless of what the CPU field says.
669
+ async function virtualizationStatus() {
670
+ if (process.platform !== "win32") return { known: false };
671
+ return new Promise((resolve) => {
672
+ const ps = "$cs=Get-CimInstance Win32_ComputerSystem; $cpu=Get-CimInstance Win32_Processor | Select-Object -First 1; Write-Output (\"\" + $cs.HypervisorPresent + \"|\" + $cpu.VirtualizationFirmwareEnabled + \"|\" + $cpu.VMMonitorModeExtensions + \"|\" + $cpu.Name)";
673
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 20000, windowsHide: true }, (err, stdout) => {
674
+ const line = String(stdout || "").trim().split(/\r?\n/).pop() || "";
675
+ const [hv, fw, vmm, ...rest] = line.split("|");
676
+ if (err || !line || line.split("|").length < 3) return resolve({ known: false, raw: line, error: err && err.message });
677
+ const b = (v) => /^true$/i.test(String(v).trim());
678
+ const hypervisorPresent = b(hv), firmwareEnabled = b(fw), cpuSupports = b(vmm);
679
+ resolve({ known: true, hypervisorPresent, firmwareEnabled, cpuSupports, cpu: rest.join("|").trim(), ok: hypervisorPresent || firmwareEnabled });
680
+ });
681
+ });
682
+ }
683
+
663
684
  async function ensureWsl({ emit } = {}) {
685
+ const virt = await virtualizationStatus();
686
+ if (virt.known && !virt.ok) {
687
+ const why = virt.cpuSupports
688
+ ? `CPU 虚拟化未在 BIOS/固件中开启(Virtualization Enabled In Firmware: No,CPU: ${virt.cpu || "?"})。请重启进 BIOS 打开 Intel VT-x / AMD SVM 后再点「重试」。`
689
+ : `此 CPU 不支持硬件虚拟化(VM Monitor Mode Extensions: No,CPU: ${virt.cpu || "?"}),无法运行 WSL2 / Docker。`;
690
+ log.error(`[bootstrap] ✗ ensure-wsl reason=virtualization_disabled hypervisorPresent=${virt.hypervisorPresent} firmwareEnabled=${virt.firmwareEnabled} cpuSupports=${virt.cpuSupports} cpu=${virt.cpu}`);
691
+ emit && emit({ phase: "done", status: "error", message: why });
692
+ return { ok: false, needsReboot: false, failed: true, reason: "virtualization_disabled", message: why };
693
+ }
664
694
  const executableMissing = await wslMissing();
665
695
  const subsystemEnabled = await featureEnabled("Microsoft-Windows-Subsystem-Linux");
666
696
  const vmPlatformEnabled = await featureEnabled("VirtualMachinePlatform");
@@ -671,8 +701,10 @@ async function ensureWsl({ emit } = {}) {
671
701
  const a = await dismEnableFeature("Microsoft-Windows-Subsystem-Linux", "启用 WSL 功能 1/2 · Linux 子系统", { emit });
672
702
  const b = await dismEnableFeature("VirtualMachinePlatform", "启用 WSL 功能 2/2 · 虚拟机平台", { emit });
673
703
  if (!a || !b) {
674
- emit && emit({ phase: "done", status: "error", message: "WSL 功能未能全部启用——请点「重试」" });
675
- return { ok: false, needsReboot: false, failed: true };
704
+ const message = `WSL 功能未能全部启用(Linux 子系统=${a ? "已启用" : "失败"},虚拟机平台=${b ? "已启用" : "失败"};通常是 UAC 被取消或当前账号无管理员权限)——请以管理员身份重试。`;
705
+ log.error(`[bootstrap] ensure-wsl reason=wsl_enable_failed subsystem=${a} vmPlatform=${b}`);
706
+ emit && emit({ phase: "done", status: "error", message });
707
+ return { ok: false, needsReboot: false, failed: true, reason: "wsl_enable_failed", message };
676
708
  }
677
709
  // Best-effort: also pull the WSL2 kernel/plumbing when the executable itself
678
710
  // is missing. Feature-only repairs must stop here and wait for reboot.
@@ -779,7 +811,7 @@ module.exports = {
779
811
  start, stop, stopContainer, restart, checkStatus, loadImage, loadImageFromTarball,
780
812
  downloadImageTarball, imagePresent, dockerOk, installDocker,
781
813
  bootstrap, probeHealth, readContainerToken, dockerDesktopExe, desktopDir, downloadsDir, imageTarballPath,
782
- launchElevated, wslMissing, ensureWsl,
814
+ launchElevated, wslMissing, ensureWsl, virtualizationStatus,
783
815
  // platform-agnostic download/retry primitives, reused by native.js
784
816
  ensureDownloaded, curlDownload, withRetry, waitUntil, run, headSize,
785
817
  // image freshness (修「重建仍用旧镜像」—— 校验 OSS ETag 变了才重下重载)
@@ -1162,7 +1162,7 @@ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-dock
1162
1162
  begin("ensure-wsl");
1163
1163
  const w = await docker.ensureWsl({ emit });
1164
1164
  if (w.needsReboot) { fail("wsl_reboot_required"); emit({ phase: "done", status: "reboot", message: "WSL2 所需功能已启用——请【重启 Windows】后回来点「重试」继续。" }); finish(false, "wsl_reboot_required"); return { ok: false, reason: "wsl_reboot_required" }; }
1165
- if (!w.ok) { fail("wsl_enable_failed"); finish(false, "wsl_enable_failed"); return { ok: false, reason: "wsl_enable_failed" }; }
1165
+ if (!w.ok) { const reason = w.reason || "wsl_enable_failed"; fail(reason, w.message); finish(false, reason); return { ok: false, reason, message: w.message || "" }; }
1166
1166
  done(true);
1167
1167
 
1168
1168
  // 2) Ubuntu distro
@@ -9,6 +9,10 @@ function sleep(ms) {
9
9
  }
10
10
 
11
11
  function withFileLock(lockPath, fn, { timeoutMs = 5000, retryDelayMs = 50 } = {}) {
12
+ // The lock is a directory next to the target file; on a fresh machine the
13
+ // parent (~/cicy-ai) does not exist yet and mkdir(lock) fails with ENOENT,
14
+ // which used to make every first-run write (api_token, terms, deviceInfo) fail.
15
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
12
16
  const startedAt = Date.now();
13
17
  while (true) {
14
18
  try {
@@ -58,6 +58,10 @@ async function ensureRpcGrant(event, toolName, args) {
58
58
  if (!wc || wc.isDestroyed()) return false;
59
59
  const origin = originOf(wc);
60
60
  if (_grants.get(wc.id) === origin) return true; // already allowed for this page
61
+ let host = ""; try { host = new URL(wc.getURL()).hostname; } catch {}
62
+ let store = null; try { store = require("../profiles/trusted-origins-store"); } catch {}
63
+ if (store && host && store.isDangerousAllowed(host)) return true; // 白名单站点 + 用户选过「始终允许」
64
+ const canAlways = !!(store && host && store.listAll().includes(host));
61
65
 
62
66
  const win = BrowserWindow.fromWebContents(wc) || BrowserWindow.getFocusedWindow() || null;
63
67
  const detail = [`来源: ${origin}`, `操作: ${toolName}`];
@@ -70,7 +74,7 @@ async function ensureRpcGrant(event, toolName, args) {
70
74
  choice = await dialog.showMessageBox(win, {
71
75
  type: "warning",
72
76
  noLink: true,
73
- buttons: ["拒绝", "允许一次", "本页面内允许"],
77
+ buttons: canAlways ? ["拒绝", "允许一次", "本页面内允许", "此站点始终允许(不再询问)"] : ["拒绝", "允许一次", "本页面内允许"],
74
78
  defaultId: 0,
75
79
  cancelId: 0,
76
80
  title: "敏感操作请求",
@@ -80,6 +84,12 @@ async function ensureRpcGrant(event, toolName, args) {
80
84
  } catch { return false; }
81
85
 
82
86
  const response = choice && choice.response;
87
+ if (response === 3 && canAlways) { // persistent: allowlisted site, never ask again for dangerous tools
88
+ try { store.allowDangerous(host); } catch {}
89
+ _grants.set(wc.id, origin);
90
+ audit({ kind: "auth", gate: "dangerous-tool", origin, tool: toolName, decision: "always-allow", temporary: false, args: pv });
91
+ return true;
92
+ }
83
93
  if (response === 2) { // remember for this page
84
94
  _grants.set(wc.id, origin);
85
95
  if (!wc.__rpcGuardWired) {
@@ -104,7 +114,12 @@ async function ensureRpcGrant(event, toolName, args) {
104
114
  // trusted-origins allowlist (so it's never asked again). This replaces the old
105
115
  // "no bridge unless pre-trusted" behaviour with explicit, on-demand consent.
106
116
  const _sessionOrigins = new Set(); // origin -> "本次允许" for this process lifetime
107
- const _deniedOrigins = new Set(); // origin -> "拒绝" sticky so a page can't spam modals
117
+ // origin -> deny timestamp. A deny is sticky only for DENY_COOLDOWN_MS so a page
118
+ // can't spam modals, but a mistaken "拒绝" (or a modal that timed out unattended)
119
+ // no longer locks the origin out for the whole process lifetime — the user gets
120
+ // asked again after the cooldown instead of having to find the settings page.
121
+ const DENY_COOLDOWN_MS = 60 * 1000;
122
+ const _deniedOrigins = new Map();
108
123
  const _pendingByOrigin = new Map(); // origin -> in-flight modal promise (dedup races)
109
124
 
110
125
  // Synchronous verdict for an origin WITHOUT prompting: "allow" | "deny" | "unknown".
@@ -119,7 +134,9 @@ function originDecision(event) {
119
134
  if (isTrustedUrl && isTrustedUrl(url)) return "allow"; // on the allowlist
120
135
  const origin = originOf(wc);
121
136
  if (_sessionOrigins.has(origin)) return "allow"; // "本次允许" earlier
122
- if (_deniedOrigins.has(origin)) return "deny"; // blocked — settings = escape hatch
137
+ const deniedAt = _deniedOrigins.get(origin);
138
+ if (deniedAt && Date.now() - deniedAt < DENY_COOLDOWN_MS) return "deny"; // recently refused
139
+ if (deniedAt) _deniedOrigins.delete(origin); // cooldown over → ask again
123
140
  return "unknown";
124
141
  }
125
142
 
@@ -165,15 +182,29 @@ function startOriginModal(event) {
165
182
  return true;
166
183
  }
167
184
  if (r === 2 && host) { // 加入白名单(持久)— trusted-origins-store.add() logs the allowlist change
168
- try {
169
- const res = require("../profiles/trusted-origins-store").add(host);
170
- if (!res || res.ok === false) return false;
185
+ let res = null, err = "";
186
+ try { res = require("../profiles/trusted-origins-store").add(host); } catch (e) { err = e && e.message; }
187
+ if (res && res.ok !== false) {
171
188
  if (refreshTrustedOrigins) refreshTrustedOrigins();
172
189
  _sessionOrigins.add(origin);
173
190
  return true;
174
- } catch { return false; }
191
+ }
192
+ // The user explicitly chose to trust the site; if persisting the allowlist
193
+ // failed, honour the intent for this run and SAY why — silently returning
194
+ // false here made the modal reappear on every call with no explanation.
195
+ _sessionOrigins.add(origin);
196
+ audit({ kind: "auth", gate: "origin", origin, decision: "session-allow", temporary: true, error: `allowlist-add-failed: ${(res && res.error) || err || "unknown"}` });
197
+ try {
198
+ dialog.showMessageBox(win, {
199
+ type: "error", noLink: true, buttons: ["知道了"],
200
+ title: "加入白名单失败",
201
+ message: `无法把 ${host} 加入受信任站点`,
202
+ detail: `${(res && res.error) || err || "未知错误"}\n\n本次已允许该站点;下次启动会再次询问。你也可以在 头像 → 受信任站点 里手动添加。`,
203
+ }).catch(() => {});
204
+ } catch {}
205
+ return true;
175
206
  }
176
- _deniedOrigins.add(origin); // 拒绝 / 关闭 — sticky for the session
207
+ _deniedOrigins.set(origin, Date.now()); // 拒绝 / 关闭 — sticky for DENY_COOLDOWN_MS
177
208
  audit({ kind: "auth", gate: "origin", origin, decision: "deny", temporary: true });
178
209
  return false;
179
210
  })();
@@ -190,4 +221,4 @@ async function ensureOriginAuthorized(event) {
190
221
  return await startOriginModal(event);
191
222
  }
192
223
 
193
- module.exports = { DANGEROUS_TOOLS, isDangerousTool, ensureRpcGrant, ensureOriginAuthorized, originDecision, startOriginModal };
224
+ module.exports = { DENY_COOLDOWN_MS, DANGEROUS_TOOLS, isDangerousTool, ensureRpcGrant, ensureOriginAuthorized, originDecision, startOriginModal };
@@ -0,0 +1,53 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ const read = (p) => fs.readFileSync(path.join(__dirname, "..", p), "utf8");
8
+
9
+ test("ensureWsl explains a disabled-virtualization host in the log and the card", () => {
10
+ const src = read("src/sidecar/docker.js");
11
+ assert.match(src, /async function virtualizationStatus\(\)/);
12
+ const ensure = src.slice(src.indexOf("async function ensureWsl"), src.indexOf("async function bootstrap"));
13
+ assert.match(ensure, /const virt = await virtualizationStatus\(\);/);
14
+ assert.match(ensure, /reason: "virtualization_disabled"/);
15
+ assert.match(ensure, /log\.error\(`\[bootstrap\] ✗ ensure-wsl reason=virtualization_disabled/);
16
+ assert.match(ensure, /BIOS/);
17
+ assert.match(ensure, /reason: "wsl_enable_failed", message/);
18
+ assert.match(read("src/sidecar/wsl-docker.js"), /const reason = w\.reason \|\| "wsl_enable_failed"; fail\(reason, w\.message\)/);
19
+ const ipc = read("src/backends/sidecar-ipc.js");
20
+ assert.match(ipc, /lastError: \(!s\.running && _lastBootstrapError\)/);
21
+ assert.match(ipc, /_lastBootstrapError = \{ reason: result\.reason \|\| result\.error/);
22
+ });
23
+
24
+ test("trusted-origins: persistent dangerous-ops allow only for allowlisted hosts", () => {
25
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "cicy-trust-"));
26
+ const prevHome = process.env.HOME, prevUp = process.env.USERPROFILE;
27
+ process.env.HOME = home; process.env.USERPROFILE = home;
28
+ delete require.cache[require.resolve("../src/profiles/trusted-origins-store")];
29
+ try {
30
+ const store = require("../src/profiles/trusted-origins-store");
31
+ assert.equal(store.allowDangerous("xs_master.cicy-ai.com").ok, false); // not allowlisted yet
32
+ assert.equal(store.allowDangerous("127.0.0.1").ok, true); // built-in host works too
33
+ assert.equal(store.isDangerousAllowed("127.0.0.1"), true);
34
+ assert.equal(store.add("xs_master.cicy-ai.com").ok, true);
35
+ assert.equal(store.allowDangerous("https://xs_master.cicy-ai.com").ok, true);
36
+ assert.equal(store.isDangerousAllowed("xs_master.cicy-ai.com"), true);
37
+ assert.equal(store.add("other.example.com").ok, true); // adding another host keeps the flag
38
+ assert.equal(store.isDangerousAllowed("xs_master.cicy-ai.com"), true);
39
+ store.remove("xs_master.cicy-ai.com"); // removal revokes it
40
+ assert.equal(store.isDangerousAllowed("xs_master.cicy-ai.com"), false);
41
+ } finally {
42
+ process.env.HOME = prevHome; if (prevUp === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUp;
43
+ delete require.cache[require.resolve("../src/profiles/trusted-origins-store")];
44
+ fs.rmSync(home, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ test("rpc-guard offers a persistent 'always allow' only for allowlisted origins", () => {
49
+ const src = read("src/utils/rpc-guard.js");
50
+ assert.match(src, /store\.isDangerousAllowed\(host\)\) return true/);
51
+ assert.match(src, /canAlways \? \["拒绝", "允许一次", "本页面内允许", "此站点始终允许(不再询问)"\]/);
52
+ assert.match(src, /response === 3 && canAlways/);
53
+ });
@@ -0,0 +1,17 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const { updateGlobalConfig, readGlobalConfig } = require("../src/utils/global-json");
7
+
8
+ test("updateGlobalConfig works when ~/cicy-ai does not exist yet (fresh machine)", () => {
9
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "cicy-fresh-"));
10
+ const file = path.join(root, "cicy-ai", "global.json"); // parent dir missing on purpose
11
+ try {
12
+ const next = updateGlobalConfig(file, (c) => ({ ...c, api_token: "cicy_test" }));
13
+ assert.equal(next.api_token, "cicy_test");
14
+ assert.equal(readGlobalConfig(file).api_token, "cicy_test");
15
+ assert.equal(fs.existsSync(`${file}.lock`), false);
16
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
17
+ });
@@ -0,0 +1,18 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ // Static guard: openTeam must NOT use the host global.json token for the
7
+ // Windows Docker :8008 team — that token belongs to the container's own volume.
8
+ test("openTeam reads the container token for the Windows Docker :8008 team, never the host global.json", () => {
9
+ const src = fs.readFileSync(path.join(__dirname, "..", "src", "backends", "local-teams.js"), "utf8");
10
+ const open = src.slice(src.indexOf("async function openTeam"), src.indexOf("async function openTeam") + 2000);
11
+ // win32 + local + (is_docker | docker port) → container token
12
+ assert.match(open, /const isWinDockerTeam = process\.platform === "win32" && isLocalUrl/);
13
+ assert.match(open, /readContainerToken\(localPort\)/);
14
+ // when it's the win docker team, the host global.json fallback must be gated OUT
15
+ assert.match(open, /if \(isWinDockerTeam\)[^]*?\} else if \(isLocalUrl\) \{\s*token = readGlobal\(\)\?\.api_token/);
16
+ // and a missing container token refuses to open (no silent host-token page)
17
+ assert.match(open, /container_token_unavailable/);
18
+ });
@@ -0,0 +1,18 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ // Static checks: a deny must expire (cooldown) and a failed allowlist add must
7
+ // still honour the user's "trust" choice for the session and explain the error.
8
+ test("origin deny is a cooldown, not process-lifetime; failed allowlist add is explained", () => {
9
+ const src = fs.readFileSync(path.join(__dirname, "..", "src", "utils", "rpc-guard.js"), "utf8");
10
+ assert.match(src, /const DENY_COOLDOWN_MS = \d+ \* 1000;/);
11
+ assert.match(src, /const _deniedOrigins = new Map\(\)/);
12
+ assert.match(src, /Date\.now\(\) - deniedAt < DENY_COOLDOWN_MS\) return "deny"/);
13
+ assert.match(src, /_deniedOrigins\.set\(origin, Date\.now\(\)\)/);
14
+ assert.match(src, /加入白名单失败/);
15
+ const trustBranch = src.slice(src.indexOf("if (r === 2 && host)"), src.indexOf("_deniedOrigins.set(origin"));
16
+ assert.match(trustBranch, /_sessionOrigins\.add\(origin\);[^]*return true;[^]*_sessionOrigins\.add\(origin\);[^]*return true;/);
17
+ assert.doesNotMatch(trustBranch, /return false/);
18
+ });
@@ -0,0 +1,11 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { normalizeHost } = require("../src/profiles/trusted-origins-store");
4
+
5
+ test("trusted-origins accepts hostnames with underscores, bare or as URL", () => {
6
+ assert.equal(normalizeHost("xs_master.cicy-ai.com"), "xs_master.cicy-ai.com");
7
+ assert.equal(normalizeHost("https://XS_master.cicy-ai.com/path?q=1"), "xs_master.cicy-ai.com");
8
+ assert.equal(normalizeHost("xs_master.cicy-ai.com:8008"), "xs_master.cicy-ai.com");
9
+ assert.equal(normalizeHost("bad host"), "");
10
+ assert.equal(normalizeHost(".x.com"), "");
11
+ });