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 +1 -1
- package/scripts/windows/add-team.html +54 -0
- package/src/backends/local-teams.js +18 -4
- package/src/backends/sidecar-ipc.js +11 -1
- package/src/main.js +1 -1
- package/src/profiles/trusted-origins-store.js +35 -5
- package/src/sidecar/docker.js +35 -3
- package/src/sidecar/wsl-docker.js +1 -1
- package/src/utils/global-json.js +4 -0
- package/src/utils/rpc-guard.js +40 -9
- package/test/docker-preflight-and-always-allow.test.js +53 -0
- package/test/global-json-fresh-dir.test.js +17 -0
- package/test/local-teams-docker-token.test.js +18 -0
- package/test/rpc-guard-deny-cooldown.test.js +18 -0
- package/test/trusted-origins-underscore.test.js +11 -0
package/package.json
CHANGED
|
@@ -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
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 };
|
package/src/sidecar/docker.js
CHANGED
|
@@ -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
|
-
|
|
675
|
-
|
|
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) {
|
|
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
|
package/src/utils/global-json.js
CHANGED
|
@@ -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 {
|
package/src/utils/rpc-guard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
}
|
|
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.
|
|
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
|
+
});
|