magpie-agent-console 1.2.0 → 1.3.1
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/README.md +28 -8
- package/app/app.css +1 -1
- package/app/app.js +89 -67
- package/daemon/dist/main.js +937 -152
- package/daemon/dist/main.js.map +1 -1
- package/lib/gate.mjs +25 -5
- package/lib/wrapper.mjs +184 -0
- package/package.json +1 -1
- package/page/boot.js +4 -0
package/lib/gate.mjs
CHANGED
|
@@ -15,14 +15,20 @@
|
|
|
15
15
|
// 1. The plugin host's server() creates the key and token; once the gate binds its port it writes page/session.js (port and key only).
|
|
16
16
|
// 2. The plugin page (sandbox iframe) reads session.js and navigates itself to http://127.0.0.1:<port>/open?k=<key>.
|
|
17
17
|
// This is an ordinary navigation: no Origin header, and the gate returns no CORS headers, so other pages can't read the response.
|
|
18
|
-
// 3. The gate checks the key, starts (or reuses) the daemon and 302s to
|
|
18
|
+
// 3. The gate checks the key, starts (or reuses) the daemon and 302s to its own wrapper page
|
|
19
|
+
// http://127.0.0.1:<port>/app?<theme params>#t=<token>&p=<daemonPort>, which frames http://127.0.0.1:<daemonPort>/#t=<token> (1.3).
|
|
19
20
|
// The token lives only in the # fragment: fragments aren't sent to servers and don't appear in Referer or daemon logs;
|
|
20
21
|
// after the navigation the window belongs to the daemon's origin (opaque inside the plugin iframe), and the initiating page can't read its URL.
|
|
21
22
|
//
|
|
22
23
|
// The gate itself: every request checks Host (blocks DNS rebinding); any request with an Origin header is refused (navigations only);
|
|
23
|
-
// everything except GET /open is 404.
|
|
24
|
+
// everything except GET /open and GET /app is 404.
|
|
25
|
+
//
|
|
26
|
+
// 1.3: /open no longer sends the frame to the daemon's random port directly but to the gate's own /app page (lib/wrapper.mjs),
|
|
27
|
+
// which shows the daemon page in a child iframe. After sleep/wake or a daemon restart, a reload of the frame lands on this
|
|
28
|
+
// stable port instead of a dead one, and the wrapper sends it back through the Magpie page to a fresh daemon.
|
|
24
29
|
import http from "node:http";
|
|
25
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
30
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
31
|
+
import { parseTry, wrapperCsp, wrapperHtml } from "./wrapper.mjs";
|
|
26
32
|
|
|
27
33
|
// Constant-time compare against timing attacks; hash to the same length first.
|
|
28
34
|
function tokenEqual(a, b) {
|
|
@@ -106,6 +112,17 @@ export function createGate({ port, key, token, ensureDaemon, host = "127.0.0.1"
|
|
|
106
112
|
return;
|
|
107
113
|
}
|
|
108
114
|
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
115
|
+
if (url.pathname === "/app" && req.method === "GET") {
|
|
116
|
+
// Static: no secret in it; the token reaches it only in the # fragment
|
|
117
|
+
const nonce = randomBytes(16).toString("base64");
|
|
118
|
+
res.writeHead(200, {
|
|
119
|
+
...SAFE_HEADERS,
|
|
120
|
+
"content-type": "text/html; charset=utf-8",
|
|
121
|
+
"content-security-policy": wrapperCsp(nonce),
|
|
122
|
+
});
|
|
123
|
+
res.end(wrapperHtml(nonce));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
109
126
|
if (url.pathname !== "/open" || req.method !== "GET") {
|
|
110
127
|
respond(res, 404, "Not found");
|
|
111
128
|
return;
|
|
@@ -135,9 +152,12 @@ export function createGate({ port, key, token, ensureDaemon, host = "127.0.0.1"
|
|
|
135
152
|
for (const name of ["theme", "lang", "tokens"]) {
|
|
136
153
|
if (url.searchParams.has(name)) query.set(name, url.searchParams.get(name));
|
|
137
154
|
}
|
|
155
|
+
// The wrapper's retry counter (see lib/wrapper.mjs), only in its exact shape
|
|
156
|
+
const tries = url.searchParams.get("ac_try");
|
|
157
|
+
if (parseTry(tries)) query.set("ac_try", tries);
|
|
138
158
|
const back = safeBackUrl(url.searchParams.get("back"));
|
|
139
|
-
const fragment = `t=${encodeURIComponent(token)}` + (back ? `&b=${encodeURIComponent(back)}` : "");
|
|
140
|
-
const location = `http://127.0.0.1:${
|
|
159
|
+
const fragment = `t=${encodeURIComponent(token)}&p=${daemon.port}` + (back ? `&b=${encodeURIComponent(back)}` : "");
|
|
160
|
+
const location = `http://127.0.0.1:${port}/app?${query}#${fragment}`;
|
|
141
161
|
res.writeHead(302, { ...SAFE_HEADERS, location });
|
|
142
162
|
res.end();
|
|
143
163
|
}
|
package/lib/wrapper.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// 1.3 sleep/wake self-heal: a tiny page the gate serves at http://127.0.0.1:<gate port>/app#t=<token>&p=<daemon port>&b=<back>.
|
|
2
|
+
//
|
|
3
|
+
// Before 1.3 the plugin frame was navigated straight to the daemon's random port. When the computer slept and the daemon was
|
|
4
|
+
// gone afterwards (or the WebView reloaded the frame), the frame reloaded that dead address: "127.0.0.1 拒绝连接", and no
|
|
5
|
+
// script of ours was left to recover. Now the frame stays on the gate's stable port and shows the daemon page in a child
|
|
6
|
+
// iframe. A reload lands on the gate (alive as long as the plugin host is), and this page notices the child is not there:
|
|
7
|
+
// - the child posts {ac_console:"alive"} every 2 s while its WebSocket is open and {ac_console:"lost",at} after 5 s without it;
|
|
8
|
+
// - no "alive" within 12 s of loading (6 s after the child frame loaded), a "lost", or 15 s of silence (only while visible, and not counting time this page's
|
|
9
|
+
// own timers were suspended by sleep) → go back through the Magpie plugin page (b), like 1.1's self-heal: boot.js reads the
|
|
10
|
+
// fresh session.js and opens the gate, which restarts the daemon and sends the frame here again with the new port.
|
|
11
|
+
// - at most 3 automatic tries in 2 minutes (counted in ?ac_try=<start>.<n>, carried through b → boot.js → gate → here);
|
|
12
|
+
// then a button, so a daemon that can't start doesn't loop forever.
|
|
13
|
+
//
|
|
14
|
+
// Security: this page holds no secret of its own. The daemon token arrives only in its # fragment (as before, never sent to a
|
|
15
|
+
// server, not in Referer) and is handed to the child in the child's fragment; the gate key is never here. The child is always
|
|
16
|
+
// http://127.0.0.1:<integer port>/, b must pass the same loopback Magpie-page check as everywhere else (no open redirect),
|
|
17
|
+
// and only the Magpie theme protocol is relayed (theme events / hello answers down, the hello call up). The page runs inside
|
|
18
|
+
// Magpie's sandboxed plugin frame (opaque origin) and is served with a nonce-only CSP.
|
|
19
|
+
|
|
20
|
+
/** Same check as safeBackUrl in gate.mjs / web/src/api/backToMagpie.ts, as source for the page */
|
|
21
|
+
const SAFE_BACK_SRC = String.raw`function safeBack(raw) {
|
|
22
|
+
if (typeof raw !== "string" || !raw || raw.length > 2048) return null;
|
|
23
|
+
var u; try { u = new URL(raw); } catch (e) { return null; }
|
|
24
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
25
|
+
if (u.username || u.password) return null;
|
|
26
|
+
var h = u.hostname.toLowerCase();
|
|
27
|
+
if (h !== "127.0.0.1" && h !== "localhost" && h !== "[::1]") return null;
|
|
28
|
+
if (!/^\/_magpie\/plugin\/[A-Za-z0-9_-]{1,128}\/(?:page\/)?index\.html$/.test(u.pathname)) return null;
|
|
29
|
+
return u.protocol + "//" + u.host + u.pathname + u.search;
|
|
30
|
+
}`;
|
|
31
|
+
|
|
32
|
+
/** ?ac_try=<start ms>.<n>: valid shape only */
|
|
33
|
+
export function parseTry(raw) {
|
|
34
|
+
const m = /^(\d{10,15})\.([0-9])$/.exec(typeof raw === "string" ? raw : "");
|
|
35
|
+
return m ? { start: Number(m[1]), n: Number(m[2]) } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const WRAP_TIMING = { bootMs: 12000, afterLoadMs: 6000, silentMs: 15000, maxTries: 3, triesWindowMs: 120000 };
|
|
39
|
+
|
|
40
|
+
const SCRIPT = String.raw`(function () {
|
|
41
|
+
"use strict";
|
|
42
|
+
var T = __TIMING__;
|
|
43
|
+
${SAFE_BACK_SRC}
|
|
44
|
+
var frag = new URLSearchParams(location.hash.slice(1));
|
|
45
|
+
var token = frag.get("t") || "";
|
|
46
|
+
var port = Number(frag.get("p"));
|
|
47
|
+
var back = safeBack(frag.get("b"));
|
|
48
|
+
var q = new URLSearchParams(location.search);
|
|
49
|
+
var lang = (q.get("lang") || "").toLowerCase();
|
|
50
|
+
var zh = !lang || lang.indexOf("zh") === 0;
|
|
51
|
+
if (q.get("theme") === "dark") document.documentElement.className = "dark";
|
|
52
|
+
var f = document.getElementById("f");
|
|
53
|
+
var box = document.getElementById("m");
|
|
54
|
+
var said = document.getElementById("s");
|
|
55
|
+
var btn = document.getElementById("r");
|
|
56
|
+
function msg(zhText, enText, button) {
|
|
57
|
+
said.textContent = zh ? zhText : enText;
|
|
58
|
+
box.hidden = false;
|
|
59
|
+
if (button) { btn.textContent = zh ? button[0] : button[1]; btn.hidden = false; }
|
|
60
|
+
}
|
|
61
|
+
if (!/^[0-9a-f]{32,128}$/i.test(token) || !(port > 0 && port < 65536 && Math.floor(port) === port)) {
|
|
62
|
+
msg("链接已失效,请在 Magpie 里重新打开插件页。", "The link is no longer valid. Please reopen the Agent Console page in Magpie.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Child: the daemon page. Theme / language / colour tokens go along in its query, the token in its fragment; w=1 tells it
|
|
67
|
+
// to report to this page instead of navigating itself.
|
|
68
|
+
var cq = new URLSearchParams();
|
|
69
|
+
["theme", "lang", "tokens"].forEach(function (k) { if (q.has(k)) cq.set(k, q.get(k)); });
|
|
70
|
+
f.src = "http://127.0.0.1:" + port + "/?" + cq.toString() + "#t=" + encodeURIComponent(token) + "&w=1";
|
|
71
|
+
|
|
72
|
+
var everAlive = false, lastAlive = 0, recovering = false;
|
|
73
|
+
var loadedAt = Date.now(), lastTick = Date.now();
|
|
74
|
+
msg("正在连接本地服务…", "Connecting to the local service…");
|
|
75
|
+
// A dead port shows the browser's error page, which also fires load: once loaded, the child gets T.afterLoadMs to say alive
|
|
76
|
+
f.addEventListener("load", function () {
|
|
77
|
+
if (!everAlive) loadedAt = Math.min(loadedAt, Date.now() - (T.bootMs - T.afterLoadMs));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function recover(at) {
|
|
81
|
+
if (recovering) return;
|
|
82
|
+
recovering = true;
|
|
83
|
+
f.classList.remove("on");
|
|
84
|
+
var now = Date.now();
|
|
85
|
+
var t = (function (raw) { var m = /^(\d{10,15})\.([0-9])$/.exec(raw || ""); return m ? { start: +m[1], n: +m[2] } : null; })(q.get("ac_try"));
|
|
86
|
+
if (!t || now - t.start > T.triesWindowMs || t.start > now) t = { start: now, n: 0 };
|
|
87
|
+
if (!back) {
|
|
88
|
+
msg("本地服务已断开。请在 Magpie 里重新打开插件页。", "The local service disconnected. Please reopen the Agent Console page in Magpie.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
var go = function (auto) {
|
|
92
|
+
var u = new URL(back);
|
|
93
|
+
if (at > 0) u.searchParams.set("ac_lost", String(Math.floor(at)));
|
|
94
|
+
if (auto) u.searchParams.set("ac_try", t.start + "." + (t.n + 1));
|
|
95
|
+
location.replace(u.toString());
|
|
96
|
+
};
|
|
97
|
+
if (t.n >= T.maxTries) {
|
|
98
|
+
msg("本地服务多次重连失败。", "Reconnecting to the local service failed several times.", ["重新连接", "Reconnect"]);
|
|
99
|
+
btn.onclick = function () { btn.disabled = true; go(false); };
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
msg("本地服务已断开,正在重新连接…", "The local service disconnected. Reconnecting…");
|
|
103
|
+
go(true);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
window.addEventListener("message", function (e) {
|
|
107
|
+
var d = e.data;
|
|
108
|
+
if (!d || typeof d !== "object") return;
|
|
109
|
+
if (e.source === f.contentWindow) {
|
|
110
|
+
if (d.ac_console === "alive") {
|
|
111
|
+
lastAlive = Date.now();
|
|
112
|
+
if (!everAlive) { everAlive = true; box.hidden = true; f.classList.add("on"); }
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (d.ac_console === "lost") { recover(Number(d.at) || Date.now()); return; }
|
|
116
|
+
// The child's Magpie bridge "hello" (asks for the current theme) goes up as a fresh object, nothing else
|
|
117
|
+
if (d.magpie === 1 && d.method === "hello" && typeof d.id === "number" && window.parent !== window) {
|
|
118
|
+
window.parent.postMessage({ magpie: 1, id: d.id, method: "hello" }, "*");
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (e.source === window.parent && d.magpie === 1 && f.contentWindow) {
|
|
123
|
+
// Theme events and hello answers go down (rebuilt: only the fields the child reads)
|
|
124
|
+
if (d.event === "theme") f.contentWindow.postMessage({ magpie: 1, event: "theme", data: d.data }, "*");
|
|
125
|
+
else if (typeof d.id === "number" && "result" in d) f.contentWindow.postMessage({ magpie: 1, id: d.id, result: d.result }, "*");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
setInterval(function () {
|
|
130
|
+
var now = Date.now();
|
|
131
|
+
var gap = now - lastTick;
|
|
132
|
+
lastTick = now;
|
|
133
|
+
// This page's own timers were held (sleep, a frozen or heavily throttled frame): give the child a fresh window
|
|
134
|
+
if (gap > 5000 || document.visibilityState === "hidden") {
|
|
135
|
+
if (everAlive) lastAlive = Math.max(lastAlive, now);
|
|
136
|
+
else loadedAt = Math.max(loadedAt, now);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!everAlive) { if (now - loadedAt > T.bootMs) recover(0); return; }
|
|
140
|
+
if (now - lastAlive > T.silentMs) recover(lastAlive);
|
|
141
|
+
}, 1000);
|
|
142
|
+
})();`;
|
|
143
|
+
|
|
144
|
+
/** The wrapper page; nonce must be fresh per response (it is the only thing the CSP allows to run) */
|
|
145
|
+
export function wrapperHtml(nonce, timing = WRAP_TIMING) {
|
|
146
|
+
const script = SCRIPT.replace("__TIMING__", JSON.stringify(timing));
|
|
147
|
+
return (
|
|
148
|
+
"<!doctype html><html><head><meta charset=utf-8><meta name=referrer content=no-referrer>" +
|
|
149
|
+
"<meta name=viewport content=\"width=device-width,initial-scale=1\"><title>Agent Console</title>" +
|
|
150
|
+
`<style nonce="${nonce}">` +
|
|
151
|
+
"html,body{margin:0;height:100%;overflow:hidden;background:#fff;color:#525252;font:13px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif}" +
|
|
152
|
+
"html.dark,html.dark body{background:#1a1a1a;color:#a3a3a3}" +
|
|
153
|
+
"iframe{position:fixed;inset:0;width:100%;height:100%;border:0;display:block;visibility:hidden}" +
|
|
154
|
+
"iframe.on{visibility:visible}" +
|
|
155
|
+
"#m{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:24px;text-align:center}" +
|
|
156
|
+
"#m[hidden]{display:none}" +
|
|
157
|
+
"button{font:inherit;padding:5px 14px;border-radius:6px;border:1px solid currentColor;background:none;color:inherit;cursor:pointer}" +
|
|
158
|
+
"</style></head><body>" +
|
|
159
|
+
"<iframe id=f allow=\"clipboard-read; clipboard-write\" referrerpolicy=no-referrer title=\"Agent Console\"></iframe>" +
|
|
160
|
+
"<div id=m hidden><p id=s></p><button id=r type=button hidden></button></div>" +
|
|
161
|
+
`<script nonce="${nonce}">${script}</script>` +
|
|
162
|
+
"</body></html>"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Who may frame /app: the gate itself, a loopback page (Magpie's plugin page and `magpie web` on this computer) and
|
|
168
|
+
* Magpie's desktop window, which is wails://localhost on macOS / Linux and http://wails.localhost on Windows (WebView2).
|
|
169
|
+
* Any other site gets a blank frame. Keep in sync with the README's security section.
|
|
170
|
+
*/
|
|
171
|
+
export const WRAPPER_FRAME_ANCESTORS =
|
|
172
|
+
"frame-ancestors 'self' http://127.0.0.1:* http://localhost:* http://[::1]:* http://wails.localhost:* wails:";
|
|
173
|
+
|
|
174
|
+
export function wrapperCsp(nonce) {
|
|
175
|
+
return [
|
|
176
|
+
"default-src 'none'",
|
|
177
|
+
`script-src 'nonce-${nonce}'`,
|
|
178
|
+
`style-src 'nonce-${nonce}'`,
|
|
179
|
+
"frame-src http://127.0.0.1:*",
|
|
180
|
+
WRAPPER_FRAME_ANCESTORS,
|
|
181
|
+
"base-uri 'none'",
|
|
182
|
+
"form-action 'none'",
|
|
183
|
+
].join("; ");
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie-agent-console",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "A Magpie page plugin that drives local Agent CLIs (Claude Code, Codex, Cursor, and others) with a persistent daemon and a structured timeline.",
|
|
5
5
|
"author": "qingnian108",
|
|
6
6
|
"homepage": "https://github.com/qingnian108/magpie-agent-console",
|
package/page/boot.js
CHANGED
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
const here = new URL(location.href);
|
|
38
38
|
const lostAt = Number(here.searchParams.get("ac_lost")) || 0;
|
|
39
39
|
here.searchParams.delete("ac_lost");
|
|
40
|
+
// ac_try: the gate wrapper's count of automatic reconnects (1.3), handed on to it through the gate
|
|
41
|
+
const tries = here.searchParams.get("ac_try") || "";
|
|
42
|
+
here.searchParams.delete("ac_try");
|
|
40
43
|
here.hash = "";
|
|
41
44
|
function validSession(s) {
|
|
42
45
|
return (
|
|
@@ -103,6 +106,7 @@
|
|
|
103
106
|
const q = new URLSearchParams();
|
|
104
107
|
q.set("k", session.key);
|
|
105
108
|
if (theme) q.set("theme", theme);
|
|
109
|
+
if (/^\d{10,15}\.[0-9]$/.test(tries)) q.set("ac_try", tries);
|
|
106
110
|
if (lang) q.set("lang", lang);
|
|
107
111
|
const slim = {};
|
|
108
112
|
for (const [k, v] of Object.entries(tokens || {})) {
|