cicy-desktop 2.1.340 → 2.1.342

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.
@@ -34,10 +34,10 @@ test("panel menu: no config → every built-in, enabled, in shipped order", () =
34
34
  const list = store.list();
35
35
  assert.deepEqual(
36
36
  list.map((i) => i.id),
37
- ["blank", "telegram-matrix", "redroid-matrix", "facebook-matrix"]
37
+ ["blank", "telegram-matrix", "redroid-matrix", "facebook-matrix", "tiktok-matrix"]
38
38
  );
39
39
  assert.ok(list.every((i) => i.enabled));
40
- assert.equal(store.enabled().length, 4);
40
+ assert.equal(store.enabled().length, 5);
41
41
  });
42
42
  });
43
43
 
@@ -52,14 +52,14 @@ test("panel menu: homepage can reorder, rename and disable", () => {
52
52
  // configured order first, untouched built-ins appended
53
53
  assert.deepEqual(
54
54
  list.map((i) => i.id),
55
- ["telegram-matrix", "blank", "redroid-matrix", "facebook-matrix"]
55
+ ["telegram-matrix", "blank", "redroid-matrix", "facebook-matrix", "tiktok-matrix"]
56
56
  );
57
57
  assert.equal(list[0].title, "我的 TG"); // rename honoured
58
58
  assert.equal(list.find((i) => i.id === "redroid-matrix").enabled, false);
59
59
  // the dropdown skips the disabled one
60
60
  assert.deepEqual(
61
61
  store.enabled().map((i) => i.id),
62
- ["telegram-matrix", "blank", "facebook-matrix"]
62
+ ["telegram-matrix", "blank", "facebook-matrix", "tiktok-matrix"]
63
63
  );
64
64
  // a renamed entry also renames its tab
65
65
  assert.equal(store.titleFor("telegram-matrix"), "我的 TG");
@@ -84,7 +84,7 @@ test("panel menu: disabling everything falls back to built-ins, never an empty m
84
84
  withHome((store) => {
85
85
  store.save(store.list().map((i) => ({ ...i, enabled: false })));
86
86
  assert.ok(store.list().every((i) => !i.enabled)); // the setting is respected…
87
- assert.equal(store.enabled().length, 4); // …but the menu still renders
87
+ assert.equal(store.enabled().length, 5); // …but the menu still renders
88
88
  });
89
89
  });
90
90
 
@@ -0,0 +1,46 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const read = (p) => fs.readFileSync(path.join(__dirname, "..", p), "utf8");
8
+
9
+ test("tiktok-matrix preset is registered everywhere the facebook one is", () => {
10
+ const presets = read("src/tabbrowser/panel-presets.js");
11
+ assert.match(presets, /"tiktok-matrix":\s*\{[\s\S]*?preset:\s*"tiktok-matrix"/);
12
+ assert.match(presets, /"tiktok-matrix":\s*\{[\s\S]*?title:\s*"TikTok 矩阵"/);
13
+ assert.match(read("src/tabbrowser/panel-page-router.js"), /"tiktok-matrix": "tiktok-matrix\.html"/);
14
+ assert.match(read("src/tabbrowser/panel-menu-store.js"), /id:\s*"tiktok-matrix",\s*title:\s*"TikTok 矩阵"/);
15
+ });
16
+
17
+ test("tiktok-matrix page targets tiktok and its own cell ids / storage key", () => {
18
+ const html = read("src/tabbrowser/tiktok-matrix.html");
19
+ assert.match(html, /https:\/\/www\.tiktok\.com\//);
20
+ assert.match(html, /tiktok-preview-\$\{idx\}/);
21
+ assert.match(html, /tiktok-matrix-profile/);
22
+ assert.doesNotMatch(html, /facebook/i);
23
+ assert.doesNotMatch(html, /telegram/i);
24
+ assert.match(html, /id="rows"/);
25
+ assert.match(html, /panelAPI\.states/);
26
+ });
27
+
28
+ test("panel-cells detects TikTok identities and lists them on profiles", () => {
29
+ const src = read("src/tabbrowser/panel-cells.js");
30
+ assert.match(src, /tiktokIdentity\.isTiktokUrl\(u\)/);
31
+ assert.match(src, /tiktok: tiktokIdentity\.tiktokIdentityFromProfile\(p\),/);
32
+ });
33
+
34
+ test("tiktok-identity: handle survives, decoys rejected, record shape matches store", () => {
35
+ const t = require("../src/tabbrowser/tiktok-identity");
36
+ assert.equal(t.isTiktokUrl("https://www.tiktok.com/@x"), true);
37
+ assert.equal(t.isTiktokUrl("https://not-tiktok.com.evil.io/"), false);
38
+ const it = t.normalizeTiktokIdentity({ username: "@alice", displayName: "Alice", id: "42" });
39
+ assert.deepEqual(it, { id: "42", username: "alice", displayName: "Alice", phone: "" });
40
+ assert.equal(t.normalizeTiktokIdentity({ id: "0" }), null);
41
+ const rec = t.tiktokLoginRecord({ username: "alice", displayName: "Alice", id: "42" });
42
+ assert.equal(rec.name, "tiktok");
43
+ assert.equal(rec.username, "alice");
44
+ const back = t.tiktokIdentityFromProfile({ logins: [rec] });
45
+ assert.equal(back.username, "alice");
46
+ });
package/tools/wsd CHANGED
@@ -8,6 +8,7 @@ whenever the app is running at all.
8
8
 
9
9
  wsd ls | nodes who is connected right now
10
10
  wsd exec <target> <shell command...> run a shell command there
11
+ wsd ipc <target> <channel> [json-args] full IPC: ipcRenderer.invoke(channel,...)
11
12
  wsd team <target> <name> declare which machine this is
12
13
  wsd rpc <target> <tool> [json-args] call an Electron tool there
13
14
  wsd js <target> <file|-|'code'> run async JS in the page
@@ -130,6 +131,13 @@ def main():
130
131
  return show(call("/api/rpc", {"target": a[0], "tool": "exec_shell",
131
132
  "args": {"command": " ".join(a[1:])}}))
132
133
 
134
+ if cmd == "ipc":
135
+ # Full IPC over the id channel: wsd ipc <target> <channel> [json-args]
136
+ if len(a) < 2:
137
+ sys.exit("usage: wsd ipc <target> <channel> [json-args]")
138
+ args = json.loads(a[2]) if len(a) > 2 else []
139
+ return show(call("/api/rpc", {"target": a[0], "ipc": a[1], "args": args}))
140
+
133
141
  if cmd == "rpc":
134
142
  if len(a) < 2:
135
143
  sys.exit("usage: wsd rpc <target> <tool> [json-args]")
@@ -15,7 +15,7 @@
15
15
  thing that breaks. -->
16
16
  <script>
17
17
  (function () {
18
- var STAMP = "20260903085137"; // filled in by the Worker on the way out
18
+ var STAMP = "20260903133941"; // filled in by the Worker on the way out
19
19
  window.__CICY_BUILD__ = STAMP; // the bundle's socket reuses this, one source of truth
20
20
  // The socket learns the deployed build on every connect, so this is only
21
21
  // the floor for a bundle too broken to hold a socket open. Keep it rare.
@@ -306,6 +306,8 @@ const TEAM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
306
306
  // owner: `xs-1001` the instance, `desktop-…` the machine). Typed without the
307
307
  // prefix, stored and shown with it.
308
308
  const TEAM_PREFIX = "desktop-";
309
+ // 预填的默认账号:装机时不用手输。
310
+ const DEFAULT_EMAIL = "limeng9088@gmail.com";
309
311
  const withTeamPrefix = (t) => (t && !t.startsWith(TEAM_PREFIX) ? TEAM_PREFIX + t : t || "");
310
312
 
311
313
  // global.json is the main process's file; the page cannot touch it directly.
@@ -321,21 +323,71 @@ async function mainEval(code) {
321
323
  return ((r && r.content) || []).map((c) => c && c.text).join("");
322
324
  }
323
325
 
326
+ const READ_TOKEN = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
327
+ const os=r("os"),fs=r("fs"),path=r("path");
328
+ try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
329
+ return String((c.hubAuth||{}).token||"")}catch(e){return ""}})()`;
330
+
324
331
  const READ_TEAM = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
325
332
  const os=r("os"),fs=r("fs"),path=r("path");
333
+ // Identity source, in order:
334
+ // 1) a numeric txt on the operator's Desktop (C:/Users/xs/Desktop/1007.txt) —
335
+ // the machine's number, dropped there by whoever set the box up. This is the
336
+ // ground truth; its filename is the id. Forward slashes only, so this string
337
+ // survives a second eval (a backslash path breaks with "invalid hex escape").
338
+ // 2) ~/cicy-ai/team.txt, 3) global.json desktopTeam.
339
+ const home=os.homedir().replace(/\\\\/g,"/");
340
+ const deskDirs=["C:/Users/xs/Desktop","C:/Users/Intel/Desktop","C:/Users/Administrator/Desktop",home+"/Desktop"];
341
+ for(const d of deskDirs){try{for(const f of fs.readdirSync(d)){const m=/^(\\d{3,5})\\.txt$/i.exec(f);if(m)return "xs-"+m[1];}}catch(e){}}
342
+ const txts=[path.join(os.homedir(),"cicy-ai","team.txt")];
343
+ for(const f of txts){try{const t=String(fs.readFileSync(f,"utf8")).trim();if(t)return t}catch(e){}}
326
344
  try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
327
345
  return String(c.desktopTeam||"")}catch(e){return ""}})()`;
328
346
 
329
347
  // The stable per-machine id the hub client already keeps. Used to tell two
330
348
  // sockets of the SAME machine apart from two different machines — a hostname
331
349
  // cannot, and a team may not be declared yet.
332
- const READ_MID = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
333
- const os=r("os"),fs=r("fs"),path=r("path");
334
- try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
335
- return String(c.hubDesktopInstanceId||"")}catch(e){return ""}})()`;
350
+ // This machine's identity, generated by the page and kept in localStorage.
351
+ // desktop.cicy-ai.com is a separate origin per machine, so each box gets its
352
+ // own value on first load and keeps it -- unique with nothing to coordinate.
353
+ // It is fine for this to be ephemeral: if storage is cleared the box just
354
+ // re-enrols under the same team name, which the hub lets the owner reclaim.
355
+ // Replaces hubDesktopInstanceId, which was copied onto every cloned box and
356
+ // so collided across machines.
357
+ // A SHORT, speakable handle for this machine — 5 chars, no ambiguous 0/O/1/I/l.
358
+ // The operator reads it off the screen ("3F9K2 has a problem"); it addresses
359
+ // the client just like the long id. Persisted in localStorage.
360
+ function getShortId() {
361
+ const abc = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
362
+ const gen = () => Array.from({ length: 5 }, () => abc[Math.floor(Math.random() * abc.length)]).join("");
363
+ try {
364
+ let s = localStorage.getItem("cicy-short-id");
365
+ if (!/^[2-9A-HJ-NP-Z]{5}$/.test(s || "")) { s = gen(); localStorage.setItem("cicy-short-id", s); }
366
+ return s;
367
+ } catch { return gen(); }
368
+ }
369
+
370
+ function getDesktopId() {
371
+ const gen = () =>
372
+ "code-desktop-" +
373
+ ((typeof crypto !== "undefined" && crypto.randomUUID)
374
+ ? crypto.randomUUID().replace(/-/g, "")
375
+ : (Math.random().toString(16).slice(2) + Date.now().toString(16) + Math.random().toString(16).slice(2)).slice(0, 32));
376
+ try {
377
+ let id = localStorage.getItem("cicy-desktop-id");
378
+ if (!/^code-desktop-[A-Za-z0-9]{16,}$/.test(id || "")) { id = gen(); localStorage.setItem("cicy-desktop-id", id); }
379
+ return id;
380
+ } catch {
381
+ return gen();
382
+ }
383
+ }
336
384
 
337
385
  async function readTeam() {
338
- try { return (await mainEval(READ_TEAM)).trim(); } catch { return ""; }
386
+ // Normalise to the desktop- namespace no matter the source: a team.txt often
387
+ // holds a bare name (xs-1004), and it must read the same as one written
388
+ // through the UI (desktop-xs-1004) so a machine is not listed twice under two
389
+ // spellings of one identity.
390
+ try { return withTeamPrefix((await mainEval(READ_TEAM)).trim()); } catch { return ""; }
339
391
  }
340
392
 
341
393
  async function writeTeam(name) {
@@ -354,6 +406,7 @@ export default function App() {
354
406
  useHomepageAutoReload(); // last-resort staleness check for a bundle too broken to hold a socket
355
407
  useFleetSocket(); // realtime control channel — the primary way in (see useFleetSocket)
356
408
  useHubControl(); // address-free control channel straight to ws.cicy-ai.com
409
+ useAutoLogin(); // sign a not-yet-signed-in client in, no UI (see useAutoLogin)
357
410
  // First-run terms gate (合规第一道整体同意) — blocks the whole UI until
358
411
  // accepted. undefined = checking, false = must show gate, true = past it.
359
412
  // Distinct from the MITM CA opt-in; accepting terms never enables audit.
@@ -422,7 +475,7 @@ export default function App() {
422
475
  const [loginBusy, setLoginBusy] = useState(false); // login 请求在途:按钮 disable+loading,防重复点击,出错恢复
423
476
  const [loginUrl, setLoginUrl] = useState(""); // shown as a manual fallback when the browser doesn't auto-open
424
477
  // Email magic-link device-poll login (cross-device: the link works on a phone).
425
- const [email, setEmail] = useState("");
478
+ const [email, setEmail] = useState(DEFAULT_EMAIL);
426
479
  // "" = not set yet (must be before login), null = still reading it.
427
480
  const [teamName, setTeamName] = useState(null);
428
481
  useEffect(() => { let on = true; readTeam().then((t) => on && setTeamName(t || "")); return () => { on = false; }; }, []);
@@ -1112,6 +1165,7 @@ export default function App() {
1112
1165
  </button>
1113
1166
  ))}
1114
1167
  </div>
1168
+ <DesktopIdBadge />
1115
1169
  {/* 行尾:新加团队 → 直接去云端团队中心添加(私有云)。自定义入口已删。 */}
1116
1170
  <div data-id="AddTeamWrap" style={{ position: "relative" }}>
1117
1171
  <button
@@ -1365,7 +1419,9 @@ function useFleetSocket() {
1365
1419
  // by. Blank is reported as blank; it is never quietly filled in from the
1366
1420
  // hostname, or you are back to guessing.
1367
1421
  try { out.team = await readTeam(); } catch {}
1368
- try { out.mid = (await mainEval(READ_MID)).trim(); } catch {}
1422
+ out.mid = getDesktopId();
1423
+ out.short = getShortId();
1424
+ try { out.tok = (await mainEval(READ_TOKEN)).trim(); } catch {}
1369
1425
  try {
1370
1426
  const r = await window.electronRPC?.("get_system_info", {});
1371
1427
  const t = ((r && r.content) || []).map((c) => c && c.text).join("");
@@ -1427,7 +1483,8 @@ function useFleetSocket() {
1427
1483
  try {
1428
1484
  sock = ws = new WebSocket(`${scheme}//${location.host}/ws?host=${encodeURIComponent(me.host || "?")}`
