cicy-desktop 2.1.78 → 2.1.80
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,321 @@
|
|
|
1
|
+
// profile-store.js — the shared "browser profile" standard across both backends.
|
|
2
|
+
//
|
|
3
|
+
// One profile = one backend (Chrome OR Electron); the two keep their own store
|
|
4
|
+
// files and their own cookie engines, but expose an IDENTICAL core schema and
|
|
5
|
+
// the same operations (list / proxy / logins). This module is the single source
|
|
6
|
+
// of truth for that core — both src/tools/chrome-tools.js and
|
|
7
|
+
// src/tools/account-tools.js (and window-utils proxy auto-apply) route through
|
|
8
|
+
// it so the field names and semantics never drift.
|
|
9
|
+
//
|
|
10
|
+
// Stores (unchanged locations):
|
|
11
|
+
// chrome → ~/cicy-ai/db/chrome.json keyed "profile_<N>"
|
|
12
|
+
// electron → ~/data/electron/account-<N>.json
|
|
13
|
+
//
|
|
14
|
+
// Core fields (identical names in BOTH files, added lazily; missing = default):
|
|
15
|
+
// name : string
|
|
16
|
+
// proxy : { url, enabled } ← persisted desired proxy (normalized)
|
|
17
|
+
// logins : [ { platform, account, addedAt } ] ← one entry per platform
|
|
18
|
+
// note : string
|
|
19
|
+
// createdAt / updatedAt : ISO
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const os = require("os");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
|
|
25
|
+
const CHROME_JSON = path.join(os.homedir(), "cicy-ai", "db", "chrome.json");
|
|
26
|
+
const ELECTRON_DIR = path.join(os.homedir(), "data", "electron");
|
|
27
|
+
|
|
28
|
+
// ── proxy: the ONE normalizer ────────────────────────────────────────────────
|
|
29
|
+
// Accepts every historical encoding and returns the canonical {url, enabled}:
|
|
30
|
+
// ""/null/undefined → { url:"", enabled:false }
|
|
31
|
+
// "socks5://…" (string) → { url:s, enabled:!!s } (legacy chrome)
|
|
32
|
+
// { enable, url } → { url, enabled:!!enable } (legacy chrome obj)
|
|
33
|
+
// { enabled, url } → { url, enabled:!!enabled } (canonical)
|
|
34
|
+
function normalizeProxy(raw) {
|
|
35
|
+
if (raw == null || raw === "") return { url: "", enabled: false };
|
|
36
|
+
if (typeof raw === "string") return { url: raw, enabled: !!raw };
|
|
37
|
+
if (typeof raw === "object") {
|
|
38
|
+
const url = typeof raw.url === "string" ? raw.url : "";
|
|
39
|
+
const enabled = ("enabled" in raw ? !!raw.enabled : !!raw.enable) && !!url;
|
|
40
|
+
return { url, enabled };
|
|
41
|
+
}
|
|
42
|
+
return { url: "", enabled: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// proxyRules(p) → the string for Electron session.setProxy / Chromium --proxy-server
|
|
46
|
+
// (empty string = direct/no proxy).
|
|
47
|
+
function proxyRules(proxyLike) {
|
|
48
|
+
const p = normalizeProxy(proxyLike);
|
|
49
|
+
return p.enabled && p.url ? p.url : "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── logins: shared mutation (one entry per platform, keyed case-insensitively) ─
|
|
53
|
+
function upsertLogin(logins, platform, account) {
|
|
54
|
+
const list = Array.isArray(logins) ? logins.slice() : [];
|
|
55
|
+
const key = String(platform || "").trim().toLowerCase();
|
|
56
|
+
if (!key) return list;
|
|
57
|
+
const next = list.filter((l) => String(l.platform || "").toLowerCase() !== key);
|
|
58
|
+
next.push({ platform: key, account: String(account || "").trim(), addedAt: new Date().toISOString() });
|
|
59
|
+
return next;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removeLoginFrom(logins, platform) {
|
|
63
|
+
const list = Array.isArray(logins) ? logins : [];
|
|
64
|
+
const key = String(platform || "").trim().toLowerCase();
|
|
65
|
+
return list.filter((l) => String(l.platform || "").toLowerCase() !== key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── rich login record (unified across chrome + electron) ─────────────────────
|
|
69
|
+
// One entry per site, keyed by `name` (platform/site name) case-insensitively.
|
|
70
|
+
// Legacy thin entries {platform, account, addedAt} are mapped forward so old
|
|
71
|
+
// data keeps working: platform→name, account→username, addedAt→loginAt.
|
|
72
|
+
const LOGIN_FIELDS = ["url", "name", "username", "email", "mobile", "twofa", "secondEmail", "note", "loginAt", "updatedAt"];
|
|
73
|
+
|
|
74
|
+
function normalizeLogin(raw) {
|
|
75
|
+
const r = raw && typeof raw === "object" ? raw : {};
|
|
76
|
+
const s = (v, alt = "") => (typeof v === "string" ? v : alt);
|
|
77
|
+
return {
|
|
78
|
+
url: s(r.url),
|
|
79
|
+
name: s(r.name, s(r.platform)),
|
|
80
|
+
username: s(r.username, s(r.account)),
|
|
81
|
+
email: s(r.email),
|
|
82
|
+
mobile: s(r.mobile),
|
|
83
|
+
twofa: s(r.twofa, s(r.totp)),
|
|
84
|
+
secondEmail: s(r.secondEmail),
|
|
85
|
+
note: s(r.note),
|
|
86
|
+
loginAt: s(r.loginAt, s(r.addedAt)),
|
|
87
|
+
updatedAt: s(r.updatedAt),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loginKey(l) {
|
|
92
|
+
return String((l && (l.name || l.url)) || "").trim().toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Upsert by site key; only NON-EMPTY incoming fields overwrite existing ones
|
|
96
|
+
// (so a partial patch never wipes data). Stamps loginAt (first seen) + updatedAt.
|
|
97
|
+
function upsertLoginRich(logins, login) {
|
|
98
|
+
const list = (Array.isArray(logins) ? logins : []).map(normalizeLogin);
|
|
99
|
+
const inc = normalizeLogin(login);
|
|
100
|
+
const key = loginKey(inc);
|
|
101
|
+
if (!key) return list;
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
const i = list.findIndex((l) => loginKey(l) === key);
|
|
104
|
+
if (i >= 0) {
|
|
105
|
+
const merged = { ...list[i] };
|
|
106
|
+
for (const k of LOGIN_FIELDS) if (inc[k]) merged[k] = inc[k];
|
|
107
|
+
merged.loginAt = merged.loginAt || now;
|
|
108
|
+
merged.updatedAt = now;
|
|
109
|
+
list[i] = merged;
|
|
110
|
+
} else {
|
|
111
|
+
inc.loginAt = inc.loginAt || now;
|
|
112
|
+
inc.updatedAt = now;
|
|
113
|
+
list.push(inc);
|
|
114
|
+
}
|
|
115
|
+
return list;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function removeLoginRich(logins, key) {
|
|
119
|
+
const k = String(key || "").trim().toLowerCase();
|
|
120
|
+
return (Array.isArray(logins) ? logins : []).map(normalizeLogin).filter((l) => loginKey(l) !== k);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── ipInfo: per-profile egress IP + geo area + last-probed time ──────────────
|
|
124
|
+
function normalizeIpInfo(raw) {
|
|
125
|
+
const r = raw && typeof raw === "object" ? raw : {};
|
|
126
|
+
return {
|
|
127
|
+
ip: typeof r.ip === "string" ? r.ip : "",
|
|
128
|
+
area: typeof r.area === "string" ? r.area : "",
|
|
129
|
+
probedAt: typeof r.probedAt === "string" ? r.probedAt : "",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── chrome backend (chrome.json, key profile_<N>) ────────────────────────────
|
|
134
|
+
function readChromeConfig() {
|
|
135
|
+
if (!fs.existsSync(CHROME_JSON)) return {};
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(fs.readFileSync(CHROME_JSON, "utf-8")) || {};
|
|
138
|
+
} catch {
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeChromeConfig(next) {
|
|
144
|
+
fs.mkdirSync(path.dirname(CHROME_JSON), { recursive: true });
|
|
145
|
+
fs.writeFileSync(CHROME_JSON, JSON.stringify(next || {}, null, 2), { mode: 0o600 });
|
|
146
|
+
try {
|
|
147
|
+
fs.chmodSync(CHROME_JSON, 0o600);
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function chromeView(idx, entry) {
|
|
152
|
+
const e = entry && typeof entry === "object" ? entry : {};
|
|
153
|
+
return {
|
|
154
|
+
id: `chrome-${idx}`,
|
|
155
|
+
backend: "chrome",
|
|
156
|
+
accountIdx: idx,
|
|
157
|
+
name: typeof e.name === "string" && e.name ? e.name : `profile_${idx}`,
|
|
158
|
+
proxy: normalizeProxy(e.proxy),
|
|
159
|
+
logins: (Array.isArray(e.logins) ? e.logins : []).map(normalizeLogin),
|
|
160
|
+
note: typeof e.note === "string" ? e.note : "",
|
|
161
|
+
// chrome-specific extras (read-only passthrough)
|
|
162
|
+
gmail: typeof e.gmail === "string" ? e.gmail : "",
|
|
163
|
+
port: typeof e.port === "number" ? e.port : 11000 + idx,
|
|
164
|
+
rpaDir: typeof e.rpaDir === "string" ? e.rpaDir : `~/chrome/profile_${idx}`,
|
|
165
|
+
platform: e.platform && typeof e.platform === "object" ? e.platform : {},
|
|
166
|
+
ipInfo: normalizeIpInfo(e.ipInfo),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function chromeIndices() {
|
|
171
|
+
const data = readChromeConfig();
|
|
172
|
+
return Object.keys(data)
|
|
173
|
+
.map((k) => (/^profile_(\d+)$/.exec(k) ? Number(/^profile_(\d+)$/.exec(k)[1]) : null))
|
|
174
|
+
.filter((n) => typeof n === "number")
|
|
175
|
+
.sort((a, b) => a - b);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mutateChrome(idx, fn) {
|
|
179
|
+
const data = readChromeConfig();
|
|
180
|
+
const key = `profile_${idx}`;
|
|
181
|
+
if (!data[key]) throw new Error(`Missing chrome.json entry: ${key}`);
|
|
182
|
+
data[key] = fn({ ...data[key] }) || data[key];
|
|
183
|
+
data[key].updatedAt = new Date().toISOString();
|
|
184
|
+
writeChromeConfig(data);
|
|
185
|
+
return chromeView(idx, data[key]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── electron backend (account-<N>.json) ──────────────────────────────────────
|
|
189
|
+
function electronFile(idx) {
|
|
190
|
+
return path.join(ELECTRON_DIR, `account-${idx}.json`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readAccount(idx) {
|
|
194
|
+
const f = electronFile(idx);
|
|
195
|
+
if (!fs.existsSync(f)) return null;
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(fs.readFileSync(f, "utf-8"));
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function writeAccount(data) {
|
|
204
|
+
fs.mkdirSync(ELECTRON_DIR, { recursive: true });
|
|
205
|
+
fs.writeFileSync(electronFile(data.accountIdx), JSON.stringify(data, null, 2));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function electronView(idx, data) {
|
|
209
|
+
const d = data && typeof data === "object" ? data : { accountIdx: idx };
|
|
210
|
+
const meta = d.metadata && typeof d.metadata === "object" ? d.metadata : {};
|
|
211
|
+
return {
|
|
212
|
+
id: `electron-${idx}`,
|
|
213
|
+
backend: "electron",
|
|
214
|
+
accountIdx: idx,
|
|
215
|
+
name: typeof meta.name === "string" && meta.name ? meta.name : `electron-${idx}`,
|
|
216
|
+
proxy: normalizeProxy(d.proxy),
|
|
217
|
+
logins: (Array.isArray(d.logins) ? d.logins : []).map(normalizeLogin),
|
|
218
|
+
note: typeof d.note === "string" ? d.note : meta.description || "",
|
|
219
|
+
partition: `persist:sandbox-${idx}`,
|
|
220
|
+
ipInfo: normalizeIpInfo(d.ipInfo),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function electronIndices() {
|
|
225
|
+
if (!fs.existsSync(ELECTRON_DIR)) return [];
|
|
226
|
+
return fs
|
|
227
|
+
.readdirSync(ELECTRON_DIR)
|
|
228
|
+
.map((f) => (/^account-(\d+)\.json$/.exec(f) ? Number(/^account-(\d+)\.json$/.exec(f)[1]) : null))
|
|
229
|
+
.filter((n) => typeof n === "number")
|
|
230
|
+
.sort((a, b) => a - b);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function mutateElectron(idx, fn) {
|
|
234
|
+
let data = readAccount(idx);
|
|
235
|
+
if (!data) {
|
|
236
|
+
data = { accountIdx: idx, createdAt: new Date().toISOString(), windows: [], metadata: {} };
|
|
237
|
+
}
|
|
238
|
+
data = fn(data) || data;
|
|
239
|
+
data.updatedAt = new Date().toISOString();
|
|
240
|
+
writeAccount(data);
|
|
241
|
+
return electronView(idx, data);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── unified surface (backend = "chrome" | "electron") ────────────────────────
|
|
245
|
+
function listProfiles(backend) {
|
|
246
|
+
if (backend === "chrome") return chromeIndices().map((i) => chromeView(i, readChromeConfig()[`profile_${i}`]));
|
|
247
|
+
if (backend === "electron") return electronIndices().map((i) => electronView(i, readAccount(i)));
|
|
248
|
+
throw new Error(`Unknown backend: ${backend}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getProfile(backend, idx) {
|
|
252
|
+
if (backend === "chrome") {
|
|
253
|
+
const e = readChromeConfig()[`profile_${idx}`];
|
|
254
|
+
if (!e) return null;
|
|
255
|
+
return chromeView(idx, e);
|
|
256
|
+
}
|
|
257
|
+
if (backend === "electron") {
|
|
258
|
+
const d = readAccount(idx);
|
|
259
|
+
return d ? electronView(idx, d) : null;
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`Unknown backend: ${backend}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// setProxy persists the desired proxy (canonical {url, enabled}) to the store.
|
|
265
|
+
// Empty/falsey url clears it. Returns the updated unified view.
|
|
266
|
+
function setProxy(backend, idx, url) {
|
|
267
|
+
const proxy = normalizeProxy(url);
|
|
268
|
+
if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, proxy }));
|
|
269
|
+
if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, proxy }));
|
|
270
|
+
throw new Error(`Unknown backend: ${backend}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// setLogin — upsert a rich login record (any subset of LOGIN_FIELDS). Keyed by
|
|
274
|
+
// `name` (site name). Works identically for both backends.
|
|
275
|
+
function setLogin(backend, idx, login) {
|
|
276
|
+
if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, logins: upsertLoginRich(e.logins, login) }));
|
|
277
|
+
if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, logins: upsertLoginRich(d.logins, login) }));
|
|
278
|
+
throw new Error(`Unknown backend: ${backend}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// addLogin — back-compat thin form (platform/account); delegates to setLogin.
|
|
282
|
+
function addLogin(backend, idx, platform, account) {
|
|
283
|
+
return setLogin(backend, idx, { name: platform, username: account });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// setIpInfo — persist a freshly probed egress IP + area, stamping probedAt=now.
|
|
287
|
+
function setIpInfo(backend, idx, info) {
|
|
288
|
+
const ipInfo = { ...normalizeIpInfo(info), probedAt: new Date().toISOString() };
|
|
289
|
+
if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, ipInfo }));
|
|
290
|
+
if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, ipInfo }));
|
|
291
|
+
throw new Error(`Unknown backend: ${backend}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function removeLogin(backend, idx, nameOrUrl) {
|
|
295
|
+
if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, logins: removeLoginRich(e.logins, nameOrUrl) }));
|
|
296
|
+
if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, logins: removeLoginRich(d.logins, nameOrUrl) }));
|
|
297
|
+
throw new Error(`Unknown backend: ${backend}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function listLogins(backend, idx) {
|
|
301
|
+
const p = getProfile(backend, idx);
|
|
302
|
+
return p ? p.logins : [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
CHROME_JSON,
|
|
307
|
+
ELECTRON_DIR,
|
|
308
|
+
normalizeProxy,
|
|
309
|
+
proxyRules,
|
|
310
|
+
listProfiles,
|
|
311
|
+
getProfile,
|
|
312
|
+
setProxy,
|
|
313
|
+
setLogin,
|
|
314
|
+
addLogin,
|
|
315
|
+
removeLogin,
|
|
316
|
+
listLogins,
|
|
317
|
+
normalizeLogin,
|
|
318
|
+
LOGIN_FIELDS,
|
|
319
|
+
setIpInfo,
|
|
320
|
+
normalizeIpInfo,
|
|
321
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Trusted-origins allowlist — the EXACT-hostname set of sites permitted to
|
|
2
|
+
// receive the electronRPC bridge in profile 0 (i.e. allowed to run exec_shell &
|
|
3
|
+
// friends on THIS machine). Persisted at ~/cicy-ai/db/trusted-origins.json as
|
|
4
|
+
// { "origins": ["app.example.com", …] }.
|
|
5
|
+
//
|
|
6
|
+
// This is the ONLY user-controlled source of trust (see window-utils.isTrustedUrl).
|
|
7
|
+
// • localhost / 127.0.0.1 are always trusted (built-in, non-removable).
|
|
8
|
+
// • Everything else must be added explicitly here (Chrome-style site settings).
|
|
9
|
+
// • Adding a team / backend does NOT grant trust — "add a server" must never
|
|
10
|
+
// implicitly hand a remote origin the ability to run commands locally.
|
|
11
|
+
// • There is deliberately NO domain-suffix wildcard (a public-upload host like
|
|
12
|
+
// r2.deepfetch.de5.net under a trusted suffix would otherwise become a trusted
|
|
13
|
+
// RPC source).
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
let _audit = () => {};
|
|
18
|
+
try { _audit = require("../utils/rpc-audit").audit; } catch {}
|
|
19
|
+
|
|
20
|
+
const STORE = path.join(os.homedir(), "cicy-ai", "db", "trusted-origins.json");
|
|
21
|
+
const BUILTIN = ["localhost", "127.0.0.1"]; // always trusted, cannot be removed
|
|
22
|
+
|
|
23
|
+
// Normalize arbitrary user input to a bare hostname:
|
|
24
|
+
// "https://X.Com/path?q" → "x.com", "x.com:3000" → "x.com", " x.com " → "x.com".
|
|
25
|
+
// Returns "" when nothing usable / invalid.
|
|
26
|
+
function normalizeHost(input) {
|
|
27
|
+
if (!input || typeof input !== "string") return "";
|
|
28
|
+
let s = input.trim().toLowerCase();
|
|
29
|
+
if (!s) return "";
|
|
30
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//.test(s)) {
|
|
31
|
+
try { return new URL(s).hostname; } catch { return ""; }
|
|
32
|
+
}
|
|
33
|
+
s = s.split("/")[0].split("?")[0].split("#")[0]; // drop path/query/fragment
|
|
34
|
+
s = s.replace(/:\d+$/, ""); // drop :port
|
|
35
|
+
if (!/^[a-z0-9.-]+$/.test(s)) return ""; // basic host charset
|
|
36
|
+
if (s.startsWith(".") || s.endsWith(".") || s.includes("..")) return "";
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readRaw() {
|
|
41
|
+
try {
|
|
42
|
+
if (!fs.existsSync(STORE)) return [];
|
|
43
|
+
const j = JSON.parse(fs.readFileSync(STORE, "utf-8")) || {};
|
|
44
|
+
return Array.isArray(j.origins) ? j.origins : [];
|
|
45
|
+
} catch { return []; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeRaw(origins) {
|
|
49
|
+
fs.mkdirSync(path.dirname(STORE), { recursive: true });
|
|
50
|
+
fs.writeFileSync(STORE, JSON.stringify({ origins }, null, 2), { mode: 0o600 });
|
|
51
|
+
try { fs.chmodSync(STORE, 0o600); } catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// User-managed origins only (normalized, de-duped, built-ins excluded).
|
|
55
|
+
function listUser() {
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const h of readRaw()) {
|
|
59
|
+
const n = normalizeHost(h);
|
|
60
|
+
if (n && !BUILTIN.includes(n) && !seen.has(n)) { seen.add(n); out.push(n); }
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The full trusted set consumed by isTrustedUrl(): built-ins ∪ user list.
|
|
66
|
+
function listAll() {
|
|
67
|
+
return [...BUILTIN, ...listUser()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// UI shape: each row tagged builtin (greyed / non-removable) or user.
|
|
71
|
+
function listForUi() {
|
|
72
|
+
return [
|
|
73
|
+
...BUILTIN.map((host) => ({ host, builtin: true })),
|
|
74
|
+
...listUser().map((host) => ({ host, builtin: false })),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function add(input) {
|
|
79
|
+
const host = normalizeHost(input);
|
|
80
|
+
if (!host) return { ok: false, error: "无效的站点地址" };
|
|
81
|
+
if (BUILTIN.includes(host)) return { ok: true, origins: listForUi() }; // already trusted
|
|
82
|
+
const cur = listUser();
|
|
83
|
+
if (!cur.includes(host)) { writeRaw([...cur, host]); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-add" }); }
|
|
84
|
+
return { ok: true, origins: listForUi() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function remove(input) {
|
|
88
|
+
const host = normalizeHost(input);
|
|
89
|
+
if (BUILTIN.includes(host)) return { ok: false, error: "内置站点不可删除" };
|
|
90
|
+
const cur = listUser();
|
|
91
|
+
if (cur.includes(host)) { writeRaw(cur.filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-remove" }); }
|
|
92
|
+
return { ok: true, origins: listForUi() };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { STORE, BUILTIN, normalizeHost, listUser, listAll, listForUi, add, remove };
|
|
@@ -16,7 +16,6 @@ function createWorkerObservabilityRoutes({ getWorkerIdentity, getWorkerSnapshot
|
|
|
16
16
|
workerId: getWorkerIdentity().workerId,
|
|
17
17
|
ts: Date.now(),
|
|
18
18
|
agents: snapshot.agents.length,
|
|
19
|
-
artifacts: snapshot.artifacts.length,
|
|
20
19
|
});
|
|
21
20
|
});
|
|
22
21
|
|
|
@@ -30,7 +29,6 @@ function createWorkerObservabilityRoutes({ getWorkerIdentity, getWorkerSnapshot
|
|
|
30
29
|
const lines = [
|
|
31
30
|
`cicy_worker_up 1`,
|
|
32
31
|
`cicy_worker_agents ${snapshot.agents.length}`,
|
|
33
|
-
`cicy_worker_artifacts ${snapshot.artifacts.length}`,
|
|
34
32
|
`cicy_worker_capabilities ${snapshot.capabilities.length}`,
|
|
35
33
|
`cicy_worker_memory_rss ${snapshot.resources.memory.rss || 0}`,
|
|
36
34
|
`cicy_worker_uptime ${snapshot.resources.uptime || 0}`,
|
package/src/sidecar/cicy-code.js
CHANGED
|
@@ -37,6 +37,9 @@ function probeExisting(port = DEFAULT_PORT, timeoutMs = 500) {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Running-daemon version lives in ONE place now: require("./version").running().
|
|
41
|
+
// update() below uses it to verify what's actually live after a restart.
|
|
42
|
+
|
|
40
43
|
let child = null;
|
|
41
44
|
|
|
42
45
|
// Runtime Bundle v1 (主人指令): prefer the versioned runtime store on EVERY
|
|
@@ -139,12 +142,10 @@ async function start({ logPath, port = DEFAULT_PORT, force = false, version = nu
|
|
|
139
142
|
CICY_CODE_PORT: String(port),
|
|
140
143
|
PORT: String(port),
|
|
141
144
|
};
|
|
145
|
+
// --helper removed (主人指令): Windows now runs cicy-code in normal mode (full
|
|
146
|
+
// tmux-based multi-agent via the bundled MSYS2 runtime), same as mac/linux —
|
|
147
|
+
// no longer the single headless 团队助手.
|
|
142
148
|
const args = [];
|
|
143
|
-
if (process.platform === "win32") {
|
|
144
|
-
// Windows runs the single headless 团队助手 (--helper=1) on w-1001 — no tmux
|
|
145
|
-
// panes, so msys2/tmux are NOT bundled or referenced anymore (主人指令 2026-06-08).
|
|
146
|
-
args.push("--helper=1");
|
|
147
|
-
}
|
|
148
149
|
child = spawn(exe, args, { stdio, detached: false, windowsHide: true, env });
|
|
149
150
|
console.log(`[cicy-code-sidecar] spawned ${exe} ${args.join(" ")} pid=${child.pid} port=${port} log=${logPath || "(none)"}`);
|
|
150
151
|
|
|
@@ -160,6 +161,21 @@ async function start({ logPath, port = DEFAULT_PORT, force = false, version = nu
|
|
|
160
161
|
// [] when lsof is missing or nothing is listening.
|
|
161
162
|
const LSOF_CANDIDATES = ["/usr/sbin/lsof", "/usr/bin/lsof", "lsof"];
|
|
162
163
|
function listPortPids(port) {
|
|
164
|
+
// Windows has no lsof — find the LISTENING PID on the port via netstat instead.
|
|
165
|
+
// Needed so stop()/update() can actually kill the old cicy-code.exe holding
|
|
166
|
+
// :8008 before launching the new version (else the new one can't bind and the
|
|
167
|
+
// update silently "succeeds" while the OLD version keeps running).
|
|
168
|
+
if (process.platform === "win32") {
|
|
169
|
+
try {
|
|
170
|
+
const out = execFileSync("netstat", ["-ano", "-p", "TCP"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
171
|
+
const pids = new Set();
|
|
172
|
+
for (const line of out.split(/\r?\n/)) {
|
|
173
|
+
const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
|
|
174
|
+
if (m && Number(m[1]) === port) pids.add(parseInt(m[2], 10));
|
|
175
|
+
}
|
|
176
|
+
return [...pids].filter(n => n > 0);
|
|
177
|
+
} catch { return []; }
|
|
178
|
+
}
|
|
163
179
|
for (const bin of LSOF_CANDIDATES) {
|
|
164
180
|
try {
|
|
165
181
|
const out = execFileSync(bin, ["-nP", `-tiTCP:${port}`, "-sTCP:LISTEN"], {
|
|
@@ -213,13 +229,17 @@ async function stop({ timeoutMs = 5000, port = DEFAULT_PORT } = {}) {
|
|
|
213
229
|
if (p.exitCode === null) { try { p.kill("SIGKILL"); } catch {} }
|
|
214
230
|
}
|
|
215
231
|
|
|
216
|
-
// 2) Anything STILL on :port we didn't spawn — a detached
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
232
|
+
// 2) Anything STILL on :port we didn't spawn — a detached prior launch, a
|
|
233
|
+
// user-run daemon, an orphan. The homepage 停止/重启 + update() must act on
|
|
234
|
+
// the REAL listener; otherwise (no tracked child) it would no-op.
|
|
235
|
+
// Windows: the old Docker-route skip was WRONG (win is native cicy-code.exe
|
|
236
|
+
// now) — it left the old daemon alive so update() couldn't replace the exe
|
|
237
|
+
// (new binary on disk, but the OLD version kept running on :port → "更新完成"
|
|
238
|
+
// yet still old). Hard-kill cicy-code.exe by image name + free the port.
|
|
239
|
+
if (process.platform === "win32") {
|
|
240
|
+
try { execFileSync("taskkill", ["/F", "/IM", "cicy-code.exe"], { stdio: "ignore" }); } catch {}
|
|
222
241
|
}
|
|
242
|
+
await killPortListeners(port, timeoutMs);
|
|
223
243
|
}
|
|
224
244
|
|
|
225
245
|
// Remove npx's cached cicy-code installs so the next spawn re-fetches from the
|
|
@@ -256,35 +276,76 @@ async function restart({ logPath, port = DEFAULT_PORT } = {}) {
|
|
|
256
276
|
// the latest per-platform subpackage into ~/.local/bin as a NEW version-named
|
|
257
277
|
// binary, re-point cicy-code at it (re-copy on Windows), then stop + start from
|
|
258
278
|
// that stable path and health-verify.
|
|
279
|
+
let _updating = false;
|
|
280
|
+
function isUpdating() { return _updating; }
|
|
281
|
+
|
|
259
282
|
async function update({ logPath, port = DEFAULT_PORT, emit } = {}) {
|
|
260
283
|
const e = emit || (() => {});
|
|
261
284
|
const localbin = require("./localbin");
|
|
285
|
+
// Suspend the health watchdog for the duration: update() stops cicy-code, then
|
|
286
|
+
// downloads (~30s) before starting the new one — during that gap the watchdog
|
|
287
|
+
// would see the daemon "unreachable" and RESPAWN the OLD binary, racing the
|
|
288
|
+
// swap (holding the port / locking the .exe) so the new version never takes.
|
|
289
|
+
// main.js's watchdog tick checks isUpdating() and skips while this is true.
|
|
290
|
+
_updating = true;
|
|
262
291
|
try {
|
|
292
|
+
// 主人令:更新 = 杀干净 cicy-code.exe → 起 cicy-code.exe → 探活 → 拿运行中真实
|
|
293
|
+
// version → 再判定"已是最新"。绝不凭磁盘 manifest 直接喊"已是最新"——manifest
|
|
294
|
+
// 可能比运行中的进程超前,甚至 daemon 根本没起。唯一可信的是运行中 /api/health
|
|
295
|
+
// 报的版本。所以这个流程对"已是最新"和"要升级"两种情况一视同仁:总是重启 + 验证。
|
|
263
296
|
e({ phase: "download", status: "running", message: "检查最新版本…" });
|
|
264
|
-
const cur = localbin.currentVersion();
|
|
265
297
|
const latest = await localbin.latestVersion();
|
|
266
298
|
if (!latest) throw new Error("无法获取最新版本号");
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
await localbin.fetchToLocalBin(latest, { emit }); // download → ~/.local/bin → re-link
|
|
273
|
-
e({ phase: "swap", status: "running", message: `切换到 ${latest},启动…` });
|
|
299
|
+
const cur = localbin.currentVersion(); // 磁盘 manifest:只用来决定要不要下载
|
|
300
|
+
const needDownload = !cur || localbin.cmpVer(latest, cur) > 0;
|
|
301
|
+
|
|
302
|
+
// 1) 杀干净
|
|
303
|
+
e({ phase: "swap", status: "running", message: "停止 cicy-code…" });
|
|
274
304
|
await stop({ port });
|
|
275
|
-
await new Promise(r => setTimeout(r,
|
|
305
|
+
await new Promise(r => setTimeout(r, 400));
|
|
306
|
+
|
|
307
|
+
// 2) 落后才下载(此时 cicy-code.exe 已死,Windows 也能覆盖)
|
|
308
|
+
if (needDownload) {
|
|
309
|
+
e({ phase: "download", status: "running", message: `下载 ${latest}…` });
|
|
310
|
+
await localbin.fetchToLocalBin(latest, { emit });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 3) 起
|
|
314
|
+
e({ phase: "swap", status: "running", message: "启动 cicy-code…" });
|
|
276
315
|
const c = await start({ logPath, port, force: true });
|
|
316
|
+
|
|
317
|
+
// 4) 探活:等 TCP 监听起来
|
|
318
|
+
let up = false;
|
|
277
319
|
for (let i = 0; i < 120; i++) {
|
|
278
|
-
if (await probeExisting(port)) {
|
|
320
|
+
if (await probeExisting(port)) { up = true; break; }
|
|
279
321
|
await new Promise(r => setTimeout(r, 500));
|
|
280
322
|
}
|
|
281
|
-
e({ phase: "done", status: "error", message: "
|
|
323
|
+
if (!up) { e({ phase: "done", status: "error", message: "cicy-code 未在 60s 内启动" }); return c; }
|
|
324
|
+
|
|
325
|
+
// 5) 拿运行中真实 version(唯一来源 version.running();可能略慢于 TCP,重试几次)
|
|
326
|
+
const version = require("./version");
|
|
327
|
+
let running = "";
|
|
328
|
+
for (let i = 0; i < 20 && !running; i++) {
|
|
329
|
+
running = await version.running(port);
|
|
330
|
+
if (!running) await new Promise(r => setTimeout(r, 500));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 6) 以运行中真实版本判定——不撒谎
|
|
334
|
+
if (running && localbin.cmpVer(running, latest) >= 0) {
|
|
335
|
+
e({ phase: "done", status: "done", message: `已是最新 ${running}` });
|
|
336
|
+
} else if (running) {
|
|
337
|
+
e({ phase: "done", status: "done", message: `已更新到 ${running}` });
|
|
338
|
+
} else {
|
|
339
|
+
e({ phase: "done", status: "done", message: `已启动(版本未知,期望 ${latest})` });
|
|
340
|
+
}
|
|
282
341
|
return c;
|
|
283
342
|
} catch (err) {
|
|
284
343
|
console.warn(`[cicy-code-sidecar] update failed: ${err.message}`);
|
|
285
344
|
e({ phase: "done", status: "error", message: `更新失败:${err.message}` });
|
|
286
345
|
return null;
|
|
346
|
+
} finally {
|
|
347
|
+
_updating = false;
|
|
287
348
|
}
|
|
288
349
|
}
|
|
289
350
|
|
|
290
|
-
module.exports = { start, stop, restart, update, probeExisting, clearNpxCache };
|
|
351
|
+
module.exports = { start, stop, restart, update, probeExisting, clearNpxCache, isUpdating };
|
package/src/sidecar/localbin.js
CHANGED
|
@@ -133,9 +133,26 @@ async function latestVersion(name = DEFAULT) {
|
|
|
133
133
|
function linkTo(name, verBinPath, ver) {
|
|
134
134
|
const link = linkFor(name);
|
|
135
135
|
fs.mkdirSync(LOCAL_BIN, { recursive: true });
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
if (IS_WIN) {
|
|
137
|
+
// Windows can't DELETE or OVERWRITE a RUNNING .exe (EPERM/EBUSY) — which is
|
|
138
|
+
// exactly the update case, since cicy-code.exe is live when we re-link. But
|
|
139
|
+
// Windows CAN *rename* a running exe: move the in-use link aside (unique name
|
|
140
|
+
// so it never collides with an older still-running one), then copy the new
|
|
141
|
+
// binary into place. The old process keeps running from the renamed file; the
|
|
142
|
+
// next start picks up the new one. (Stale .old-* are harmless; best-effort swept.)
|
|
143
|
+
try {
|
|
144
|
+
if (fs.existsSync(link)) fs.renameSync(link, `${link}.old-${Date.now()}`);
|
|
145
|
+
} catch {}
|
|
146
|
+
fs.copyFileSync(verBinPath, link);
|
|
147
|
+
try {
|
|
148
|
+
for (const f of fs.readdirSync(LOCAL_BIN)) {
|
|
149
|
+
if (f.startsWith(`${BIN}.old-`)) { try { fs.rmSync(path.join(LOCAL_BIN, f), { force: true }); } catch {} }
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
} else {
|
|
153
|
+
try { fs.rmSync(link, { force: true }); } catch {}
|
|
154
|
+
fs.symlinkSync(verBinPath, link);
|
|
155
|
+
}
|
|
139
156
|
if (ver) writeManifest(name, ver);
|
|
140
157
|
return link;
|
|
141
158
|
}
|
package/src/sidecar/native.js
CHANGED
|
@@ -140,9 +140,9 @@ async function start({ port = 8008, logPath = null, emit, version = null } = {})
|
|
|
140
140
|
PORT: String(port),
|
|
141
141
|
CICY_CODE_PORT: String(port),
|
|
142
142
|
};
|
|
143
|
-
// --helper
|
|
144
|
-
//
|
|
145
|
-
const child = spawn(exe, [
|
|
143
|
+
// --helper removed (主人指令): boot cicy-code in normal mode (full tmux-based
|
|
144
|
+
// multi-agent), not the single headless 团队助手.
|
|
145
|
+
const child = spawn(exe, [], { stdio, detached: true, windowsHide: true, env });
|
|
146
146
|
child.unref();
|
|
147
147
|
try { fs.writeFileSync(PID_FILE, String(child.pid)); } catch {}
|
|
148
148
|
console.log(`[native-sidecar] spawned ${exe} pid=${child.pid} port=${port} log=${logPath || "(none)"}`);
|