@tiens.nguyen/gu-cli 1.0.833 → 1.0.834
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 +274 -0
- package/browser-command.mjs +116 -0
- package/browser-extension/README.md +111 -0
- package/browser-extension/background.js +483 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +35 -0
- package/browser-extension/popup.html +22 -0
- package/browser-extension/popup.js +22 -0
- package/gonext_agent_chat.py +188 -5
- package/gu-cli.mjs +3 -0
- package/gu-repl.mjs +66 -2
- package/gu_browser.py +263 -0
- package/package.json +9 -1
- package/shell-details.mjs +58 -4
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` | the gu Chrome extension the agent uses to open, see and click web pages (UI tests): is it connected, and how to install it (Load unpacked from `~/.gonext/browser-extension`); setup and Chrome Web Store publishing: [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,274 @@
|
|
|
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
|
+
|
|
21
|
+
export const DEFAULT_PORT = 4127;
|
|
22
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
23
|
+
|
|
24
|
+
export function statePath() {
|
|
25
|
+
return process.env.GU_BROWSER_STATE || join(homedir(), ".gonext", "browser-bridge.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── WebSocket frames ────────────────────────────────────────────────────────────────────────
|
|
29
|
+
export function encodeFrame(text, opcode = 1) {
|
|
30
|
+
const payload = Buffer.from(text);
|
|
31
|
+
const n = payload.length;
|
|
32
|
+
const head = n < 126 ? Buffer.alloc(2) : n < 65536 ? Buffer.alloc(4) : Buffer.alloc(10);
|
|
33
|
+
head[0] = 0x80 | opcode;
|
|
34
|
+
if (n < 126) head[1] = n;
|
|
35
|
+
else if (n < 65536) {
|
|
36
|
+
head[1] = 126;
|
|
37
|
+
head.writeUInt16BE(n, 2);
|
|
38
|
+
} else {
|
|
39
|
+
head[1] = 127;
|
|
40
|
+
head.writeBigUInt64BE(BigInt(n), 2);
|
|
41
|
+
}
|
|
42
|
+
return Buffer.concat([head, payload]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Pulls whole frames off the front of buf. Returns { frames: [{fin, opcode, payload}], rest }.
|
|
46
|
+
export function decodeFrames(buf) {
|
|
47
|
+
const frames = [];
|
|
48
|
+
let off = 0;
|
|
49
|
+
while (buf.length - off >= 2) {
|
|
50
|
+
const b0 = buf[off];
|
|
51
|
+
const b1 = buf[off + 1];
|
|
52
|
+
let len = b1 & 0x7f;
|
|
53
|
+
let p = off + 2;
|
|
54
|
+
if (len === 126) {
|
|
55
|
+
if (buf.length < p + 2) break;
|
|
56
|
+
len = buf.readUInt16BE(p);
|
|
57
|
+
p += 2;
|
|
58
|
+
} else if (len === 127) {
|
|
59
|
+
if (buf.length < p + 8) break;
|
|
60
|
+
len = Number(buf.readBigUInt64BE(p));
|
|
61
|
+
p += 8;
|
|
62
|
+
}
|
|
63
|
+
const masked = (b1 & 0x80) !== 0;
|
|
64
|
+
const mask = masked ? buf.subarray(p, p + 4) : null;
|
|
65
|
+
if (masked) p += 4;
|
|
66
|
+
if (buf.length < p + len) break;
|
|
67
|
+
const payload = Buffer.from(buf.subarray(p, p + len));
|
|
68
|
+
if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
69
|
+
frames.push({ fin: (b0 & 0x80) !== 0, opcode: b0 & 0x0f, payload });
|
|
70
|
+
off = p + len;
|
|
71
|
+
}
|
|
72
|
+
return { frames, rest: buf.subarray(off) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── the bridge ──────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toString("hex"), idleMs = 4 * 3600e3, log = () => {} } = {}) {
|
|
77
|
+
let ext = null; // { socket, version }
|
|
78
|
+
let nextId = 1;
|
|
79
|
+
const pending = new Map(); // id → { resolve, timer }
|
|
80
|
+
const waiters = new Set(); // resolve() when the extension connects
|
|
81
|
+
let lastUse = Date.now();
|
|
82
|
+
|
|
83
|
+
const hostOk = (h) => /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(String(h || ""));
|
|
84
|
+
|
|
85
|
+
function dropExtension(sock) {
|
|
86
|
+
if (!ext || ext.socket !== sock) return;
|
|
87
|
+
ext = null;
|
|
88
|
+
for (const [id, p] of pending) {
|
|
89
|
+
clearTimeout(p.timer);
|
|
90
|
+
p.resolve({ ok: false, error: "the gu Chrome extension disconnected" });
|
|
91
|
+
pending.delete(id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function send(cmd, timeoutMs) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
if (!ext) return resolve({ ok: false, error: "not connected", notConnected: true });
|
|
98
|
+
const id = nextId++;
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
pending.delete(id);
|
|
101
|
+
resolve({ ok: false, error: `the browser did not answer in ${Math.round(timeoutMs / 1000)}s` });
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
pending.set(id, { resolve, timer });
|
|
104
|
+
ext.socket.write(encodeFrame(JSON.stringify({ ...cmd, id })));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function waitConnected(ms) {
|
|
109
|
+
if (ext || ms <= 0) return Promise.resolve(Boolean(ext));
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
const done = (v) => {
|
|
112
|
+
clearTimeout(t);
|
|
113
|
+
waiters.delete(done);
|
|
114
|
+
resolve(v);
|
|
115
|
+
};
|
|
116
|
+
const t = setTimeout(() => done(Boolean(ext)), ms);
|
|
117
|
+
waiters.add(done);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const server = createServer(async (req, res) => {
|
|
122
|
+
const json = (code, body) => {
|
|
123
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
124
|
+
res.end(JSON.stringify(body));
|
|
125
|
+
};
|
|
126
|
+
if (!hostOk(req.headers.host) || req.headers.origin) return json(403, { error: "forbidden" });
|
|
127
|
+
if (req.headers.authorization !== `Bearer ${token}`) return json(401, { error: "bad token" });
|
|
128
|
+
lastUse = Date.now();
|
|
129
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
130
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
131
|
+
const waitMs = Math.min(Number(url.searchParams.get("wait_ms")) || 0, 60000);
|
|
132
|
+
await waitConnected(waitMs);
|
|
133
|
+
return json(200, { connected: Boolean(ext), version: ext?.version || null, pid: process.pid });
|
|
134
|
+
}
|
|
135
|
+
if (req.method === "POST" && url.pathname === "/cmd") {
|
|
136
|
+
let body = "";
|
|
137
|
+
for await (const c of req) body += c;
|
|
138
|
+
let cmd;
|
|
139
|
+
try {
|
|
140
|
+
cmd = JSON.parse(body);
|
|
141
|
+
} catch {
|
|
142
|
+
return json(400, { error: "bad json" });
|
|
143
|
+
}
|
|
144
|
+
const timeoutMs = Math.min(Math.max(Number(cmd.timeout_ms) || 60000, 1000), 120000);
|
|
145
|
+
delete cmd.timeout_ms;
|
|
146
|
+
const r = await send(cmd, timeoutMs);
|
|
147
|
+
return json(r.notConnected ? 503 : 200, r);
|
|
148
|
+
}
|
|
149
|
+
json(404, { error: "not found" });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
server.on("upgrade", (req, socket) => {
|
|
153
|
+
const origin = String(req.headers.origin || "");
|
|
154
|
+
const key = req.headers["sec-websocket-key"];
|
|
155
|
+
if (!hostOk(req.headers.host) || !origin.startsWith("chrome-extension://") || !key || new URL(req.url, "http://x").pathname !== "/extension") {
|
|
156
|
+
socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
160
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
161
|
+
socket.setNoDelay(true);
|
|
162
|
+
if (ext) ext.socket.destroy(); // the newest extension wins (Chrome restarted it)
|
|
163
|
+
ext = { socket, version: null, origin };
|
|
164
|
+
log(`extension connected (${origin})`);
|
|
165
|
+
let buf = Buffer.alloc(0);
|
|
166
|
+
let parts = [];
|
|
167
|
+
socket.on("data", (chunk) => {
|
|
168
|
+
buf = Buffer.concat([buf, chunk]);
|
|
169
|
+
const { frames, rest } = decodeFrames(buf);
|
|
170
|
+
buf = Buffer.from(rest);
|
|
171
|
+
for (const f of frames) {
|
|
172
|
+
if (f.opcode === 8) {
|
|
173
|
+
socket.end(encodeFrame("", 8));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (f.opcode === 9) {
|
|
177
|
+
socket.write(encodeFrame(f.payload.toString(), 10));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (f.opcode === 10) continue;
|
|
181
|
+
parts.push(f.payload);
|
|
182
|
+
if (!f.fin) continue;
|
|
183
|
+
const text = Buffer.concat(parts).toString("utf8");
|
|
184
|
+
parts = [];
|
|
185
|
+
let msg;
|
|
186
|
+
try {
|
|
187
|
+
msg = JSON.parse(text);
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (msg.type === "hello") {
|
|
192
|
+
if (ext?.socket === socket) ext.version = msg.version || "?";
|
|
193
|
+
for (const w of [...waiters]) w(true);
|
|
194
|
+
} else if (msg.id !== undefined && pending.has(msg.id)) {
|
|
195
|
+
const p = pending.get(msg.id);
|
|
196
|
+
pending.delete(msg.id);
|
|
197
|
+
clearTimeout(p.timer);
|
|
198
|
+
p.resolve(msg.ok ? { ok: true, result: msg.result } : { ok: false, error: msg.error || "failed" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
socket.on("close", () => dropExtension(socket));
|
|
203
|
+
socket.on("error", () => dropExtension(socket));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const idle = setInterval(() => {
|
|
207
|
+
if (Date.now() - lastUse > idleMs) {
|
|
208
|
+
log("idle — exiting");
|
|
209
|
+
server.close();
|
|
210
|
+
clearInterval(idle);
|
|
211
|
+
if (ext) ext.socket.destroy();
|
|
212
|
+
server.emit("idle-exit");
|
|
213
|
+
}
|
|
214
|
+
}, Math.min(idleMs, 60000));
|
|
215
|
+
idle.unref();
|
|
216
|
+
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
server.once("error", reject);
|
|
219
|
+
server.listen(port, "127.0.0.1", () => {
|
|
220
|
+
resolve({
|
|
221
|
+
server,
|
|
222
|
+
port: server.address().port,
|
|
223
|
+
token,
|
|
224
|
+
connected: () => Boolean(ext),
|
|
225
|
+
close: () => {
|
|
226
|
+
clearInterval(idle);
|
|
227
|
+
if (ext) ext.socket.destroy();
|
|
228
|
+
return new Promise((r) => server.close(() => r()));
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── run as a program: gu starts it on demand, detached ──────────────────────────────────────
|
|
236
|
+
async function main() {
|
|
237
|
+
const path = statePath();
|
|
238
|
+
const port = process.env.GU_BROWSER_PORT !== undefined ? Number(process.env.GU_BROWSER_PORT) : DEFAULT_PORT;
|
|
239
|
+
const log = (m) => process.stderr.write(`[gu-browser-bridge] ${m}\n`);
|
|
240
|
+
let b;
|
|
241
|
+
try {
|
|
242
|
+
b = await startBridge({ port, log });
|
|
243
|
+
} catch (e) {
|
|
244
|
+
log(`cannot listen on 127.0.0.1:${port}: ${e.code || e.message}`);
|
|
245
|
+
process.exit(3);
|
|
246
|
+
}
|
|
247
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
248
|
+
writeFileSync(path, JSON.stringify({ port: b.port, token: b.token, pid: process.pid, started: Date.now() / 1000 }), { mode: 0o600 });
|
|
249
|
+
log(`listening on 127.0.0.1:${b.port}`);
|
|
250
|
+
const cleanup = () => {
|
|
251
|
+
try {
|
|
252
|
+
if (JSON.parse(readFileSync(path, "utf8")).pid === process.pid) unlinkSync(path);
|
|
253
|
+
} catch {
|
|
254
|
+
/* gone */
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
b.server.on("idle-exit", () => {
|
|
258
|
+
cleanup();
|
|
259
|
+
process.exit(0);
|
|
260
|
+
});
|
|
261
|
+
for (const sig of ["SIGTERM", "SIGINT"]) process.on(sig, () => {
|
|
262
|
+
cleanup();
|
|
263
|
+
process.exit(0);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const isMain = (() => {
|
|
268
|
+
try {
|
|
269
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
})();
|
|
274
|
+
if (isMain) main();
|
|
@@ -0,0 +1,116 @@
|
|
|
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, openSync, closeSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
export const EXT_SRC = join(HERE, "browser-extension");
|
|
14
|
+
export const EXT_FILES = ["manifest.json", "background.js", "popup.html", "popup.js", "icons/icon16.png", "icons/icon48.png", "icons/icon128.png"];
|
|
15
|
+
|
|
16
|
+
const dataDir = () => join(homedir(), ".gonext");
|
|
17
|
+
export const extensionDir = () => process.env.GU_BROWSER_EXT_DIR || join(dataDir(), "browser-extension");
|
|
18
|
+
const statePath = () => process.env.GU_BROWSER_STATE || join(dataDir(), "browser-bridge.json");
|
|
19
|
+
|
|
20
|
+
export function installExtensionFiles(src = EXT_SRC, dst = extensionDir()) {
|
|
21
|
+
mkdirSync(join(dst, "icons"), { recursive: true });
|
|
22
|
+
for (const n of EXT_FILES) {
|
|
23
|
+
const from = join(src, n);
|
|
24
|
+
const to = join(dst, n);
|
|
25
|
+
if (!existsSync(to) || !readFileSync(to).equals(readFileSync(from))) copyFileSync(from, to);
|
|
26
|
+
}
|
|
27
|
+
return dst;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readState() {
|
|
31
|
+
try {
|
|
32
|
+
const st = JSON.parse(readFileSync(statePath(), "utf8"));
|
|
33
|
+
return st.port && st.token ? st : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function status(st, waitMs = 0) {
|
|
40
|
+
const r = await fetch(`http://127.0.0.1:${st.port}/status?wait_ms=${waitMs}`, {
|
|
41
|
+
headers: { authorization: `Bearer ${st.token}` },
|
|
42
|
+
signal: AbortSignal.timeout(waitMs + 3000),
|
|
43
|
+
});
|
|
44
|
+
return r.json();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// → { running, connected, version }. Starts the bridge when it is not running, so an extension
|
|
48
|
+
// installed a moment from now has something to connect to.
|
|
49
|
+
export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
|
|
50
|
+
let st = readState();
|
|
51
|
+
try {
|
|
52
|
+
if (st) return { running: true, ...(await status(st, waitMs)) };
|
|
53
|
+
} catch {
|
|
54
|
+
/* stale state file */
|
|
55
|
+
}
|
|
56
|
+
if (!start) return { running: false, connected: false };
|
|
57
|
+
mkdirSync(dataDir(), { recursive: true });
|
|
58
|
+
const log = openSync(join(dataDir(), "browser-bridge.log"), "a");
|
|
59
|
+
spawn(process.execPath, [join(HERE, "browser-bridge.mjs")], { detached: true, stdio: ["ignore", log, log] }).unref();
|
|
60
|
+
closeSync(log);
|
|
61
|
+
const end = Date.now() + 5000;
|
|
62
|
+
while (Date.now() < end) {
|
|
63
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
64
|
+
st = readState();
|
|
65
|
+
if (!st) continue;
|
|
66
|
+
try {
|
|
67
|
+
return { running: true, ...(await status(st, waitMs)) };
|
|
68
|
+
} catch {
|
|
69
|
+
/* not listening yet */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { running: false, connected: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function browserReport({ connected, version, running }, folder, platform = process.platform) {
|
|
76
|
+
if (connected) {
|
|
77
|
+
return [
|
|
78
|
+
`✓ The gu Chrome extension is connected (v${version}).`,
|
|
79
|
+
"The agent can open and use web pages in its own tabs (the \"gu\" tab",
|
|
80
|
+
"group) to test a UI, click through a flow or fill a form.",
|
|
81
|
+
"Your other tabs are never touched.",
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
const pick = platform === "darwin"
|
|
85
|
+
? [" In the file dialog press ⌘⇧G, paste the folder (it is on your", " clipboard) and press Enter."]
|
|
86
|
+
: [" In the file dialog type or paste the folder (it starts with a dot,", " so it is hidden)."];
|
|
87
|
+
return [
|
|
88
|
+
running ? "The gu Chrome extension is not connected." : "The gu browser bridge could not start (is node working?).",
|
|
89
|
+
"The agent uses it to open, see and click web pages like a person.",
|
|
90
|
+
"To install it:",
|
|
91
|
+
" 1. Open Chrome and go to chrome://extensions",
|
|
92
|
+
" 2. Turn on Developer mode (top right)",
|
|
93
|
+
" 3. Click \"Load unpacked\" and choose this folder:",
|
|
94
|
+
` ${folder}`,
|
|
95
|
+
...pick,
|
|
96
|
+
" 4. Type /browser again — it should say connected.",
|
|
97
|
+
"Already installed? Open Chrome: it reconnects within 30 seconds",
|
|
98
|
+
"(or click the extension's icon → Connect now).",
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// macOS: put the folder on the clipboard and open the extensions page, so step 3 is a paste.
|
|
103
|
+
export function helpInstall(folder, platform = process.platform) {
|
|
104
|
+
if (platform !== "darwin") return false;
|
|
105
|
+
try {
|
|
106
|
+
const cp = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
107
|
+
cp.stdin.end(folder);
|
|
108
|
+
if (existsSync("/Applications/Google Chrome.app")) {
|
|
109
|
+
spawn("open", ["-a", "Google Chrome", "chrome://extensions"], { stdio: "ignore", detached: true }).unref();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* best effort */
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# gu browser — Chrome extension
|
|
2
|
+
|
|
3
|
+
The agent's `browser(...)` tool works through this extension. With it, the agent opens a web page, sees it, and clicks and types with a real mouse and keyboard, to test a UI flow or click through an app. It follows the same idea as Claude in Chrome. The task is #363 (GitHub #144).
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
agent turn (python, a new process each turn)
|
|
7
|
+
│ HTTP + token ~/.gonext/browser-bridge.json (0600)
|
|
8
|
+
▼
|
|
9
|
+
browser-bridge.mjs 127.0.0.1:4127, started on demand, outlives the turn
|
|
10
|
+
│ WebSocket only from a chrome-extension:// origin
|
|
11
|
+
▼
|
|
12
|
+
this extension chrome.debugger → real Input.dispatchMouseEvent / insertText / key events
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
gu's own tabs only the tab group "gu" and the popups those tabs open
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Set up (a user)
|
|
19
|
+
|
|
20
|
+
1. Install or update gu. The extension ships inside the npm package.
|
|
21
|
+
2. Type `/browser` in the gu terminal. It copies the extension to `~/.gonext/browser-extension`, starts the bridge and shows whether the extension is connected. On a Mac it also opens `chrome://extensions` and puts the folder path on the clipboard.
|
|
22
|
+
3. In Chrome open `chrome://extensions` and turn on **Developer mode** (top right).
|
|
23
|
+
4. Click **Load unpacked** and choose `~/.gonext/browser-extension`.
|
|
24
|
+
- **Mac:** in the file dialog press ⌘⇧G and paste the path. The folder starts with a dot, so it is hidden.
|
|
25
|
+
- **Linux / Windows:** type or paste the path.
|
|
26
|
+
5. Type `/browser` again. It should say `✓ The gu Chrome extension is connected`.
|
|
27
|
+
|
|
28
|
+
Some notes:
|
|
29
|
+
|
|
30
|
+
- **Why a fixed folder:** the extension is loaded from `~/.gonext/browser-extension`, not from the npm package, because an update moves the package and Chrome would lose an extension loaded from there.
|
|
31
|
+
- **After a gu update:** gu copies the new files into that folder. Click the reload icon on the extension's card in `chrome://extensions`, or restart Chrome.
|
|
32
|
+
- **Chrome closed or asleep:** the extension reconnects within 30 seconds of Chrome starting. The extension's icon has a **Connect now** button, and it shows a purple `on` badge while connected.
|
|
33
|
+
- **When it is not connected:** the agent's tool answers `BROWSER NOT AVAILABLE` with these steps. The prompt tells the model to stop and ask you, not to work around it.
|
|
34
|
+
- **The "debugging this browser" bar:** Chrome shows "gu browser started debugging this browser" while the agent works. That is the `chrome.debugger` permission, which is what makes a click a real, trusted click.
|
|
35
|
+
- **Other Chromium browsers** (Edge, Brave, Arc, Chromium) load it the same way from their own extensions page.
|
|
36
|
+
|
|
37
|
+
Turning it off:
|
|
38
|
+
|
|
39
|
+
- `GU_BROWSER=0` in the worker's environment removes the tool.
|
|
40
|
+
- Removing the extension in `chrome://extensions` disconnects it.
|
|
41
|
+
|
|
42
|
+
## Develop
|
|
43
|
+
|
|
44
|
+
| What | How |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| Bridge (frames, origin/host/token checks) | `node --test tests/browser-bridge.test.mjs` |
|
|
47
|
+
| `/browser` text and file copy | `node --test tests/browser-command.test.mjs` |
|
|
48
|
+
| Python client and agent turns, with a stand-in extension | `python3 -m unittest tests.test_browser` |
|
|
49
|
+
| **Real Chrome**, this extension unpacked (15 checks; the click must be trusted) | see below |
|
|
50
|
+
|
|
51
|
+
The real-Chrome check needs **Chrome for Testing** or Chromium. Branded Chrome has ignored `--load-extension` since version 137.
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
curl -sSfLO https://storage.googleapis.com/chrome-for-testing-public/<version>/mac-arm64/chrome-mac-arm64.zip
|
|
55
|
+
ditto -x -k chrome-mac-arm64.zip cft/ # not npx @puppeteer/browsers: it left the app without Frameworks
|
|
56
|
+
pkill -f "Google Chrome for Testing" # a leftover one on the same profile swallows the launch
|
|
57
|
+
GU_E2E_CHROME="cft/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" \
|
|
58
|
+
python3 scripts/browser-e2e.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The check uses port 4127, so stop any other gu bridge first.
|
|
62
|
+
|
|
63
|
+
To work on the extension in your own Chrome:
|
|
64
|
+
|
|
65
|
+
1. Load `tools/gonext-local-worker/browser-extension` itself with **Load unpacked**.
|
|
66
|
+
2. After an edit, click reload on its card.
|
|
67
|
+
3. Its service worker console is under **Inspect views: service worker**.
|
|
68
|
+
|
|
69
|
+
`connect()` must set its `connecting` flag before its first `await`. Chrome fires it several times at startup, and two sockets made every command hang.
|
|
70
|
+
|
|
71
|
+
Which files make the extension is listed in two places, and both must change together:
|
|
72
|
+
|
|
73
|
+
- `EXT_FILES` in `gu_browser.py` and in `browser-command.mjs`: what gets copied and packed.
|
|
74
|
+
- `files` in `package.json`: what ships in the npm package.
|
|
75
|
+
|
|
76
|
+
## Publish to the Chrome Web Store
|
|
77
|
+
|
|
78
|
+
Publishing is optional; Load unpacked works without it. Once published, a user installs with one click, and Chrome updates the extension by itself.
|
|
79
|
+
|
|
80
|
+
1. **Bump the version.** Raise `"version"` in `manifest.json`. The store refuses a version it has seen.
|
|
81
|
+
2. **Build the zip.**
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
python3 scripts/pack-browser-extension.py # → dist/gu-browser-<version>.zip (dist/ is git-ignored)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It packs exactly `EXT_FILES`, with `manifest.json` at the zip's root, and checks the icons exist.
|
|
88
|
+
3. **Developer account** (once). Register at <https://chrome.google.com/webstore/devconsole>; there is a one-time US$5 fee. Verify the contact email.
|
|
89
|
+
4. **New item.** Choose **Add new item**, upload the zip, then fill in the listing:
|
|
90
|
+
- **Description:** what it does and that it needs the gu CLI. Copy from the top of this file.
|
|
91
|
+
- **Category:** Developer Tools.
|
|
92
|
+
- **Images:** the 128×128 icon (`icons/icon128.png`) and at least one 1280×800 screenshot. A page in the "gu" tab group with the `on` badge works.
|
|
93
|
+
- **Visibility:** **Unlisted** for gu users only (installed from the link that `/browser` can print), or **Public**.
|
|
94
|
+
5. **Privacy tab.** The review needs all of the following:
|
|
95
|
+
- **Single purpose:** "Lets the gu coding agent on this computer open, read and operate web pages in its own tab group, for testing web UIs."
|
|
96
|
+
- **Permission justifications:**
|
|
97
|
+
- `debugger`: send real mouse and keyboard input, take screenshots, and read console errors in gu's own tabs.
|
|
98
|
+
- `tabs`, `tabGroups`: open gu's tabs and keep them in the "gu" group.
|
|
99
|
+
- `alarms`: reconnect to the local gu bridge after Chrome puts the worker to sleep.
|
|
100
|
+
- `storage`: remember which tabs are gu's.
|
|
101
|
+
- Host permission `<all_urls>`: the agent may be asked to test any site, including localhost.
|
|
102
|
+
- **Remote code:** "No". Everything runs from the packaged files.
|
|
103
|
+
- **Data use:** website content is read only on the user's machine and sent only to the gu bridge on 127.0.0.1. Nothing is sold or transferred, and nothing is used for advertising.
|
|
104
|
+
- **Privacy policy URL:** required, because the extension handles website content. Host a short page that states the above.
|
|
105
|
+
6. **Submit for review.** `debugger` and `<all_urls>` trigger an in-depth review, which can take days to a few weeks. Answer any questions in the developer console.
|
|
106
|
+
7. **After approval:**
|
|
107
|
+
- The store build has a fixed extension ID. The bridge accepts any `chrome-extension://` origin, so nothing in gu changes for it.
|
|
108
|
+
- Put the store link in `/browser` (`browserReport` in `browser-command.mjs`) as the first option, and keep Load unpacked as the fallback.
|
|
109
|
+
- **Each later release:** bump the version, run `pack-browser-extension.py`, then **Package → Upload new package → Submit**.
|
|
110
|
+
|
|
111
|
+
Microsoft Edge Add-ons (<https://partner.microsoft.com/dashboard/microsoftedge>) takes the same zip, with a similar listing and review.
|