@tiens.nguyen/gu-cli 1.0.833 → 1.0.835
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 +1 -0
- package/browser-bridge.mjs +335 -0
- package/browser-command.mjs +310 -0
- package/browser-extension/README.md +151 -0
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +261 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +37 -0
- package/browser-extension/popup.html +26 -0
- package/browser-extension/popup.js +45 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +325 -19
- package/gu-cli.mjs +14 -12
- package/gu-repl.mjs +170 -2
- package/gu_browser.py +335 -0
- package/package.json +11 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +98 -10
- package/terminal-layout.mjs +1 -0
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ server. After picking, `gu doctor` shows the URL and model that are actually in
|
|
|
57
57
|
| `/model`, `/agent-model`, `/embed-model` | switch models (see above) |
|
|
58
58
|
| `/context-window [tokens\|auto]` | tell gu how big the coding model's context is (hosted models cannot be probed) |
|
|
59
59
|
| `/doctor` | check every configured model answers, especially the embedder |
|
|
60
|
+
| `/browser [allow\|forget\|install\|cleanup]` | the agent's web browser (UI tests): it asks before first use, then works in your Chrome through the gu extension or, with nothing installed, in gu's own Chrome window; `allow`/`forget` stop or restart the question for this folder; guide: [browser-extension/README.md](browser-extension/README.md) |
|
|
60
61
|
| `/tokens` | break down the last prompt sent to the coding model — how much of it was RAG |
|
|
61
62
|
| `/prices [update]` | price and cache tables, and what the last turn and this session cost |
|
|
62
63
|
| `/rag` | where this folder's RAG knowledge base lives: local, cloud, or both |
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The browser bridge (task #363, GitHub #144): the gu Chrome extension holds a WebSocket to this
|
|
3
|
+
// process, and the agent's browser tool (a new Python process every turn) sends it commands over
|
|
4
|
+
// HTTP. The bridge outlives a turn so the extension stays connected between them.
|
|
5
|
+
//
|
|
6
|
+
// Local only, and only for the two who should use it:
|
|
7
|
+
// • it listens on 127.0.0.1 and refuses a Host header that is not 127.0.0.1/localhost
|
|
8
|
+
// (a web page cannot reach it through DNS rebinding);
|
|
9
|
+
// • the WebSocket is accepted only from an Origin of chrome-extension:// — a web page cannot
|
|
10
|
+
// forge that header;
|
|
11
|
+
// • a command needs the token in ~/.gonext/browser-bridge.json, which only this user can read.
|
|
12
|
+
//
|
|
13
|
+
// No dependency: the WebSocket is the few lines of RFC 6455 a single local client needs.
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
|
+
import { mkdirSync, writeFileSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { OwnChrome } from "./browser-chrome.mjs";
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_PORT = 4127;
|
|
23
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
24
|
+
|
|
25
|
+
export function statePath() {
|
|
26
|
+
return process.env.GU_BROWSER_STATE || join(homedir(), ".gonext", "browser-bridge.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── WebSocket frames ────────────────────────────────────────────────────────────────────────
|
|
30
|
+
export function encodeFrame(text, opcode = 1) {
|
|
31
|
+
const payload = Buffer.isBuffer(text) ? text : Buffer.from(text);
|
|
32
|
+
const n = payload.length;
|
|
33
|
+
const head = n < 126 ? Buffer.alloc(2) : n < 65536 ? Buffer.alloc(4) : Buffer.alloc(10);
|
|
34
|
+
head[0] = 0x80 | opcode;
|
|
35
|
+
if (n < 126) head[1] = n;
|
|
36
|
+
else if (n < 65536) {
|
|
37
|
+
head[1] = 126;
|
|
38
|
+
head.writeUInt16BE(n, 2);
|
|
39
|
+
} else {
|
|
40
|
+
head[1] = 127;
|
|
41
|
+
head.writeBigUInt64BE(BigInt(n), 2);
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat([head, payload]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Pulls whole frames off the front of buf. Returns { frames: [{fin, opcode, payload}], rest }.
|
|
47
|
+
export function decodeFrames(buf) {
|
|
48
|
+
const frames = [];
|
|
49
|
+
let off = 0;
|
|
50
|
+
while (buf.length - off >= 2) {
|
|
51
|
+
const b0 = buf[off];
|
|
52
|
+
const b1 = buf[off + 1];
|
|
53
|
+
let len = b1 & 0x7f;
|
|
54
|
+
let p = off + 2;
|
|
55
|
+
if (len === 126) {
|
|
56
|
+
if (buf.length < p + 2) break;
|
|
57
|
+
len = buf.readUInt16BE(p);
|
|
58
|
+
p += 2;
|
|
59
|
+
} else if (len === 127) {
|
|
60
|
+
if (buf.length < p + 8) break;
|
|
61
|
+
len = Number(buf.readBigUInt64BE(p));
|
|
62
|
+
p += 8;
|
|
63
|
+
}
|
|
64
|
+
const masked = (b1 & 0x80) !== 0;
|
|
65
|
+
const mask = masked ? buf.subarray(p, p + 4) : null;
|
|
66
|
+
if (masked) p += 4;
|
|
67
|
+
if (buf.length < p + len) break;
|
|
68
|
+
const payload = Buffer.from(buf.subarray(p, p + len));
|
|
69
|
+
if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
70
|
+
frames.push({ fin: (b0 & 0x80) !== 0, opcode: b0 & 0x0f, payload });
|
|
71
|
+
off = p + len;
|
|
72
|
+
}
|
|
73
|
+
return { frames, rest: buf.subarray(off) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── the bridge ──────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
const extId = (origin) => String(origin || "").replace(/^chrome-extension:\/\//, "").replace(/\/$/, "");
|
|
78
|
+
|
|
79
|
+
// A refused second copy is told why with a close code it backs off on (background.js).
|
|
80
|
+
export const CLOSE_OTHER_COPY = 4001;
|
|
81
|
+
|
|
82
|
+
export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toString("hex"), idleMs = 4 * 3600e3, log = () => {}, ownChrome = null } = {}) {
|
|
83
|
+
let ext = null; // { socket, version }
|
|
84
|
+
// gu's own Chrome window (#150): used when the extension is not connected AND the command asks
|
|
85
|
+
// for it (backend "auto" — the agent sends that only after the user allowed the browser).
|
|
86
|
+
let own = ownChrome;
|
|
87
|
+
let nextId = 1;
|
|
88
|
+
const pending = new Map(); // id → { resolve, timer }
|
|
89
|
+
const waiters = new Set(); // resolve() when the extension connects
|
|
90
|
+
let lastUse = Date.now();
|
|
91
|
+
const refused = new Map(); // extension id → when a second copy was turned away (#148)
|
|
92
|
+
const sockets = new Set(); // every upgraded socket, so close() can end them all
|
|
93
|
+
|
|
94
|
+
const hostOk = (h) => /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(String(h || ""));
|
|
95
|
+
|
|
96
|
+
function dropExtension(sock) {
|
|
97
|
+
if (!ext || ext.socket !== sock) return;
|
|
98
|
+
ext = null;
|
|
99
|
+
for (const [id, p] of pending) {
|
|
100
|
+
clearTimeout(p.timer);
|
|
101
|
+
p.resolve({ ok: false, error: "the gu Chrome extension disconnected" });
|
|
102
|
+
pending.delete(id);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function send(cmd, timeoutMs) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
if (!ext) return resolve({ ok: false, error: "not connected", notConnected: true });
|
|
109
|
+
const id = nextId++;
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
pending.delete(id);
|
|
112
|
+
resolve({ ok: false, error: `the browser did not answer in ${Math.round(timeoutMs / 1000)}s` });
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
pending.set(id, { resolve, timer });
|
|
115
|
+
ext.socket.write(encodeFrame(JSON.stringify({ ...cmd, id })));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runOwn(cmd, timeoutMs) {
|
|
120
|
+
own ??= new OwnChrome({ log });
|
|
121
|
+
let timer;
|
|
122
|
+
try {
|
|
123
|
+
const result = await Promise.race([
|
|
124
|
+
own.run(cmd),
|
|
125
|
+
new Promise((_, reject) => (timer = setTimeout(() => reject(new Error(`gu's Chrome window did not answer in ${Math.round(timeoutMs / 1000)}s`)), timeoutMs))),
|
|
126
|
+
]);
|
|
127
|
+
return { ok: true, result, backend: "chrome" };
|
|
128
|
+
} catch (e) {
|
|
129
|
+
const error = String(e?.message || e);
|
|
130
|
+
return { ok: false, error, backend: "chrome", ...(/no Chrome, Chromium/.test(error) ? { noChrome: true } : {}) };
|
|
131
|
+
} finally {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function waitConnected(ms) {
|
|
137
|
+
if (ext || ms <= 0) return Promise.resolve(Boolean(ext));
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
const done = (v) => {
|
|
140
|
+
clearTimeout(t);
|
|
141
|
+
waiters.delete(done);
|
|
142
|
+
resolve(v);
|
|
143
|
+
};
|
|
144
|
+
const t = setTimeout(() => done(Boolean(ext)), ms);
|
|
145
|
+
waiters.add(done);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const server = createServer(async (req, res) => {
|
|
150
|
+
const json = (code, body) => {
|
|
151
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
152
|
+
res.end(JSON.stringify(body));
|
|
153
|
+
};
|
|
154
|
+
if (!hostOk(req.headers.host) || req.headers.origin) return json(403, { error: "forbidden" });
|
|
155
|
+
if (req.headers.authorization !== `Bearer ${token}`) return json(401, { error: "bad token" });
|
|
156
|
+
lastUse = Date.now();
|
|
157
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
158
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
159
|
+
const waitMs = Math.min(Number(url.searchParams.get("wait_ms")) || 0, 60000);
|
|
160
|
+
await waitConnected(waitMs);
|
|
161
|
+
const since = Date.now() - 5 * 60e3;
|
|
162
|
+
return json(200, {
|
|
163
|
+
connected: Boolean(ext),
|
|
164
|
+
version: ext?.version || null,
|
|
165
|
+
id: ext ? extId(ext.origin) : null,
|
|
166
|
+
installType: ext?.installType || null,
|
|
167
|
+
ownChrome: Boolean(own?.running),
|
|
168
|
+
refused: [...refused].filter(([, at]) => at > since).map(([id, at]) => ({ id, at })),
|
|
169
|
+
pid: process.pid,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (req.method === "POST" && url.pathname === "/cmd") {
|
|
173
|
+
let body = "";
|
|
174
|
+
for await (const c of req) body += c;
|
|
175
|
+
let cmd;
|
|
176
|
+
try {
|
|
177
|
+
cmd = JSON.parse(body);
|
|
178
|
+
} catch {
|
|
179
|
+
return json(400, { error: "bad json" });
|
|
180
|
+
}
|
|
181
|
+
const timeoutMs = Math.min(Math.max(Number(cmd.timeout_ms) || 60000, 1000), 120000);
|
|
182
|
+
const backend = cmd.backend || "extension";
|
|
183
|
+
delete cmd.timeout_ms;
|
|
184
|
+
delete cmd.backend;
|
|
185
|
+
if (ext || backend !== "auto" || ["status", "uninstall_self"].includes(cmd.action)) {
|
|
186
|
+
const r = await send(cmd, timeoutMs);
|
|
187
|
+
return json(r.notConnected ? 503 : 200, { ...r, backend: "extension" });
|
|
188
|
+
}
|
|
189
|
+
return json(200, await runOwn(cmd, timeoutMs));
|
|
190
|
+
}
|
|
191
|
+
json(404, { error: "not found" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
server.on("upgrade", (req, socket) => {
|
|
195
|
+
const origin = String(req.headers.origin || "");
|
|
196
|
+
const key = req.headers["sec-websocket-key"];
|
|
197
|
+
if (!hostOk(req.headers.host) || !origin.startsWith("chrome-extension://") || !key || new URL(req.url, "http://x").pathname !== "/extension") {
|
|
198
|
+
socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
socket.setNoDelay(true);
|
|
202
|
+
sockets.add(socket);
|
|
203
|
+
socket.on("close", () => sockets.delete(socket));
|
|
204
|
+
const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
205
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
206
|
+
// ONE EXTENSION AT A TIME (#148). The same one reconnecting (Chrome restarted its worker)
|
|
207
|
+
// replaces the old socket. A DIFFERENT one — a developer copy next to the store copy — is
|
|
208
|
+
// turned away: with "newest wins" the two took the connection from each other every 3 s.
|
|
209
|
+
if (ext && !ext.socket.destroyed && ext.origin !== origin) {
|
|
210
|
+
refused.set(extId(origin), Date.now());
|
|
211
|
+
log(`refused a second gu extension (${extId(origin)}) — ${extId(ext.origin)} is connected`);
|
|
212
|
+
const reason = Buffer.from("another gu extension is connected");
|
|
213
|
+
const code = Buffer.alloc(2);
|
|
214
|
+
code.writeUInt16BE(CLOSE_OTHER_COPY);
|
|
215
|
+
socket.end(encodeFrame(Buffer.concat([code, reason]), 8));
|
|
216
|
+
setTimeout(() => socket.destroy(), 1000).unref();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (ext) ext.socket.destroy();
|
|
220
|
+
ext = { socket, version: null, installType: null, origin };
|
|
221
|
+
log(`extension connected (${origin})`);
|
|
222
|
+
let buf = Buffer.alloc(0);
|
|
223
|
+
let parts = [];
|
|
224
|
+
socket.on("data", (chunk) => {
|
|
225
|
+
buf = Buffer.concat([buf, chunk]);
|
|
226
|
+
const { frames, rest } = decodeFrames(buf);
|
|
227
|
+
buf = Buffer.from(rest);
|
|
228
|
+
for (const f of frames) {
|
|
229
|
+
if (f.opcode === 8) {
|
|
230
|
+
socket.end(encodeFrame("", 8));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (f.opcode === 9) {
|
|
234
|
+
socket.write(encodeFrame(f.payload.toString(), 10));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (f.opcode === 10) continue;
|
|
238
|
+
parts.push(f.payload);
|
|
239
|
+
if (!f.fin) continue;
|
|
240
|
+
const text = Buffer.concat(parts).toString("utf8");
|
|
241
|
+
parts = [];
|
|
242
|
+
let msg;
|
|
243
|
+
try {
|
|
244
|
+
msg = JSON.parse(text);
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (msg.type === "hello") {
|
|
249
|
+
if (ext?.socket === socket) {
|
|
250
|
+
ext.version = msg.version || "?";
|
|
251
|
+
ext.installType = msg.installType || null;
|
|
252
|
+
}
|
|
253
|
+
for (const w of [...waiters]) w(true);
|
|
254
|
+
} else if (msg.id !== undefined && pending.has(msg.id)) {
|
|
255
|
+
const p = pending.get(msg.id);
|
|
256
|
+
pending.delete(msg.id);
|
|
257
|
+
clearTimeout(p.timer);
|
|
258
|
+
p.resolve(msg.ok ? { ok: true, result: msg.result } : { ok: false, error: msg.error || "failed" });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
socket.on("close", () => dropExtension(socket));
|
|
263
|
+
socket.on("error", () => dropExtension(socket));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const idle = setInterval(() => {
|
|
267
|
+
if (Date.now() - lastUse > idleMs) {
|
|
268
|
+
log("idle — exiting");
|
|
269
|
+
server.close();
|
|
270
|
+
clearInterval(idle);
|
|
271
|
+
for (const so of sockets) so.destroy();
|
|
272
|
+
server.emit("idle-exit");
|
|
273
|
+
}
|
|
274
|
+
}, Math.min(idleMs, 60000));
|
|
275
|
+
idle.unref();
|
|
276
|
+
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
server.once("error", reject);
|
|
279
|
+
server.listen(port, "127.0.0.1", () => {
|
|
280
|
+
resolve({
|
|
281
|
+
server,
|
|
282
|
+
port: server.address().port,
|
|
283
|
+
token,
|
|
284
|
+
connected: () => Boolean(ext),
|
|
285
|
+
close: () => {
|
|
286
|
+
clearInterval(idle);
|
|
287
|
+
for (const so of sockets) so.destroy();
|
|
288
|
+
server.closeAllConnections?.();
|
|
289
|
+
return new Promise((r) => server.close(() => r()));
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── run as a program: gu starts it on demand, detached ──────────────────────────────────────
|
|
297
|
+
async function main() {
|
|
298
|
+
const path = statePath();
|
|
299
|
+
const port = process.env.GU_BROWSER_PORT !== undefined ? Number(process.env.GU_BROWSER_PORT) : DEFAULT_PORT;
|
|
300
|
+
const log = (m) => process.stderr.write(`[gu-browser-bridge] ${m}\n`);
|
|
301
|
+
let b;
|
|
302
|
+
try {
|
|
303
|
+
b = await startBridge({ port, log });
|
|
304
|
+
} catch (e) {
|
|
305
|
+
log(`cannot listen on 127.0.0.1:${port}: ${e.code || e.message}`);
|
|
306
|
+
process.exit(3);
|
|
307
|
+
}
|
|
308
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
309
|
+
writeFileSync(path, JSON.stringify({ port: b.port, token: b.token, pid: process.pid, started: Date.now() / 1000 }), { mode: 0o600 });
|
|
310
|
+
log(`listening on 127.0.0.1:${b.port}`);
|
|
311
|
+
const cleanup = () => {
|
|
312
|
+
try {
|
|
313
|
+
if (JSON.parse(readFileSync(path, "utf8")).pid === process.pid) unlinkSync(path);
|
|
314
|
+
} catch {
|
|
315
|
+
/* gone */
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
b.server.on("idle-exit", () => {
|
|
319
|
+
cleanup();
|
|
320
|
+
process.exit(0);
|
|
321
|
+
});
|
|
322
|
+
for (const sig of ["SIGTERM", "SIGINT"]) process.on(sig, () => {
|
|
323
|
+
cleanup();
|
|
324
|
+
process.exit(0);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const isMain = (() => {
|
|
329
|
+
try {
|
|
330
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
})();
|
|
335
|
+
if (isMain) main();
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// /browser (task #363, GitHub #144): is the gu Chrome extension connected, and how to install it.
|
|
2
|
+
//
|
|
3
|
+
// The extension is loaded from a FIXED folder, ~/.gonext/browser-extension, not from the npm
|
|
4
|
+
// package: an update moves the package and Chrome would lose an unpacked extension loaded there.
|
|
5
|
+
// The files are copied there (again after an update) every time this runs, as the agent does.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, openSync, closeSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { sameProcess } from "./proc-identity.mjs";
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
export const EXT_SRC = join(HERE, "browser-extension");
|
|
15
|
+
export const EXT_FILES = ["manifest.json", "background.js", "actions.js", "popup.html", "popup.js", "icons/icon16.png", "icons/icon48.png", "icons/icon128.png"];
|
|
16
|
+
|
|
17
|
+
const dataDir = () => join(homedir(), ".gonext");
|
|
18
|
+
export const extensionDir = () => process.env.GU_BROWSER_EXT_DIR || join(dataDir(), "browser-extension");
|
|
19
|
+
const statePath = () => process.env.GU_BROWSER_STATE || join(dataDir(), "browser-bridge.json");
|
|
20
|
+
|
|
21
|
+
export function installExtensionFiles(src = EXT_SRC, dst = extensionDir()) {
|
|
22
|
+
mkdirSync(join(dst, "icons"), { recursive: true });
|
|
23
|
+
for (const n of EXT_FILES) {
|
|
24
|
+
const from = join(src, n);
|
|
25
|
+
const to = join(dst, n);
|
|
26
|
+
if (!existsSync(to) || !readFileSync(to).equals(readFileSync(from))) copyFileSync(from, to);
|
|
27
|
+
}
|
|
28
|
+
return dst;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readState() {
|
|
32
|
+
try {
|
|
33
|
+
const st = JSON.parse(readFileSync(statePath(), "utf8"));
|
|
34
|
+
return st.port && st.token ? st : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function status(st, waitMs = 0) {
|
|
41
|
+
const r = await fetch(`http://127.0.0.1:${st.port}/status?wait_ms=${waitMs}`, {
|
|
42
|
+
headers: { authorization: `Bearer ${st.token}` },
|
|
43
|
+
signal: AbortSignal.timeout(waitMs + 3000),
|
|
44
|
+
});
|
|
45
|
+
return r.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// → { running, connected, version }. Starts the bridge when it is not running, so an extension
|
|
49
|
+
// installed a moment from now has something to connect to.
|
|
50
|
+
export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
|
|
51
|
+
let st = readState();
|
|
52
|
+
try {
|
|
53
|
+
if (st) return { running: true, ...(await status(st, waitMs)) };
|
|
54
|
+
} catch {
|
|
55
|
+
/* stale state file */
|
|
56
|
+
}
|
|
57
|
+
if (!start) return { running: false, connected: false };
|
|
58
|
+
mkdirSync(dataDir(), { recursive: true });
|
|
59
|
+
const log = openSync(join(dataDir(), "browser-bridge.log"), "a");
|
|
60
|
+
spawn(process.execPath, [join(HERE, "browser-bridge.mjs")], { detached: true, stdio: ["ignore", log, log] }).unref();
|
|
61
|
+
closeSync(log);
|
|
62
|
+
const end = Date.now() + 5000;
|
|
63
|
+
while (Date.now() < end) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
65
|
+
st = readState();
|
|
66
|
+
if (!st) continue;
|
|
67
|
+
try {
|
|
68
|
+
return { running: true, ...(await status(st, waitMs)) };
|
|
69
|
+
} catch {
|
|
70
|
+
/* not listening yet */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { running: false, connected: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The Chrome Web Store page of the gu extension, once published (#149/#150). Empty until then.
|
|
77
|
+
export const STORE_URL = process.env.GU_BROWSER_STORE_URL || "";
|
|
78
|
+
|
|
79
|
+
// "Always allow the browser in this folder" (#150) — the agent reads the same file
|
|
80
|
+
// (gu_browser.allowed_in) before it asks.
|
|
81
|
+
const allowPath = () => join(dirname(statePath()), "browser-allow.json");
|
|
82
|
+
const real = (p) => {
|
|
83
|
+
try {
|
|
84
|
+
return realpathSync(p);
|
|
85
|
+
} catch {
|
|
86
|
+
return p;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
export function allowedIn(folder) {
|
|
90
|
+
try {
|
|
91
|
+
return (JSON.parse(readFileSync(allowPath(), "utf8")).folders || []).includes(real(folder));
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function allowIn(folder, allow = true) {
|
|
97
|
+
let folders = [];
|
|
98
|
+
try {
|
|
99
|
+
folders = JSON.parse(readFileSync(allowPath(), "utf8")).folders || [];
|
|
100
|
+
} catch {
|
|
101
|
+
/* none yet */
|
|
102
|
+
}
|
|
103
|
+
const f = real(folder);
|
|
104
|
+
folders = [...folders.filter((x) => x !== f), ...(allow ? [f] : [])];
|
|
105
|
+
mkdirSync(dirname(allowPath()), { recursive: true });
|
|
106
|
+
writeFileSync(allowPath(), JSON.stringify({ folders }, null, 2));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function browserReport({ connected, version, installType, refused = [] }, { allowed = false } = {}) {
|
|
110
|
+
// Two copies installed (the store one next to a developer one): the bridge kept one (#148).
|
|
111
|
+
const twoCopies = refused.length
|
|
112
|
+
? [`⚠ A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
|
|
113
|
+
"⚠ turned away — two copies are installed. Keep one: /browser cleanup removes",
|
|
114
|
+
"⚠ the developer (unpacked) copy."]
|
|
115
|
+
: [];
|
|
116
|
+
const here = allowed
|
|
117
|
+
? "In this folder it may, without asking (/browser forget to undo)."
|
|
118
|
+
: "In this folder it asks first (/browser allow to stop asking).";
|
|
119
|
+
if (connected) {
|
|
120
|
+
const kind = installType === "development" ? ", developer — loaded unpacked" : installType === "normal" ? ", Chrome Web Store" : "";
|
|
121
|
+
return [
|
|
122
|
+
`✓ The gu Chrome extension is connected (v${version}${kind}).`,
|
|
123
|
+
"The agent uses your Chrome, in its own tabs (the \"gu\" tab group) and tabs",
|
|
124
|
+
"you share from the extension's icon → Let gu use this tab.",
|
|
125
|
+
here,
|
|
126
|
+
...twoCopies,
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
return [
|
|
130
|
+
"Nothing to install. When the agent needs a browser it asks you first, then",
|
|
131
|
+
"opens gu's own Chrome window (a separate profile — not signed in to your",
|
|
132
|
+
"accounts) and works in it with a real mouse and keyboard.",
|
|
133
|
+
STORE_URL
|
|
134
|
+
? `To let it use YOUR Chrome (your logins, a tab you share): ${STORE_URL}`
|
|
135
|
+
: "To let it use YOUR Chrome (your logins, a tab you share): /browser install",
|
|
136
|
+
here,
|
|
137
|
+
...twoCopies,
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// /browser install — the extension, for the user's own Chrome. The store page when there is one;
|
|
142
|
+
// until then the developer way (Developer mode → Load unpacked).
|
|
143
|
+
export function installSteps(folder, platform = process.platform) {
|
|
144
|
+
if (STORE_URL) {
|
|
145
|
+
return [`Open ${STORE_URL} in Chrome and click "Add to Chrome".`, "Then type /browser — it should say connected."];
|
|
146
|
+
}
|
|
147
|
+
const pick = platform === "darwin"
|
|
148
|
+
? [" In the file dialog press ⌘⇧G, paste the folder (it is on your", " clipboard) and press Enter."]
|
|
149
|
+
: [" In the file dialog type or paste the folder (it starts with a dot,", " so it is hidden)."];
|
|
150
|
+
return [
|
|
151
|
+
"The gu extension is not on the Chrome Web Store yet, so it is added the",
|
|
152
|
+
"developer way (you do not need it — gu's own window works without):",
|
|
153
|
+
" 1. Open Chrome and go to chrome://extensions",
|
|
154
|
+
" 2. Turn on Developer mode (top right)",
|
|
155
|
+
" 3. Click \"Load unpacked\" and choose this folder:",
|
|
156
|
+
` ${folder}`,
|
|
157
|
+
...pick,
|
|
158
|
+
" 4. Type /browser — it should say connected.",
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// macOS: open Chrome's extensions page. → whether it was opened.
|
|
163
|
+
export function openExtensionsPage(platform = process.platform) {
|
|
164
|
+
// GU_NO_OPEN_APPS: the terminal tests, which must not open the real Chrome of whoever runs them.
|
|
165
|
+
if (platform !== "darwin" || process.env.GU_NO_OPEN_APPS || !existsSync("/Applications/Google Chrome.app")) return false;
|
|
166
|
+
try {
|
|
167
|
+
spawn("open", ["-a", "Google Chrome", "chrome://extensions"], { stdio: "ignore", detached: true }).unref();
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// macOS: put the folder on the clipboard and open the extensions page, so step 3 is a paste.
|
|
175
|
+
export function helpInstall(folder, platform = process.platform) {
|
|
176
|
+
if (platform !== "darwin" || process.env.GU_NO_OPEN_APPS) return false;
|
|
177
|
+
try {
|
|
178
|
+
const cp = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
179
|
+
cp.stdin.end(folder);
|
|
180
|
+
} catch {
|
|
181
|
+
/* best effort */
|
|
182
|
+
}
|
|
183
|
+
return openExtensionsPage(platform);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── the developer install, and cleaning it up (#145: #146 detect, #147 /browser cleanup) ──────
|
|
187
|
+
const seenPath = () => join(dirname(statePath()), "browser-extension-seen");
|
|
188
|
+
const logPath = () => join(dataDir(), "browser-bridge.log");
|
|
189
|
+
|
|
190
|
+
// Where each Chromium browser keeps its profiles. Read only — gu never writes a browser's files.
|
|
191
|
+
export function browserRoots(home = homedir(), platform = process.platform, env = process.env) {
|
|
192
|
+
const named = (list) => list.map(([browser, dir]) => ({ browser, dir }));
|
|
193
|
+
if (platform === "darwin") {
|
|
194
|
+
const a = join(home, "Library", "Application Support");
|
|
195
|
+
return named([["Chrome", join(a, "Google", "Chrome")], ["Chrome Beta", join(a, "Google", "Chrome Beta")],
|
|
196
|
+
["Chrome Canary", join(a, "Google", "Chrome Canary")], ["Chromium", join(a, "Chromium")],
|
|
197
|
+
["Edge", join(a, "Microsoft Edge")], ["Brave", join(a, "BraveSoftware", "Brave-Browser")]]);
|
|
198
|
+
}
|
|
199
|
+
if (platform === "win32") {
|
|
200
|
+
const l = env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
201
|
+
return named([["Chrome", join(l, "Google", "Chrome", "User Data")], ["Chrome Beta", join(l, "Google", "Chrome Beta", "User Data")],
|
|
202
|
+
["Chrome Canary", join(l, "Google", "Chrome SxS", "User Data")], ["Chromium", join(l, "Chromium", "User Data")],
|
|
203
|
+
["Edge", join(l, "Microsoft", "Edge", "User Data")], ["Brave", join(l, "BraveSoftware", "Brave-Browser", "User Data")]]);
|
|
204
|
+
}
|
|
205
|
+
const c = env.XDG_CONFIG_HOME || join(home, ".config");
|
|
206
|
+
return named([["Chrome", join(c, "google-chrome")], ["Chrome Beta", join(c, "google-chrome-beta")],
|
|
207
|
+
["Chromium", join(c, "chromium")], ["Edge", join(c, "microsoft-edge")], ["Brave", join(c, "BraveSoftware", "Brave-Browser")]]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const UNPACKED = new Set([4, 8]); // Chrome's Manifest::Location: UNPACKED (Load unpacked), COMMAND_LINE (--load-extension)
|
|
211
|
+
|
|
212
|
+
function isGuExtension(path, entry, extDir) {
|
|
213
|
+
if (entry?.manifest?.name === "gu browser") return true;
|
|
214
|
+
if (path === extDir) return true; // the folder may already be deleted
|
|
215
|
+
try {
|
|
216
|
+
if (realpathSync(path) === realpathSync(extDir)) return true;
|
|
217
|
+
} catch {
|
|
218
|
+
/* one of them is gone */
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return JSON.parse(readFileSync(join(path, "manifest.json"), "utf8")).name === "gu browser";
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// → [{browser, profile, id, path}] for every profile with gu's extension loaded UNPACKED. A store
|
|
228
|
+
// install is not listed — that is the one to keep.
|
|
229
|
+
export function findDevInstalls({ roots = browserRoots(), extDir = extensionDir() } = {}) {
|
|
230
|
+
const found = [];
|
|
231
|
+
for (const { browser, dir } of roots) {
|
|
232
|
+
let profiles = [];
|
|
233
|
+
try {
|
|
234
|
+
profiles = readdirSync(dir).filter((p) => p === "Default" || /^Profile \d+$/.test(p));
|
|
235
|
+
} catch {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
for (const profile of profiles) {
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
for (const file of ["Secure Preferences", "Preferences"]) {
|
|
241
|
+
let settings;
|
|
242
|
+
try {
|
|
243
|
+
settings = JSON.parse(readFileSync(join(dir, profile, file), "utf8"))?.extensions?.settings || {};
|
|
244
|
+
} catch {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
for (const [id, e] of Object.entries(settings)) {
|
|
248
|
+
if (seen.has(id) || !e || !UNPACKED.has(e.location) || typeof e.path !== "string") continue;
|
|
249
|
+
const path = isAbsolute(e.path) ? e.path : join(dir, profile, "Extensions", e.path);
|
|
250
|
+
if (!isGuExtension(path, e, extDir)) continue;
|
|
251
|
+
seen.add(id);
|
|
252
|
+
found.push({ browser, profile, id, path });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return found;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// gu's own leftovers of the developer install, in ~/.gonext.
|
|
261
|
+
export function localLeftovers() {
|
|
262
|
+
return [extensionDir(), statePath(), seenPath(), logPath()].filter((p) => existsSync(p));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function bridgeCommand(cmd, timeoutMs = 15000) {
|
|
266
|
+
const st = readState();
|
|
267
|
+
if (!st) return { ok: false, error: "the bridge is not running" };
|
|
268
|
+
try {
|
|
269
|
+
const r = await fetch(`http://127.0.0.1:${st.port}/cmd`, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { authorization: `Bearer ${st.token}`, "content-type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ ...cmd, timeout_ms: timeoutMs }),
|
|
273
|
+
signal: AbortSignal.timeout(timeoutMs + 3000),
|
|
274
|
+
});
|
|
275
|
+
return await r.json();
|
|
276
|
+
} catch (e) {
|
|
277
|
+
return { ok: false, error: String(e.message || e) };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Stop the bridge the state file names — only while that pid is still the bridge (#373, GitHub #154).
|
|
283
|
+
* The state outlives a bridge that crashed or a reboot; its pid may be someone else's by then. The
|
|
284
|
+
* bridge records `started` once it listens, so its process began at most a minute before that.
|
|
285
|
+
*/
|
|
286
|
+
export function stopBridge({ startOf } = {}) {
|
|
287
|
+
const st = readState();
|
|
288
|
+
if (!st?.pid) return false;
|
|
289
|
+
if (sameProcess(st.pid, st.started, { before: 60, after: 2, ...(startOf ? { startOf } : {}) }) !== "same") return false;
|
|
290
|
+
try {
|
|
291
|
+
process.kill(st.pid, "SIGTERM");
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Deletes gu's files of the developer install. → the paths removed.
|
|
299
|
+
export function removeLocalFiles() {
|
|
300
|
+
const gone = [];
|
|
301
|
+
for (const p of localLeftovers()) {
|
|
302
|
+
try {
|
|
303
|
+
rmSync(p, { recursive: true, force: true });
|
|
304
|
+
gone.push(p);
|
|
305
|
+
} catch {
|
|
306
|
+
/* reported by the rescan */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return gone;
|
|
310
|
+
}
|