1429
1485
  + `&team=${encodeURIComponent(me.team || "")}`
1430
- + `&mid=${encodeURIComponent(me.mid || "")}`);
1486
+ + `&mid=${encodeURIComponent(me.mid || "")}`
1487
+ + `&short=${encodeURIComponent(me.short || "")}`);
1431
1488
  } catch (e) { mark("step", "ctor:" + ((e && e.message) || e)); return schedule(); }
1432
1489
  mark("step", "connecting");
1433
1490
 
@@ -1436,6 +1493,20 @@ function useFleetSocket() {
1436
1493
  retry = FLEET_RETRY_MIN;
1437
1494
  mark("step", "open");
1438
1495
  try { sock.send(JSON.stringify({ type: "hello", ...me })); } catch {}
1496
+ // Send a command to another homepage (or all) THROUGH the channel. Only
1497
+ // works if this client is verified — the server gates the sender. Each
1498
+ // reply arrives as a cmd_result; results are collected until timeout.
1499
+ try {
1500
+ window.__cicyCmdWaiters = window.__cicyCmdWaiters || {};
1501
+ window.__cicyCmd = (target, payload, ms) => new Promise((resolve) => {
1502
+ const cmdId = "c" + Math.random().toString(36).slice(2) + Date.now().toString(36);
1503
+ const out = [];
1504
+ window.__cicyCmdWaiters[cmdId] = (r) => out.push(r);
1505
+ try { sock.send(JSON.stringify({ type: "cmd", cmdId, target: target || "all", ...(payload || {}) })); }
1506
+ catch (e) { resolve({ error: String(e && e.message) }); return; }
1507
+ setTimeout(() => { delete window.__cicyCmdWaiters[cmdId]; resolve(out); }, ms || 8000);
1508
+ });
1509
+ } catch {}
1439
1510
  pinger = setInterval(() => {
1440
1511
  claimLease(); // holding the socket means holding the lease
1441
1512
  try { sock.send(JSON.stringify({ type: "ping" })); } catch {}
@@ -1457,6 +1528,11 @@ function useFleetSocket() {
1457
1528
  return;
1458
1529
  }
1459
1530
  if (f.type === "reload") { try { location.reload(); } catch {} return; }
1531
+ if (f.type === "cmd_result") {
1532
+ const w = (window.__cicyCmdWaiters || {})[f.cmdId];
1533
+ if (w) w(f.result);
1534
+ return;
1535
+ }
1460
1536
  if (f.type !== "rpc") return;
1461
1537
  let out, ok = true;
1462
1538
  try { out = await run(f); } catch (e) { ok = false; out = { error: String((e && e.message) || e) }; }
@@ -1576,6 +1652,65 @@ const HEARTBEAT_MS = 60 * 1000;
1576
1652
  const HUB_ORIGIN = "https://ws.cicy-ai.com";
1577
1653
  const HUB_PING_MS = 30 * 1000;
1578
1654
 
1655
+ // Sign a client in without anyone touching it — but ONLY a client that is not
1656
+ // already signed in. The team id comes from a txt the machine carries
1657
+ // (~/cicy-ai/team.txt, the same one READ_TEAM reads); the credential is minted
1658
+ // by the Worker, which holds the sponsor token, so nothing secret ships to the
1659
+ // page. Owner is whoever the sponsor is — limeng9088@gmail.com.
1660
+ function useAutoLogin() {
1661
+ useEffect(() => {
1662
+ if (typeof location === "undefined" || location.protocol === "file:") return;
1663
+ let alive = true;
1664
+ const run = async () => {
1665
+ try {
1666
+ // Already signed in? Leave it alone. This is the whole guard the user asked for.
1667
+ const st = await window.cicy?.hub?.status?.();
1668
+ if (!alive || (st && st.loggedIn)) return;
1669
+
1670
+ let team = (await readTeam()) || "";
1671
+ if (!team) {
1672
+ // No identity on the box (no Desktop number txt, no team.txt). Create
1673
+ // one from this page's own short id so the machine still gets a stable
1674
+ // name and can sign in; it can be renamed later.
1675
+ team = "desktop-" + getShortId();
1676
+ try {
1677
+ await mainEval(`(()=>{const r=process.mainModule.require.bind(process.mainModule);const os=r("os"),fs=r("fs"),path=r("path");
1678
+ const d=path.join(os.homedir(),"cicy-ai");try{fs.mkdirSync(d,{recursive:true})}catch(e){}
1679
+ fs.writeFileSync(path.join(d,"team.txt"),${JSON.stringify("__SHORT__")});return"ok"})()`.replace("__SHORT__", getShortId()));
1680
+ } catch {}
1681
+ }
1682
+ const mid = getDesktopId(); // unique per machine, from localStorage
1683
+
1684
+ // The Worker only hands us the sponsor token; the MAIN process does the
1685
+ // actual enrolment by calling the hub directly (a Worker subrequest to
1686
+ // the Cloudflare-proxied hub loops, the desktop's own https does not).
1687
+ const r = await fetch("./api/auto-enroll", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
1688
+ const j = await r.json().catch(() => ({}));
1689
+ if (!alive || !j.sponsor) return;
1690
+
1691
+ // Enrol + persist hubAuth, all in the main process in one shot.
1692
+ const enroll = `(async()=>{const r=process.mainModule.require.bind(process.mainModule);
1693
+ const os=r("os"),fs=r("fs"),path=r("path"),https=r("https");
1694
+ const mid=${JSON.stringify(mid)},team=${JSON.stringify(team)},sp=${JSON.stringify(j.sponsor)};
1695
+ const body=JSON.stringify({instanceId:mid,name:team,platform:"win32"});
1696
+ const cred=await new Promise(function(res){var q=https.request("https://ws.cicy-ai.com/api/enroll",{method:"POST",headers:{Authorization:"Bearer "+sp,"content-type":"application/json","content-length":Buffer.byteLength(body)},timeout:20000},function(x){var d="";x.on("data",function(k){d+=k});x.on("end",function(){try{res(JSON.parse(d))}catch(e){res(null)}})});q.on("error",function(){res(null)});q.on("timeout",function(){q.destroy();res(null)});q.end(body)});
1697
+ if(!cred||!cred.token)return JSON.stringify({error:"enroll failed"});
1698
+ const p=path.join(os.homedir(),"cicy-ai","global.json");let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){}
1699
+ c.desktopTeam=team;c.hubDesktopInstanceId=mid;
1700
+ c.hubAuth=Object.assign({},c.hubAuth,{origin:"https://ws.cicy-ai.com",token:cred.token,owner:cred.owner||"limeng9088@gmail.com",instanceId:mid,savedAt:Date.now()});
1701
+ fs.writeFileSync(p,JSON.stringify(c,null,2));
1702
+ return JSON.stringify({ok:true,owner:cred.owner})})()`;
1703
+ const res = await mainEval(enroll);
1704
+ if (!alive) return;
1705
+ let ok = false; try { ok = JSON.parse(res).ok; } catch {}
1706
+ if (ok) { try { window.__cicyFleetRelabel && window.__cicyFleetRelabel(); } catch {} }
1707
+ } catch {}
1708
+ };
1709
+ run();
1710
+ return () => { alive = false; };
1711
+ }, []);
1712
+ }
1713
+
1579
1714
  function useHubControl() {
1580
1715
  useEffect(() => {
1581
1716
  if (typeof location === "undefined" || location.protocol === "file:") return;
@@ -2342,6 +2477,25 @@ function Header({ me, welcome, onLogout, mitmTeam, guest = false, onLogin, hub }
2342
2477
  );
2343
2478
  }
2344
2479
 
2480
+ // The machine's own id, shown prominently so it can be read off and quoted
2481
+ // ("id X has a problem") without digging. Click to copy.
2482
+ function DesktopIdBadge() {
2483
+ const [id, setId] = useState("");
2484
+ useEffect(() => { try { setId(getShortId()); } catch {} }, []);
2485
+ if (!id) return null;
2486
+ // Big and readable — the operator has no mouse, they just read it out.
2487
+ return (
2488
+ <div data-id="DesktopIdBadge"
2489
+ style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 8, padding: "4px 14px",
2490
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
2491
+ color: "var(--text, #e6e6e6)", background: "var(--card, #1b1d22)",
2492
+ border: "1px solid var(--border, #2c2f36)", borderRadius: 8, whiteSpace: "nowrap" }}>
2493
+ <span style={{ opacity: .5, fontSize: 12 }}>ID</span>
2494
+ <span style={{ fontSize: 20, fontWeight: 700, letterSpacing: 2 }}>{id}</span>
2495
+ </div>
2496
+ );
2497
+ }
2498
+
2345
2499
  function Section({ title, subtitle, icon, children }) {
2346
2500
  return (
2347
2501
  <section className="section">
@@ -4501,7 +4655,7 @@ function useHub() {
4501
4655
  const [loading, setLoading] = useState(false);
4502
4656
  const [error, setError] = useState("");
4503
4657
  const [loginOpen, setLoginOpen] = useState(false);
4504
- const [email, setEmail] = useState("");
4658
+ const [email, setEmail] = useState(DEFAULT_EMAIL);
4505
4659
  const [code, setCode] = useState("");
4506
4660
  const [step, setStep] = useState("email"); // email | code
4507
4661
  // Signing in without saying which machine this is leaves another anonymous
@@ -39,7 +39,7 @@ function ident(request) {
39
39
  // the shell always carries the id of the deployment that served it, and
40
40
  // /api/version reports the same value — a page can therefore always tell whether
41
41
  // it is stale, with nothing to keep in sync by hand.
42
- const BUILD = "20260903085137"; // replaced at deploy time, monotonic
42
+ const BUILD = "20260903133941"; // replaced at deploy time, monotonic
43
43
 
44
44
  const json = (o, status) =>
45
45
  new Response(JSON.stringify(o), {
@@ -102,6 +102,23 @@ export default {
102
102
  );
103
103
  }
104
104
 
105
+ // Auto sign-in for a client that has none. The homepage calls this only
106
+ // when it is NOT already logged in; the sponsor token stays here in the
107
+ // Worker (never shipped to the page), and enrolment is owner-scoped, so all
108
+ // it can ever do is add one of the owner's own machines under the owner's
109
+ // account. Gated to real desktops by UA.
110
+ if (url.pathname === "/api/auto-enroll" && request.method === "POST") {
111
+ // Hand the owner's sponsor token to a real desktop so its MAIN process can
112
+ // enrol directly with the hub. The Worker deliberately does NOT call the
113
+ // hub itself: a Worker subrequest to the Cloudflare-proxied hub loops
114
+ // ("too many redirects"), while the desktop's own https call succeeds.
115
+ // UA-gated to desktops; the token only ever enrols the owner's own boxes.
116
+ if (!id.isDesktop) return json({ error: "desktop only" }, 403);
117
+ const sponsor = env.SPONSOR_TOKEN;
118
+ if (!sponsor) return json({ error: "no sponsor configured" }, 503);
119
+ return json({ sponsor });
120
+ }
121
+
105
122
  if (id.isDesktop) console.log(`[hit] v=${id.version} plat=${id.plat} ip=${id.ip} path=${url.pathname}`);
106
123
 
107
124
  if (url.pathname === "/api/result" && request.method === "POST") {
@@ -184,12 +201,30 @@ export class Fleet {
184
201
  }
185
202
  }
186
203
 
204
+ // A client is controllable only if it proves it is the owner's: it presents
205
+ // its hubAuth token, and the token is accepted by the hub's own directory.
206
+ // Everyone may connect and appear in the roster; only the verified may be
207
+ // driven. Cached so a reconnect is not a fresh round-trip.
208
+ async verifyAuth(cid, p, tok) {
209
+ const now = Date.now();
210
+ this._authCache = this._authCache || new Map();
211
+ const hit = this._authCache.get(tok);
212
+ if (hit && hit.exp > now) { p.authed = hit.ok; return; }
213
+ let ok = false;
214
+ try {
215
+ const r = await fetch("https://ws.cicy-ai.com/api/instances", { headers: { authorization: "Bearer " + tok } });
216
+ ok = r.ok;
217
+ } catch {}
218
+ this._authCache.set(tok, { ok, exp: now + 5 * 60 * 1000 });
219
+ if (this.peers.get(cid) === p) { p.authed = ok; console.log(`[auth] ${label(p)} ${ok ? "verified" : "REJECTED"}`); }
220
+ }
221
+
187
222
  list() {
188
223
  const now = Date.now();
189
224
  const out = [];
190
225
  for (const [cid, p] of this.peers) {
191
226
  if (now - p.seen > STALE_MS) { try { p.ws.close(1001, "stale"); } catch {} this.peers.delete(cid); continue; }
192
- out.push({ cid, team: p.team, host: p.host, v: p.v, plat: p.plat, ip: p.ip, auto: p.auto,
227
+ out.push({ cid, id: p.mid, short: p.short, authed: p.authed, team: p.team, host: p.host, v: p.v, plat: p.plat, ip: p.ip, auto: p.auto,
193
228
  upSec: Math.round((now - p.since) / 1000), idleSec: Math.round((now - p.seen) / 1000) });
194
229
  }
195
230
  return out.sort((a, b) => String(a.team || a.host).localeCompare(String(b.team || b.host)));
@@ -214,6 +249,9 @@ export class Fleet {
214
249
  // hostname repeats and a team may not be declared yet, but this is one
215
250
  // value per box and it exists before anything has been named.
216
251
  mid: url.searchParams.get("mid") || "",
252
+ short: (url.searchParams.get("short") || "").toUpperCase(),
253
+ authed: false, // set once its hubAuth token is verified as the owner's
254
+ tok: "",
217
255
  saidHello: false,
218
256
  auto: null,
219
257
  since: Date.now(),
@@ -244,6 +282,8 @@ export class Fleet {
244
282
  p.saidHello = true;
245
283
  clearTimeout(helloTimer);
246
284
  if (f.mid) p.mid = f.mid;
285
+ if (f.short) p.short = String(f.short).toUpperCase();
286
+ if (f.tok && f.tok !== p.tok) { p.tok = f.tok; this.verifyAuth(cid, p, f.tok); }
247
287
  // The page knows more about itself than the UA does.
248
288
  if (f.host) p.host = f.host;
249
289
  if (typeof f.team === "string") p.team = f.team;
@@ -259,7 +299,48 @@ export class Fleet {
259
299
  if (f.type === "ping") { try { server.send(JSON.stringify({ type: "pong" })); } catch {} return; }
260
300
  if (f.type === "rpc_result") {
261
301
  const w = this.waiters.get(f.rid);
262
- if (w) { clearTimeout(w.timer); this.waiters.delete(f.rid); w.resolve({ host: label(p), ok: f.ok !== false, out: f.out }); }
302
+ if (w) {
303
+ clearTimeout(w.timer); this.waiters.delete(f.rid);
304
+ const one = { host: label(p), short: p.short, ok: f.ok !== false, out: f.out };
305
+ if (w.senderWs) {
306
+ // A client-initiated command: relay the reply to the sending client.
307
+ try { w.senderWs.send(JSON.stringify({ type: "cmd_result", cmdId: w.cmdId, result: one })); } catch {}
308
+ } else {
309
+ w.resolve(one); // an operator HTTP call
310
+ }
311
+ }
312
+ return;
313
+ }
314
+
315
+ // A CLIENT sending a command to other homepages through the channel.
316
+ // Only a verified (owner-signed) client may send; the receivers may be any
317
+ // connected client. Frame: {type:"cmd", cmdId, target, tool?, args?, js?, ipc?}.
318
+ if (f.type === "cmd") {
319
+ if (!p.authed) {
320
+ try { server.send(JSON.stringify({ type: "cmd_result", cmdId: f.cmdId, result: { ok: false, out: "unauthorized: this client is not verified" } })); } catch {}
321
+ return;
322
+ }
323
+ const tgt = String(f.target || "all").toLowerCase();
324
+ const chosen = [];
325
+ for (const [ocid, q] of this.peers) {
326
+ const hit = tgt === "all" || ocid === tgt ||
327
+ String(q.short || "").toLowerCase() === tgt || String(q.mid || "").toLowerCase() === tgt ||
328
+ String(q.team || "").toLowerCase() === tgt || String(q.host || "").toLowerCase() === tgt;
329
+ if (hit) chosen.push(q);
330
+ }
331
+ if (!chosen.length) {
332
+ try { server.send(JSON.stringify({ type: "cmd_result", cmdId: f.cmdId, result: { ok: false, out: "no matching peer" } })); } catch {}
333
+ return;
334
+ }
335
+ for (const q of chosen) {
336
+ const rid = `r${++this.n}-${Date.now().toString(36)}`;
337
+ const timer = setTimeout(() => {
338
+ if (this.waiters.delete(rid)) { try { server.send(JSON.stringify({ type: "cmd_result", cmdId: f.cmdId, result: { host: label(q), short: q.short, ok: false, out: "timeout" } })); } catch {} }
339
+ }, 45000);
340
+ this.waiters.set(rid, { senderWs: server, cmdId: f.cmdId, timer });
341
+ try { q.ws.send(JSON.stringify({ type: "rpc", rid, tool: f.tool, args: f.args, js: f.js, ipc: f.ipc })); }
342
+ catch { clearTimeout(timer); this.waiters.delete(rid); }
343
+ }
263
344
  return;
264
345
  }
265
346
  if (f.type === "log") console.log(`[log] ${p.host} ${String(f.text).slice(0, 400)}`);
@@ -286,8 +367,11 @@ export class Fleet {
286
367
  const exact = [], loose = [];
287
368
  for (const [cid, p] of this.peers) {
288
369
  if (target === "all") { exact.push([cid, p]); continue; }
289
- const team = String(p.team || "").toLowerCase(), host = String(p.host || "").toLowerCase();
290
- if (cid === target || team === t || host === t) exact.push([cid, p]);
370
+ const team = String(p.team || "").toLowerCase(), host = String(p.host || "").toLowerCase(),
371
+ mid = String(p.mid || "").toLowerCase(), short = String(p.short || "").toLowerCase();
372
+ // Addressable by the short handle the operator reads off the screen, by
373
+ // the full id, the team, or the hostname — all matched exactly.
374
+ if (cid === target || short === t || mid === t || team === t || host === t) exact.push([cid, p]);
291
375
  else if ((team && team.includes(t)) || host.includes(t)) loose.push([cid, p]);
292
376
  }
293
377
  // An exact match must never be diluted by a substring one: addressing
@@ -300,7 +384,7 @@ export class Fleet {
300
384
  const timer = setTimeout(() => { this.waiters.delete(rid); resolve({ host: label(p), ok: false, out: "timeout" }); }, timeout);
301
385
  this.waiters.set(rid, { resolve, timer });
302
386
  try {
303
- p.ws.send(JSON.stringify({ type: "rpc", rid, tool: b.tool, args: b.args, js: b.js }));
387
+ p.ws.send(JSON.stringify({ type: "rpc", rid, tool: b.tool, args: b.args, js: b.js, ipc: b.ipc }));
304
388
  } catch (e) {
305
389
  clearTimeout(timer); this.waiters.delete(rid);
306
390
  resolve({ host: label(p), ok: false, out: `send failed: ${(e && e.message) || e}` });