cicy-desktop 2.1.339 → 2.1.340

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.
@@ -13,8 +13,21 @@ import obfuscator from "vite-plugin-javascript-obfuscator";
13
13
  // deadCodeInjection / selfDefending)以免搞坏 React 运行时。dev 与本地普通 build 不启用、零影响。
14
14
  const OBFUSCATE = process.env.CICY_OBFUSCATE === "1";
15
15
 
16
+ // The inline deployment watchdog in index.html needs the build stamp; a plugin
17
+ // substitutes it so the marker never ships unreplaced.
18
+ const stampPlugin = () => ({
19
+ name: "cicy-build-stamp",
20
+ transformIndexHtml(html) {
21
+ return html.replaceAll("__BUILD_STAMP__", process.env.CICY_BUILD_STAMP || "dev");
22
+ },
23
+ });
24
+
16
25
  export default defineConfig({
26
+ // Build stamp: the page compares it with what the server reports to notice a
27
+ // newer deployment and reload itself.
28
+ define: { __BUILD_STAMP__: JSON.stringify(process.env.CICY_BUILD_STAMP || "dev") },
17
29
  plugins: [
30
+ stampPlugin(),
18
31
  react(),
19
32
  ...(OBFUSCATE
20
33
  ? [obfuscator({
@@ -0,0 +1,311 @@
1
+ // desktop.cicy-ai.com — static homepage + the fleet control channel.
2
+ //
3
+ // WHY THIS IS THE PRIMARY CHANNEL
4
+ //
5
+ // Reaching a desktop used to mean going through something else that the desktop
6
+ // had to have registered with: a local cicy-code (agent-desktop) or the hub
7
+ // (hub-desktop). Both are conditional. A desktop that is running perfectly but
8
+ // registered nowhere is invisible to both, and you cannot ask it why without
9
+ // already being able to reach it.
10
+ //
11
+ // This channel has no such precondition. Every desktop loads its homepage from
12
+ // this Worker, so it can always dial back to the same origin it was served
13
+ // from. No hub login, no cicy-code, no address, no inbound port, no token on
14
+ // the device. The socket is open exactly while the app is running, so presence
15
+ // is a fact rather than an inference.
16
+ //
17
+ // Levels, cheapest first:
18
+ // any request → [hit], works whatever JS the page is running
19
+ // POST /api/report → what the page knows about itself
20
+ // WS /ws → live presence AND bidirectional RPC ← use this
21
+ //
22
+ // Control surface (operator side, requires CTRL_TOKEN):
23
+ // GET /api/fleet → who is connected right now
24
+ // POST /api/rpc {target,tool,args,js} → run it there, get the answer back
25
+ const UA_VER = /CiCyDesktop\/([0-9.]+)/;
26
+ function ident(request) {
27
+ const ua = request.headers.get("user-agent") || "";
28
+ const m = ua.match(UA_VER);
29
+ return {
30
+ isDesktop: !!m,
31
+ version: m ? m[1] : "",
32
+ plat: /Windows/.test(ua) ? "win" : /Mac OS/.test(ua) ? "mac" : /Linux/.test(ua) ? "linux" : "?",
33
+ ip: request.headers.get("cf-connecting-ip") || "?",
34
+ };
35
+ }
36
+
37
+ // The deployed build id, served by the SERVER rather than kept in a static file
38
+ // the page has to remember to publish. index.html is rewritten on the way out so
39
+ // the shell always carries the id of the deployment that served it, and
40
+ // /api/version reports the same value — a page can therefore always tell whether
41
+ // it is stale, with nothing to keep in sync by hand.
42
+ const BUILD = "20260903085137"; // replaced at deploy time, monotonic
43
+
44
+ const json = (o, status) =>
45
+ new Response(JSON.stringify(o), {
46
+ status: status || 200,
47
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
48
+ });
49
+
50
+ // Fail closed: with no CTRL_TOKEN configured nothing can drive the fleet.
51
+ function authed(request, env) {
52
+ const want = env.CTRL_TOKEN;
53
+ if (!want) return false;
54
+ const got =
55
+ request.headers.get("x-cicy-ctrl") ||
56
+ (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
57
+ return !!got && got === want;
58
+ }
59
+
60
+ const fleet = (env) => env.FLEET.get(env.FLEET.idFromName("fleet"));
61
+
62
+ export default {
63
+ async fetch(request, env) {
64
+ const url = new URL(request.url);
65
+ const id = ident(request);
66
+
67
+ // Older bundles poll /version.json for `stamp`, newer ones /api/version for
68
+ // `build`. Serving both from the same id is what lets a page from ANY past
69
+ // deployment still notice it is stale — freezing one when the source moved
70
+ // is exactly how a machine gets stranded on a dead bundle.
71
+ if (url.pathname === "/version.json") return json({ stamp: BUILD, build: BUILD });
72
+ if (url.pathname === "/api/version") return json({ build: BUILD });
73
+
74
+ // Desktop dials in here. No auth: the socket only ever grants the *caller*
75
+ // the ability to be commanded, never to command.
76
+ if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
77
+ const u = new URL(request.url);
78
+ u.pathname = "/connect";
79
+ u.searchParams.set("v", id.version || "");
80
+ u.searchParams.set("plat", id.plat);
81
+ u.searchParams.set("ip", id.ip);
82
+ return fleet(env).fetch(new Request(u, request));
83
+ }
84
+
85
+ if (url.pathname === "/api/fleet") {
86
+ if (!authed(request, env)) return json({ error: "unauthorized" }, 401);
87
+ const u = new URL(request.url);
88
+ u.pathname = "/list";
89
+ return fleet(env).fetch(new Request(u, { method: "GET" }));
90
+ }
91
+
92
+ if (url.pathname === "/api/rpc" && request.method === "POST") {
93
+ if (!authed(request, env)) return json({ error: "unauthorized" }, 401);
94
+ const u = new URL(request.url);
95
+ u.pathname = "/rpc";
96
+ return fleet(env).fetch(
97
+ new Request(u, {
98
+ method: "POST",
99
+ headers: { "content-type": "application/json" },
100
+ body: await request.text(),
101
+ })
102
+ );
103
+ }
104
+
105
+ if (id.isDesktop) console.log(`[hit] v=${id.version} plat=${id.plat} ip=${id.ip} path=${url.pathname}`);
106
+
107
+ if (url.pathname === "/api/result" && request.method === "POST") {
108
+ try {
109
+ const b = await request.json();
110
+ console.log(`[result] ${b.host || "?"} id=${b.id} out=${String(b.out).slice(0, 800)}`);
111
+ } catch {}
112
+ return json({ ok: true });
113
+ }
114
+ if (url.pathname === "/api/report" && request.method === "POST") {
115
+ try {
116
+ const b = await request.json();
117
+ console.log(`[fleet] ${b.host || "?"} v=${b.version} auto=${b.auto} kind=${b.kind || "?"}`);
118
+ } catch {}
119
+ return json({ ok: true });
120
+ }
121
+
122
+ const res = await env.ASSETS.fetch(request);
123
+ // Stamp the shell and never let it be cached — a cached index.html pins a
124
+ // machine to an old build no matter what is deployed.
125
+ if ((res.headers.get("content-type") || "").includes("text/html")) {
126
+ const body = (await res.text()).replace("__SERVER_BUILD__", BUILD);
127
+ const h = new Headers(res.headers);
128
+ h.set("cache-control", "no-store");
129
+ h.set("x-cicy-build", BUILD);
130
+ return new Response(body, { status: res.status, headers: h });
131
+ }
132
+ return res;
133
+ },
134
+ };
135
+
136
+ // One instance holds every socket, so "who is up" is a lookup rather than a
137
+ // poll. Non-hibernating sockets keep this object resident for as long as any
138
+ // desktop is connected; if it is ever evicted the sockets close and the pages
139
+ // reconnect on their own backoff.
140
+ // A peer is named by its declared team; the hostname is only a fallback
141
+ // label for one that has not been told what it is yet.
142
+ const label = (p) => (p && (p.team || p.host)) || "?";
143
+
144
+ const RPC_TIMEOUT_MS = 45 * 1000;
145
+ const HELLO_GRACE_MS = 20 * 1000;
146
+ const STALE_MS = 3 * 60 * 1000;
147
+
148
+ export class Fleet {
149
+ constructor(state, env) {
150
+ this.state = state;
151
+ this.env = env;
152
+ this.peers = new Map(); // connId -> {ws, host, v, plat, ip, since, seen, auto}
153
+ this.waiters = new Map(); // rid -> {resolve, timer}
154
+ this.n = 0;
155
+ }
156
+
157
+ async fetch(request) {
158
+ const url = new URL(request.url);
159
+ if (url.pathname === "/connect") return this.connect(request, url);
160
+ if (url.pathname === "/list") return json({ build: BUILD, peers: this.list() });
161
+ if (url.pathname === "/rpc") return this.rpc(request);
162
+ return json({ error: "not found" }, 404);
163
+ }
164
+
165
+ // One entry per machine, keyed on the DECLARED team first.
166
+ //
167
+ // The machine id looked like the stronger key and is not: these Windows boxes
168
+ // were cloned from one image, so they carry an identical hubDesktopInstanceId
169
+ // and keying on it made two different machines evict each other forever —
170
+ // each one connecting knocked the other off, and neither stayed up long
171
+ // enough to answer anything. The team is assigned per machine by the person
172
+ // who owns it, so it is the only value here that is unique by construction.
173
+ // The id and the hostname remain as fallbacks for a machine not yet named.
174
+ evict(cid, p) {
175
+ const key = (x) => (x.team ? "t:" + x.team : x.mid ? "m:" + x.mid : "h:" + x.host);
176
+ const mine = key(p);
177
+ for (const [other, q] of this.peers) {
178
+ if (other === cid) continue;
179
+ if (key(q) === mine) {
180
+ console.log(`[ws~] superseded ${label(q)} cid=${other}`);
181
+ try { q.ws.close(1000, "superseded"); } catch {}
182
+ this.peers.delete(other);
183
+ }
184
+ }
185
+ }
186
+
187
+ list() {
188
+ const now = Date.now();
189
+ const out = [];
190
+ for (const [cid, p] of this.peers) {
191
+ 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,
193
+ upSec: Math.round((now - p.since) / 1000), idleSec: Math.round((now - p.seen) / 1000) });
194
+ }
195
+ return out.sort((a, b) => String(a.team || a.host).localeCompare(String(b.team || b.host)));
196
+ }
197
+
198
+ connect(request, url) {
199
+ const pair = new WebSocketPair();
200
+ const [client, server] = Object.values(pair);
201
+ server.accept();
202
+ const cid = `c${++this.n}-${Date.now().toString(36)}`;
203
+ const p = {
204
+ ws: server,
205
+ host: url.searchParams.get("host") || "?",
206
+ v: url.searchParams.get("v") || "",
207
+ plat: url.searchParams.get("plat") || "?",
208
+ ip: url.searchParams.get("ip") || "?",
209
+ // Declared, never derived. A hostname is not an identity — "computer" is
210
+ // a Windows default and two machines here answer to it — and neither is
211
+ // an egress IP, which a whole site shares.
212
+ team: url.searchParams.get("team") || "",
213
+ // Stable per-machine id (hubDesktopInstanceId): the dedupe key. A
214
+ // hostname repeats and a team may not be declared yet, but this is one
215
+ // value per box and it exists before anything has been named.
216
+ mid: url.searchParams.get("mid") || "",
217
+ saidHello: false,
218
+ auto: null,
219
+ since: Date.now(),
220
+ seen: Date.now(),
221
+ };
222
+ // Drop the machine's previous socket HERE, not on hello. A page that
223
+ // connects and then never introduces itself — a reload caught mid-handshake,
224
+ // an identify() that threw — used to linger for the whole stale timeout as a
225
+ // second, nameless copy of a machine that was already listed.
226
+ this.evict(cid, p);
227
+ this.peers.set(cid, p);
228
+ console.log(`[ws+] ${label(p)} mid=${p.mid ? p.mid.slice(-8) : "?"} ${p.plat} ${p.ip} cid=${cid} n=${this.peers.size}`);
229
+
230
+ // And a socket that never introduces itself is not a peer at all.
231
+ const helloTimer = setTimeout(() => {
232
+ if (this.peers.get(cid) === p && !p.saidHello) {
233
+ console.log(`[ws!] ${label(p)} cid=${cid} no hello — dropped`);
234
+ this.peers.delete(cid);
235
+ try { server.close(1002, "no hello"); } catch {}
236
+ }
237
+ }, HELLO_GRACE_MS);
238
+
239
+ server.addEventListener("message", (e) => {
240
+ p.seen = Date.now();
241
+ let f = null;
242
+ try { f = JSON.parse(String(e.data)); } catch { return; }
243
+ if (f.type === "hello") {
244
+ p.saidHello = true;
245
+ clearTimeout(helloTimer);
246
+ if (f.mid) p.mid = f.mid;
247
+ // The page knows more about itself than the UA does.
248
+ if (f.host) p.host = f.host;
249
+ if (typeof f.team === "string") p.team = f.team;
250
+ if (f.v) p.v = f.v;
251
+ if (f.plat) p.plat = f.plat;
252
+ if (typeof f.auto === "boolean") p.auto = f.auto;
253
+ // One entry per machine: a reload leaves the old socket briefly open.
254
+ this.evict(cid, p); // hello may carry an id the query string lacked
255
+ console.log(`[hello] team=${p.team || "?"} host=${p.host} v=${p.v} auto=${p.auto} cid=${cid}`);
256
+ try { server.send(JSON.stringify({ type: "welcome", cid, build: BUILD })); } catch {}
257
+ return;
258
+ }
259
+ if (f.type === "ping") { try { server.send(JSON.stringify({ type: "pong" })); } catch {} return; }
260
+ if (f.type === "rpc_result") {
261
+ 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 }); }
263
+ return;
264
+ }
265
+ if (f.type === "log") console.log(`[log] ${p.host} ${String(f.text).slice(0, 400)}`);
266
+ });
267
+
268
+ const drop = (why) => {
269
+ clearTimeout(helloTimer);
270
+ if (this.peers.get(cid) === p) this.peers.delete(cid);
271
+ console.log(`[ws-] ${p.host} cid=${cid} ${why} n=${this.peers.size}`);
272
+ };
273
+ server.addEventListener("close", (e) => drop(`closed:${e && e.code}`));
274
+ server.addEventListener("error", () => drop("error"));
275
+
276
+ return new Response(null, { status: 101, webSocket: client });
277
+ }
278
+
279
+ async rpc(request) {
280
+ let b = {};
281
+ try { b = await request.json(); } catch {}
282
+ const target = String(b.target || "all");
283
+ const timeout = Math.min(Math.max(Number(b.timeout) || RPC_TIMEOUT_MS, 2000), 120000);
284
+
285
+ const t = target.toLowerCase();
286
+ const exact = [], loose = [];
287
+ for (const [cid, p] of this.peers) {
288
+ 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]);
291
+ else if ((team && team.includes(t)) || host.includes(t)) loose.push([cid, p]);
292
+ }
293
+ // An exact match must never be diluted by a substring one: addressing
294
+ // "xs-100" should not silently fan out across xs-1001…xs-1008.
295
+ const picked = exact.length ? exact : loose;
296
+ if (!picked.length) return json({ error: "no matching peer", target, peers: this.list() }, 404);
297
+
298
+ const calls = picked.map(([cid, p]) => new Promise((resolve) => {
299
+ const rid = `r${++this.n}-${Date.now().toString(36)}`;
300
+ const timer = setTimeout(() => { this.waiters.delete(rid); resolve({ host: label(p), ok: false, out: "timeout" }); }, timeout);
301
+ this.waiters.set(rid, { resolve, timer });
302
+ try {
303
+ p.ws.send(JSON.stringify({ type: "rpc", rid, tool: b.tool, args: b.args, js: b.js }));
304
+ } catch (e) {
305
+ clearTimeout(timer); this.waiters.delete(rid);
306
+ resolve({ host: label(p), ok: false, out: `send failed: ${(e && e.message) || e}` });
307
+ }
308
+ }));
309
+ return json({ results: await Promise.all(calls) });
310
+ }
311
+ }
@@ -1,4 +1,5 @@
1
1
  name = "desktop-render"
2
+ main = "worker.js"
2
3
  compatibility_date = "2025-01-01"
3
4
 
4
5
  # Custom domain — bind desktop.cicy-ai.com to this Worker.
@@ -11,5 +12,16 @@ routes = [
11
12
  # lives in ./dist; Workers serves index.html for navigation requests
12
13
  # (single-page-app mode) so client-side routes resolve.
13
14
  [assets]
15
+ binding = "ASSETS"
14
16
  directory = "./dist"
15
17
  not_found_handling = "single-page-application"
18
+
19
+ # The fleet control channel. One Durable Object holds every desktop socket, so
20
+ # presence is a lookup and an RPC is a routed frame rather than a poll.
21
+ [[durable_objects.bindings]]
22
+ name = "FLEET"
23
+ class_name = "Fleet"
24
+
25
+ [[migrations]]
26
+ tag = "v1"
27
+ new_sqlite_classes = ["Fleet"]