cicy-desktop 2.1.78 → 2.1.79
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/bin/cicy-desktop +7 -7
- package/package.json +6 -6
- package/src/backends/homepage-preload.js +22 -0
- package/src/backends/homepage-react/assets/index-CKpaMBKz.css +1 -0
- package/src/backends/homepage-react/assets/index-CSsNZgC5.js +365 -0
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/homepage-window.js +52 -7
- package/src/backends/ipc.js +57 -0
- package/src/backends/local-teams.js +73 -26
- package/src/backends/sidecar-ipc.js +11 -0
- package/src/backends/webview-preload.js +5 -3
- package/src/backends/window-manager.js +13 -3
- package/src/chrome/chrome-launcher.js +5 -4
- package/src/chrome/debugger-port-resolver.js +1 -1
- package/src/cloud/cloud-client.js +237 -41
- package/src/cluster/types.js +0 -5
- package/src/extension/inject.js +1 -1
- package/src/main.js +282 -88
- package/src/master/chrome-config.js +2 -2
- package/src/preload-rpc.js +1 -1
- package/src/profiles/profile-store.js +321 -0
- package/src/profiles/trusted-origins-store.js +95 -0
- package/src/server/worker-observability-routes.js +0 -2
- package/src/sidecar/cicy-code.js +84 -23
- package/src/sidecar/localbin.js +20 -3
- package/src/sidecar/native.js +3 -3
- package/src/sidecar/version.js +45 -0
- package/src/tabbrowser/newtab-protocol.js +54 -0
- package/src/tabbrowser/tab-browser.html +151 -0
- package/src/tabbrowser/tab-shell-preload.js +28 -0
- package/src/tabbrowser/tab-shell.html +227 -0
- package/src/tools/account-tools.js +191 -25
- package/src/tools/chrome-tools.js +173 -37
- package/src/tools/device-tools.js +25 -0
- package/src/tools/index.js +2 -0
- package/src/tools/tab-browser-tools.js +453 -0
- package/src/tools/window-tools.js +64 -7
- package/src/utils/brand-host-electron.js +25 -0
- package/src/utils/context-menu-options.js +80 -0
- package/src/utils/cookie-logins.js +58 -0
- package/src/utils/ip-probe.js +50 -0
- package/src/utils/rpc-audit.js +53 -0
- package/src/utils/rpc-guard.js +189 -0
- package/src/utils/window-monitor.js +5 -15
- package/src/utils/window-registry.js +210 -0
- package/src/utils/window-thumbnails.js +126 -0
- package/src/utils/window-utils.js +146 -109
- package/workers/render/package-lock.json +6 -6
- package/workers/render/src/App.css +36 -2
- package/workers/render/src/App.jsx +587 -103
- package/src/backends/artifact-ipc.js +0 -142
- package/src/backends/homepage-react/assets/index-DE9m6JTn.css +0 -1
- package/src/backends/homepage-react/assets/index-DLYMzgf5.js +0 -365
- package/src/cluster/artifact-registry.js +0 -61
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared "which sites is this session logged into?" summarizer for BOTH backends.
|
|
2
|
+
//
|
|
3
|
+
// Input: an array of cookies, each with at least { name, domain }. Both
|
|
4
|
+
// CDP Storage.getCookies (Chrome) and Electron session.cookies.get() return
|
|
5
|
+
// that shape, so the same rollup serves chrome + electron tools identically.
|
|
6
|
+
//
|
|
7
|
+
// Output: per-registrable-domain counts, flagging domains that carry an
|
|
8
|
+
// auth/session-looking cookie. That flag is a STRONG SIGNAL, not proof — a
|
|
9
|
+
// session cookie can be present while the account is signed out / expired
|
|
10
|
+
// (e.g. github keeping `logged_out` cookies). Confirm by loading the site.
|
|
11
|
+
|
|
12
|
+
// Cookie names that typically indicate an authenticated/session state.
|
|
13
|
+
const AUTH_RE = /sid|session|auth|token|login|sapisid|__secure|remember|passport|csrf/i;
|
|
14
|
+
|
|
15
|
+
// Cheap registrable-domain: last two labels. Good enough for grouping/display;
|
|
16
|
+
// not a full Public Suffix List (e.g. foo.co.uk collapses to co.uk).
|
|
17
|
+
function registrableDomain(host) {
|
|
18
|
+
const h = String(host || "").replace(/^\./, "").toLowerCase();
|
|
19
|
+
if (!h) return "";
|
|
20
|
+
const parts = h.split(".");
|
|
21
|
+
return parts.length <= 2 ? h : parts.slice(-2).join(".");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function summarizeCookieLogins(cookies) {
|
|
25
|
+
const list = Array.isArray(cookies) ? cookies : [];
|
|
26
|
+
const byDomain = new Map();
|
|
27
|
+
for (const c of list) {
|
|
28
|
+
const d = registrableDomain(c && c.domain);
|
|
29
|
+
if (!d) continue;
|
|
30
|
+
let e = byDomain.get(d);
|
|
31
|
+
if (!e) {
|
|
32
|
+
e = { domain: d, cookies: 0, authCookies: [] };
|
|
33
|
+
byDomain.set(d, e);
|
|
34
|
+
}
|
|
35
|
+
e.cookies += 1;
|
|
36
|
+
if (c && c.name && AUTH_RE.test(c.name) && !e.authCookies.includes(c.name)) {
|
|
37
|
+
e.authCookies.push(c.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const sites = [...byDomain.values()]
|
|
41
|
+
.map((e) => ({
|
|
42
|
+
domain: e.domain,
|
|
43
|
+
cookies: e.cookies,
|
|
44
|
+
loggedIn: e.authCookies.length > 0,
|
|
45
|
+
authCookies: e.authCookies,
|
|
46
|
+
}))
|
|
47
|
+
.sort((a, b) => b.cookies - a.cookies);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
totalCookies: list.length,
|
|
51
|
+
domains: sites.length,
|
|
52
|
+
loggedIn: sites.filter((s) => s.loggedIn).map((s) => s.domain),
|
|
53
|
+
sites,
|
|
54
|
+
note: "loggedIn = 该域名带会话 cookie,强信号但非确证(cookie 在≠一定登录),可打开站点确认",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { summarizeCookieLogins, AUTH_RE, registrableDomain };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Per-profile egress IP + geo probe. Fetches an IP-info API THROUGH a given
|
|
2
|
+
// Electron session (so the result reflects that profile's proxy egress), in the
|
|
3
|
+
// main process via net.request (no browser launch needed). Returns
|
|
4
|
+
// { ip, area } — area is "country · region · city" (blank parts dropped).
|
|
5
|
+
//
|
|
6
|
+
// NOTE: the opposite of cloud-client's detectIpGeo (which deliberately goes
|
|
7
|
+
// DIRECT for the *machine's* egress). Here we WANT the proxy path, per profile.
|
|
8
|
+
const { net } = require("electron");
|
|
9
|
+
|
|
10
|
+
function fetchJsonViaSession(url, ses, timeoutMs = 7000) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
let done = false;
|
|
13
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
14
|
+
try {
|
|
15
|
+
const req = net.request({ url, session: ses, useSessionCookies: false });
|
|
16
|
+
const to = setTimeout(() => { try { req.abort(); } catch (_) {} finish(null); }, timeoutMs);
|
|
17
|
+
let body = "";
|
|
18
|
+
req.on("response", (res) => {
|
|
19
|
+
res.on("data", (d) => { body += d; });
|
|
20
|
+
res.on("end", () => { clearTimeout(to); try { finish(JSON.parse(body)); } catch (_) { finish(null); } });
|
|
21
|
+
res.on("error", () => { clearTimeout(to); finish(null); });
|
|
22
|
+
});
|
|
23
|
+
req.on("error", () => { clearTimeout(to); finish(null); });
|
|
24
|
+
req.end();
|
|
25
|
+
} catch (_) { finish(null); }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const joinArea = (...parts) => parts.filter((p) => typeof p === "string" && p.trim()).join(" · ");
|
|
30
|
+
|
|
31
|
+
// Probe egress IP + geo via the given session. Primary: api.myip.com
|
|
32
|
+
// ({ ip, country, cc }) — same endpoint as `agent-chrome ip`. Falls back to
|
|
33
|
+
// ip-api.com (adds region/city) then ipify (ip only). Never throws.
|
|
34
|
+
async function probeIpViaSession(ses) {
|
|
35
|
+
let j = await fetchJsonViaSession("https://api.myip.com", ses);
|
|
36
|
+
if (j && typeof j.ip === "string" && j.ip) {
|
|
37
|
+
return { ip: j.ip, area: joinArea(j.cc, j.country) };
|
|
38
|
+
}
|
|
39
|
+
j = await fetchJsonViaSession("http://ip-api.com/json", ses);
|
|
40
|
+
if (j && typeof j.query === "string" && j.query) {
|
|
41
|
+
return { ip: j.query, area: joinArea(j.country, j.regionName, j.city) };
|
|
42
|
+
}
|
|
43
|
+
j = await fetchJsonViaSession("https://api.ipify.org?format=json", ses);
|
|
44
|
+
if (j && typeof j.ip === "string" && j.ip) {
|
|
45
|
+
return { ip: j.ip, area: "" };
|
|
46
|
+
}
|
|
47
|
+
return { ip: "", area: "" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { probeIpViaSession, fetchJsonViaSession };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// RPC audit log — an append-only record of (a) every electronRPC tool call and
|
|
2
|
+
// (b) every authorization decision (including the TEMPORARY ones — "本次允许" /
|
|
3
|
+
// "允许一次" / "本页面内允许" — which otherwise live only in memory and leave no
|
|
4
|
+
// trace), plus trusted-origin allowlist add/remove. Written as JSONL to
|
|
5
|
+
// ~/cicy-ai/db/rpc-audit.log (mode 0600), rotated at 5 MB.
|
|
6
|
+
//
|
|
7
|
+
// Security intent: the RPC bridge can run host code / read-write files, so who
|
|
8
|
+
// authorized what, when, and which calls actually ran must be reviewable after
|
|
9
|
+
// the fact — not just gated by an in-the-moment modal.
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const os = require("os");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
const LOG = path.join(os.homedir(), "cicy-ai", "db", "rpc-audit.log");
|
|
15
|
+
const MAX_BYTES = 5 * 1024 * 1024; // rotate to .1 past this
|
|
16
|
+
|
|
17
|
+
function rotateIfNeeded() {
|
|
18
|
+
try {
|
|
19
|
+
const st = fs.statSync(LOG);
|
|
20
|
+
if (st.size > MAX_BYTES) {
|
|
21
|
+
try { fs.renameSync(LOG, LOG + ".1"); } catch {}
|
|
22
|
+
}
|
|
23
|
+
} catch {} // missing file → nothing to rotate
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Append one record as a JSON line. NEVER throws — auditing must not break RPC.
|
|
27
|
+
function audit(entry) {
|
|
28
|
+
try {
|
|
29
|
+
fs.mkdirSync(path.dirname(LOG), { recursive: true });
|
|
30
|
+
rotateIfNeeded();
|
|
31
|
+
const rec = { ts: new Date().toISOString(), ...entry };
|
|
32
|
+
fs.appendFileSync(LOG, JSON.stringify(rec) + "\n", { mode: 0o600 });
|
|
33
|
+
try { fs.chmodSync(LOG, 0o600); } catch {}
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Short, secret-light preview of args for exec_*/file_* (same fields the consent
|
|
38
|
+
// dialog surfaces). Other tools log no args.
|
|
39
|
+
function argsPreview(tool, args) {
|
|
40
|
+
try {
|
|
41
|
+
if (/^exec_/.test(tool)) {
|
|
42
|
+
const c = args && (args.command || args.code || args.script || args.cmd);
|
|
43
|
+
if (c) return String(c).slice(0, 240);
|
|
44
|
+
}
|
|
45
|
+
if (/^file_/.test(tool)) {
|
|
46
|
+
const p = args && (args.path || args.filename || args.file);
|
|
47
|
+
if (p) return String(p).slice(0, 240);
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { LOG, audit, argsPreview };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// RPC capability gate (security hole #3 — "trusted source XSS ≠ RCE").
|
|
2
|
+
//
|
|
3
|
+
// The renderer electronRPC bridge can invoke ANY registered tool, including host
|
|
4
|
+
// code/file execution (exec_*, file_*). The homepage is first-party system UI and
|
|
5
|
+
// keeps the unguarded "rpc" channel. EVERY OTHER renderer surface (team-helper
|
|
6
|
+
// <webview>, trusted remote pages, dom-ready injected scripts) is wired to the
|
|
7
|
+
// "rpc:guarded" channel: a *dangerous* tool there requires an explicit, page-
|
|
8
|
+
// scoped user grant. So a trusted origin that gets XSS'd cannot silently run a
|
|
9
|
+
// command — the user sees a consent dialog naming the origin + operation. Normal
|
|
10
|
+
// (non-dangerous) tools pass straight through.
|
|
11
|
+
//
|
|
12
|
+
// NOTE: this gate is only *enforceable* for renderers that have NO direct Node
|
|
13
|
+
// access (contextIsolation:true, no nodeIntegration) — e.g. the team-helper
|
|
14
|
+
// webview and the sandboxed tab-browser trusted tabs. A renderer created with
|
|
15
|
+
// nodeIntegration:true (the legacy createWindow trusted path) can `require()`
|
|
16
|
+
// child_process directly and bypass any IPC gate; closing that requires dropping
|
|
17
|
+
// its nodeIntegration, tracked separately.
|
|
18
|
+
const { dialog, BrowserWindow } = require("electron");
|
|
19
|
+
const { audit } = require("./rpc-audit");
|
|
20
|
+
|
|
21
|
+
// Host code-exec + host filesystem tools = the RCE surface.
|
|
22
|
+
const DANGEROUS_TOOLS = new Set([
|
|
23
|
+
"exec_shell", "exec_python", "exec_node",
|
|
24
|
+
"exec_shell_file", "exec_python_file", "exec_node_file",
|
|
25
|
+
"file_read", "file_write", "file_upload", "file_download",
|
|
26
|
+
]);
|
|
27
|
+
function isDangerousTool(t) { return DANGEROUS_TOOLS.has(t); }
|
|
28
|
+
|
|
29
|
+
// webContents.id -> granted origin (page-scoped; cleared on cross-doc nav / destroy).
|
|
30
|
+
const _grants = new Map();
|
|
31
|
+
|
|
32
|
+
function originOf(wc) {
|
|
33
|
+
try { return new URL(wc.getURL()).origin; } catch { return wc.getURL() || "(unknown)"; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function previewArgs(tool, args) {
|
|
37
|
+
try {
|
|
38
|
+
if (/^exec_/.test(tool)) {
|
|
39
|
+
const c = args && (args.command || args.code || args.script || args.cmd);
|
|
40
|
+
if (c) return `命令: ${String(c).slice(0, 240)}`;
|
|
41
|
+
}
|
|
42
|
+
if (/^file_/.test(tool)) {
|
|
43
|
+
const p = args && (args.path || args.filename || args.file);
|
|
44
|
+
if (p) return `路径: ${String(p).slice(0, 240)}`;
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Returns true if the dangerous call is allowed (granted now or earlier for this
|
|
51
|
+
// page), false if the user denied it.
|
|
52
|
+
async function ensureRpcGrant(event, toolName, args) {
|
|
53
|
+
const wc = event && event.sender;
|
|
54
|
+
if (!wc || wc.isDestroyed()) return false;
|
|
55
|
+
const origin = originOf(wc);
|
|
56
|
+
if (_grants.get(wc.id) === origin) return true; // already allowed for this page
|
|
57
|
+
|
|
58
|
+
const win = BrowserWindow.fromWebContents(wc) || BrowserWindow.getFocusedWindow() || null;
|
|
59
|
+
const detail = [`来源: ${origin}`, `操作: ${toolName}`];
|
|
60
|
+
const pv = previewArgs(toolName, args);
|
|
61
|
+
if (pv) detail.push(pv);
|
|
62
|
+
detail.push("", "该站点请求在你的电脑上执行命令 / 读写文件。只有你完全信任它时才允许。");
|
|
63
|
+
|
|
64
|
+
let choice;
|
|
65
|
+
try {
|
|
66
|
+
choice = await dialog.showMessageBox(win, {
|
|
67
|
+
type: "warning",
|
|
68
|
+
noLink: true,
|
|
69
|
+
buttons: ["拒绝", "允许一次", "本页面内允许"],
|
|
70
|
+
defaultId: 0,
|
|
71
|
+
cancelId: 0,
|
|
72
|
+
title: "敏感操作请求",
|
|
73
|
+
message: "站点要在本机执行敏感操作",
|
|
74
|
+
detail: detail.join("\n"),
|
|
75
|
+
});
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
|
|
78
|
+
const response = choice && choice.response;
|
|
79
|
+
if (response === 2) { // remember for this page
|
|
80
|
+
_grants.set(wc.id, origin);
|
|
81
|
+
if (!wc.__rpcGuardWired) {
|
|
82
|
+
wc.__rpcGuardWired = true;
|
|
83
|
+
const clear = () => _grants.delete(wc.id);
|
|
84
|
+
wc.on("did-start-navigation", (_e, _url, isInPlace, isMainFrame) => { if (isMainFrame && !isInPlace) clear(); });
|
|
85
|
+
wc.once("destroyed", clear);
|
|
86
|
+
}
|
|
87
|
+
audit({ kind: "auth", gate: "dangerous-tool", origin, tool: toolName, decision: "page-allow", temporary: true, args: pv });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
audit({ kind: "auth", gate: "dangerous-tool", origin, tool: toolName, decision: response === 1 ? "allow-once" : "deny", temporary: true, args: pv });
|
|
91
|
+
return response === 1; // allow once
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Origin allowlist gate (domain-level trust-on-demand) ──────────────────────
|
|
95
|
+
// Distinct from the per-tool DANGEROUS gate above: this decides whether a page's
|
|
96
|
+
// ORIGIN may use the electronRPC bridge AT ALL. The bridge is now injected into
|
|
97
|
+
// every profile-0 tab, but it stays inert until the origin is authorized: a
|
|
98
|
+
// non-allowlisted origin's first rpc:guarded call pops a consent modal where the
|
|
99
|
+
// user can deny, allow for this run only, or add the domain to the persistent
|
|
100
|
+
// trusted-origins allowlist (so it's never asked again). This replaces the old
|
|
101
|
+
// "no bridge unless pre-trusted" behaviour with explicit, on-demand consent.
|
|
102
|
+
const _sessionOrigins = new Set(); // origin -> "本次允许" for this process lifetime
|
|
103
|
+
const _deniedOrigins = new Set(); // origin -> "拒绝" — sticky so a page can't spam modals
|
|
104
|
+
const _pendingByOrigin = new Map(); // origin -> in-flight modal promise (dedup races)
|
|
105
|
+
|
|
106
|
+
// Synchronous verdict for an origin WITHOUT prompting: "allow" | "deny" | "unknown".
|
|
107
|
+
// Lets a non-blocking caller (agent/skill, see main.js) decide instantly whether to
|
|
108
|
+
// proceed, refuse, or return a PENDING sentinel while the modal runs in the bg.
|
|
109
|
+
function originDecision(event) {
|
|
110
|
+
const wc = event && event.sender;
|
|
111
|
+
if (!wc || wc.isDestroyed()) return "deny";
|
|
112
|
+
const url = wc.getURL();
|
|
113
|
+
let isTrustedUrl;
|
|
114
|
+
try { ({ isTrustedUrl } = require("./window-utils")); } catch {}
|
|
115
|
+
if (isTrustedUrl && isTrustedUrl(url)) return "allow"; // on the allowlist
|
|
116
|
+
const origin = originOf(wc);
|
|
117
|
+
if (_sessionOrigins.has(origin)) return "allow"; // "本次允许" earlier
|
|
118
|
+
if (_deniedOrigins.has(origin)) return "deny"; // blocked — settings = escape hatch
|
|
119
|
+
return "unknown";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Ensure the consent modal is running for this origin (deduped per origin). Returns
|
|
123
|
+
// the in-flight promise (resolves true/false). Safe to fire-and-forget: it records
|
|
124
|
+
// the outcome in _sessionOrigins/_deniedOrigins/the allowlist so a later
|
|
125
|
+
// originDecision() resolves it — that's how the agent's poll eventually succeeds.
|
|
126
|
+
function startOriginModal(event) {
|
|
127
|
+
const wc = event && event.sender;
|
|
128
|
+
if (!wc || wc.isDestroyed()) return Promise.resolve(false);
|
|
129
|
+
const origin = originOf(wc);
|
|
130
|
+
if (_pendingByOrigin.has(origin)) return _pendingByOrigin.get(origin); // dedup
|
|
131
|
+
let host = "";
|
|
132
|
+
try { host = new URL(wc.getURL()).hostname; } catch {}
|
|
133
|
+
let refreshTrustedOrigins;
|
|
134
|
+
try { ({ refreshTrustedOrigins } = require("./window-utils")); } catch {}
|
|
135
|
+
|
|
136
|
+
const p = (async () => {
|
|
137
|
+
const win = BrowserWindow.fromWebContents(wc) || BrowserWindow.getFocusedWindow() || null;
|
|
138
|
+
let choice;
|
|
139
|
+
try {
|
|
140
|
+
choice = await dialog.showMessageBox(win, {
|
|
141
|
+
type: "warning",
|
|
142
|
+
noLink: true,
|
|
143
|
+
buttons: ["拒绝", "本次允许", "信任此站点(加入白名单)"],
|
|
144
|
+
defaultId: 0,
|
|
145
|
+
cancelId: 0,
|
|
146
|
+
title: "站点请求桌面 RPC 授权",
|
|
147
|
+
message: "是否授权该站点使用桌面 RPC?",
|
|
148
|
+
detail: [
|
|
149
|
+
`来源: ${origin}`,
|
|
150
|
+
"",
|
|
151
|
+
"授权后,该站点可通过 electronRPC 调用本机工具(执行命令 / 读写文件等)。",
|
|
152
|
+
"只有你完全信任它时才授权。",
|
|
153
|
+
"「信任此站点」会把该域名加入白名单,以后不再询问。",
|
|
154
|
+
].join("\n"),
|
|
155
|
+
});
|
|
156
|
+
} catch { return false; }
|
|
157
|
+
const r = choice && choice.response;
|
|
158
|
+
if (r === 1) { // 本次允许 — temporary (process lifetime), record it so it isn't trace-less
|
|
159
|
+
_sessionOrigins.add(origin);
|
|
160
|
+
audit({ kind: "auth", gate: "origin", origin, decision: "session-allow", temporary: true });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (r === 2 && host) { // 加入白名单(持久)— trusted-origins-store.add() logs the allowlist change
|
|
164
|
+
try {
|
|
165
|
+
const res = require("../profiles/trusted-origins-store").add(host);
|
|
166
|
+
if (!res || res.ok === false) return false;
|
|
167
|
+
if (refreshTrustedOrigins) refreshTrustedOrigins();
|
|
168
|
+
_sessionOrigins.add(origin);
|
|
169
|
+
return true;
|
|
170
|
+
} catch { return false; }
|
|
171
|
+
}
|
|
172
|
+
_deniedOrigins.add(origin); // 拒绝 / 关闭 — sticky for the session
|
|
173
|
+
audit({ kind: "auth", gate: "origin", origin, decision: "deny", temporary: true });
|
|
174
|
+
return false;
|
|
175
|
+
})();
|
|
176
|
+
_pendingByOrigin.set(origin, p);
|
|
177
|
+
p.finally(() => _pendingByOrigin.delete(origin));
|
|
178
|
+
return p;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Blocking gate (in-page callers): resolve the verdict, prompting if undecided.
|
|
182
|
+
async function ensureOriginAuthorized(event) {
|
|
183
|
+
const d = originDecision(event);
|
|
184
|
+
if (d === "allow") return true;
|
|
185
|
+
if (d === "deny") return false;
|
|
186
|
+
return await startOriginModal(event);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { DANGEROUS_TOOLS, isDangerousTool, ensureRpcGrant, ensureOriginAuthorized, originDecision, startOriginModal };
|
|
@@ -3,7 +3,6 @@ const path = require("path");
|
|
|
3
3
|
const os = require("os");
|
|
4
4
|
const beautify = require("js-beautify");
|
|
5
5
|
const log = require("electron-log");
|
|
6
|
-
const { maybeRegisterArtifact } = require("../cluster/artifact-registry");
|
|
7
6
|
|
|
8
7
|
// 存储每个窗口的日志和请求
|
|
9
8
|
const windowLogs = new Map();
|
|
@@ -197,20 +196,11 @@ function saveDataToFile(winId, url, content, type, contentType, isBinary, dataSi
|
|
|
197
196
|
fs.writeFileSync(filepath, formattedContent);
|
|
198
197
|
}
|
|
199
198
|
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
kind: type,
|
|
208
|
-
url,
|
|
209
|
-
winId,
|
|
210
|
-
contentType,
|
|
211
|
-
timestamp: ts,
|
|
212
|
-
}
|
|
213
|
-
);
|
|
199
|
+
return {
|
|
200
|
+
__file: filepath,
|
|
201
|
+
__size: dataSize,
|
|
202
|
+
__binary: isBinary,
|
|
203
|
+
};
|
|
214
204
|
} catch (error) {
|
|
215
205
|
// 文件保存失败,返回错误信息
|
|
216
206
|
log.error(`[Window ${winId}] Failed to save file for ${url}:`, error.message);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Persistent window registry.
|
|
2
|
+
//
|
|
3
|
+
// Background: every window list in the app (get_windows tool, /ui/windows,
|
|
4
|
+
// local-agent-registry) is a LIVE enumeration of BrowserWindow.getAllWindows()
|
|
5
|
+
// with NO persistence, and close_window/destroy() drops the window from that
|
|
6
|
+
// only source. So a closed window leaves no trace and nothing survives a
|
|
7
|
+
// restart.
|
|
8
|
+
//
|
|
9
|
+
// This module adds a durable structure on disk (~/cicy-ai/db/windows.json):
|
|
10
|
+
// - open → upsert an entry (status:"open"), deduped by accountIdx + url
|
|
11
|
+
// - close → KEEP the entry, just flip status:"closed" (never delete)
|
|
12
|
+
// - restart → the list reloads; windows that were still open when the app
|
|
13
|
+
// QUIT come back (auto-reopen), windows the user closed stay "closed".
|
|
14
|
+
//
|
|
15
|
+
// Identity: each entry has an immutable windowKey (uuid). The live
|
|
16
|
+
// BrowserWindow.id is ephemeral (reassigned every session) so we keep a
|
|
17
|
+
// runtime-only liveId↔windowKey map and never trust the persisted liveId
|
|
18
|
+
// across restarts (load() clears it).
|
|
19
|
+
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const os = require("os");
|
|
23
|
+
const crypto = require("crypto");
|
|
24
|
+
const log = require("electron-log");
|
|
25
|
+
|
|
26
|
+
const DB_DIR = path.join(os.homedir(), "cicy-ai", "db");
|
|
27
|
+
const REGISTRY_PATH = path.join(DB_DIR, "windows.json");
|
|
28
|
+
|
|
29
|
+
// { windows: { [windowKey]: entry } }
|
|
30
|
+
let _cache = null;
|
|
31
|
+
// runtime-only: live BrowserWindow.id -> windowKey (rebuilt every process)
|
|
32
|
+
const liveIdToKey = new Map();
|
|
33
|
+
|
|
34
|
+
function ensureDir() {
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nowIso() {
|
|
41
|
+
return new Date().toISOString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Normalize for dedup: drop hash, strip trailing slash, lowercase.
|
|
45
|
+
function normalizeUrl(url) {
|
|
46
|
+
if (!url) return "";
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(url);
|
|
49
|
+
u.hash = "";
|
|
50
|
+
let s = u.toString();
|
|
51
|
+
if (s.endsWith("/")) s = s.slice(0, -1);
|
|
52
|
+
return s.toLowerCase();
|
|
53
|
+
} catch {
|
|
54
|
+
return String(url).trim().replace(/\/+$/, "").toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function load() {
|
|
59
|
+
if (_cache) return _cache;
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(REGISTRY_PATH)) {
|
|
62
|
+
const raw = JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8"));
|
|
63
|
+
_cache = raw && typeof raw === "object" && raw.windows ? raw : { windows: {} };
|
|
64
|
+
} else {
|
|
65
|
+
_cache = { windows: {} };
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
log.error("[WindowRegistry] load failed:", e.message);
|
|
69
|
+
_cache = { windows: {} };
|
|
70
|
+
}
|
|
71
|
+
// Process restarted → no window is live yet. The persisted liveId is stale;
|
|
72
|
+
// clear it so registerOpen() reuses the existing slot instead of orphaning it.
|
|
73
|
+
for (const k of Object.keys(_cache.windows)) _cache.windows[k].liveId = null;
|
|
74
|
+
return _cache;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function persist() {
|
|
78
|
+
ensureDir();
|
|
79
|
+
try {
|
|
80
|
+
const tmp = REGISTRY_PATH + ".tmp";
|
|
81
|
+
fs.writeFileSync(tmp, JSON.stringify(_cache, null, 2), "utf8");
|
|
82
|
+
fs.renameSync(tmp, REGISTRY_PATH);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
log.error("[WindowRegistry] persist failed:", e.message);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findEntry(accountIdx, url) {
|
|
89
|
+
const reg = load();
|
|
90
|
+
const norm = normalizeUrl(url);
|
|
91
|
+
for (const key of Object.keys(reg.windows)) {
|
|
92
|
+
const e = reg.windows[key];
|
|
93
|
+
if (e.accountIdx === accountIdx && normalizeUrl(e.url) === norm) return e;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A real BrowserWindow was created/opened. Upsert its entry (dedup by
|
|
99
|
+
// accountIdx+url) and bind it to the live id. Returns the entry.
|
|
100
|
+
function registerOpen({ accountIdx = 0, url = "", title = "", bounds = null, liveId }) {
|
|
101
|
+
const reg = load();
|
|
102
|
+
let entry = findEntry(accountIdx, url);
|
|
103
|
+
// Don't collapse two distinct live windows into one slot: if the matching
|
|
104
|
+
// entry is already bound to a DIFFERENT live window, start a fresh entry.
|
|
105
|
+
if (entry && entry.liveId != null && entry.liveId !== liveId) entry = null;
|
|
106
|
+
|
|
107
|
+
if (!entry) {
|
|
108
|
+
const windowKey = crypto.randomUUID();
|
|
109
|
+
entry = {
|
|
110
|
+
windowKey,
|
|
111
|
+
accountIdx,
|
|
112
|
+
url: url || "",
|
|
113
|
+
title: title || "",
|
|
114
|
+
bounds: bounds || null,
|
|
115
|
+
status: "open",
|
|
116
|
+
createdAt: nowIso(),
|
|
117
|
+
updatedAt: nowIso(),
|
|
118
|
+
openedAt: nowIso(),
|
|
119
|
+
closedAt: null,
|
|
120
|
+
liveId: liveId ?? null,
|
|
121
|
+
};
|
|
122
|
+
reg.windows[windowKey] = entry;
|
|
123
|
+
} else {
|
|
124
|
+
entry.status = "open";
|
|
125
|
+
if (url) entry.url = url;
|
|
126
|
+
if (title) entry.title = title;
|
|
127
|
+
if (bounds) entry.bounds = bounds;
|
|
128
|
+
entry.openedAt = nowIso();
|
|
129
|
+
entry.updatedAt = nowIso();
|
|
130
|
+
entry.closedAt = null;
|
|
131
|
+
entry.liveId = liveId ?? entry.liveId;
|
|
132
|
+
}
|
|
133
|
+
if (liveId != null) liveIdToKey.set(liveId, entry.windowKey);
|
|
134
|
+
persist();
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Keep a live window's record fresh (url/title/bounds change).
|
|
139
|
+
function touch({ liveId, url, title, bounds }) {
|
|
140
|
+
const key = liveIdToKey.get(liveId);
|
|
141
|
+
if (!key) return;
|
|
142
|
+
const reg = load();
|
|
143
|
+
const e = reg.windows[key];
|
|
144
|
+
if (!e) return;
|
|
145
|
+
let changed = false;
|
|
146
|
+
if (url && e.url !== url) {
|
|
147
|
+
e.url = url;
|
|
148
|
+
changed = true;
|
|
149
|
+
}
|
|
150
|
+
if (title && e.title !== title) {
|
|
151
|
+
e.title = title;
|
|
152
|
+
changed = true;
|
|
153
|
+
}
|
|
154
|
+
if (bounds) {
|
|
155
|
+
e.bounds = bounds;
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
if (changed) {
|
|
159
|
+
e.updatedAt = nowIso();
|
|
160
|
+
persist();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// User/agent closed a window → keep the record, flip to "closed".
|
|
165
|
+
function markClosed(liveId) {
|
|
166
|
+
const key = liveIdToKey.get(liveId);
|
|
167
|
+
if (!key) return;
|
|
168
|
+
const reg = load();
|
|
169
|
+
const e = reg.windows[key];
|
|
170
|
+
if (e) {
|
|
171
|
+
e.status = "closed";
|
|
172
|
+
e.closedAt = nowIso();
|
|
173
|
+
e.updatedAt = nowIso();
|
|
174
|
+
e.liveId = null;
|
|
175
|
+
persist();
|
|
176
|
+
}
|
|
177
|
+
liveIdToKey.delete(liveId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function list() {
|
|
181
|
+
return Object.values(load().windows);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function keyForLiveId(liveId) {
|
|
185
|
+
return liveIdToKey.get(liveId) || null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getByKey(windowKey) {
|
|
189
|
+
return load().windows[windowKey] || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Entries that should auto-reopen on startup: status "open" with no live
|
|
193
|
+
// binding (i.e. they were open when the app last quit). liveId is cleared by
|
|
194
|
+
// load() at process start, so at startup these are exactly the survivors.
|
|
195
|
+
function staleOpenEntries() {
|
|
196
|
+
return list().filter((e) => e.status === "open" && e.liveId == null);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
REGISTRY_PATH,
|
|
201
|
+
normalizeUrl,
|
|
202
|
+
registerOpen,
|
|
203
|
+
touch,
|
|
204
|
+
markClosed,
|
|
205
|
+
list,
|
|
206
|
+
keyForLiveId,
|
|
207
|
+
getByKey,
|
|
208
|
+
staleOpenEntries,
|
|
209
|
+
load,
|
|
210
|
+
};
|