@tiens.nguyen/gu-cli 1.0.834 → 1.0.836
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 -1
- package/browser-bridge.mjs +72 -11
- package/browser-command.mjs +214 -20
- package/browser-extension/README.md +67 -27
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +100 -322
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/manifest.json +3 -1
- package/browser-extension/popup.html +4 -0
- package/browser-extension/popup.js +23 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +424 -59
- package/gu-cli.mjs +45 -15
- package/gu-repl.mjs +185 -26
- package/gu_browser.py +92 -20
- package/gu_powershell.py +675 -0
- package/install-path.mjs +19 -2
- package/package.json +5 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +95 -26
- package/terminal-layout.mjs +1 -0
- package/win-path.mjs +185 -0
package/README.md
CHANGED
|
@@ -57,7 +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
|
|
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) |
|
|
61
61
|
| `/tokens` | break down the last prompt sent to the coding model — how much of it was RAG |
|
|
62
62
|
| `/prices [update]` | price and cache tables, and what the last turn and this session cost |
|
|
63
63
|
| `/rag` | where this folder's RAG knowledge base lives: local, cloud, or both |
|
package/browser-bridge.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { mkdirSync, writeFileSync, readFileSync, realpathSync, unlinkSync } from
|
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { OwnChrome } from "./browser-chrome.mjs";
|
|
20
21
|
|
|
21
22
|
export const DEFAULT_PORT = 4127;
|
|
22
23
|
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
@@ -27,7 +28,7 @@ export function statePath() {
|
|
|
27
28
|
|
|
28
29
|
// ── WebSocket frames ────────────────────────────────────────────────────────────────────────
|
|
29
30
|
export function encodeFrame(text, opcode = 1) {
|
|
30
|
-
const payload = Buffer.from(text);
|
|
31
|
+
const payload = Buffer.isBuffer(text) ? text : Buffer.from(text);
|
|
31
32
|
const n = payload.length;
|
|
32
33
|
const head = n < 126 ? Buffer.alloc(2) : n < 65536 ? Buffer.alloc(4) : Buffer.alloc(10);
|
|
33
34
|
head[0] = 0x80 | opcode;
|
|
@@ -73,12 +74,22 @@ export function decodeFrames(buf) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
// ── the bridge ──────────────────────────────────────────────────────────────────────────────
|
|
76
|
-
|
|
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 } = {}) {
|
|
77
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;
|
|
78
87
|
let nextId = 1;
|
|
79
88
|
const pending = new Map(); // id → { resolve, timer }
|
|
80
89
|
const waiters = new Set(); // resolve() when the extension connects
|
|
81
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
|
|
82
93
|
|
|
83
94
|
const hostOk = (h) => /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(String(h || ""));
|
|
84
95
|
|
|
@@ -105,6 +116,23 @@ export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toStr
|
|
|
105
116
|
});
|
|
106
117
|
}
|
|
107
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
|
+
|
|
108
136
|
function waitConnected(ms) {
|
|
109
137
|
if (ext || ms <= 0) return Promise.resolve(Boolean(ext));
|
|
110
138
|
return new Promise((resolve) => {
|
|
@@ -130,7 +158,16 @@ export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toStr
|
|
|
130
158
|
if (req.method === "GET" && url.pathname === "/status") {
|
|
131
159
|
const waitMs = Math.min(Number(url.searchParams.get("wait_ms")) || 0, 60000);
|
|
132
160
|
await waitConnected(waitMs);
|
|
133
|
-
|
|
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
|
+
});
|
|
134
171
|
}
|
|
135
172
|
if (req.method === "POST" && url.pathname === "/cmd") {
|
|
136
173
|
let body = "";
|
|
@@ -142,9 +179,14 @@ export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toStr
|
|
|
142
179
|
return json(400, { error: "bad json" });
|
|
143
180
|
}
|
|
144
181
|
const timeoutMs = Math.min(Math.max(Number(cmd.timeout_ms) || 60000, 1000), 120000);
|
|
182
|
+
const backend = cmd.backend || "extension";
|
|
145
183
|
delete cmd.timeout_ms;
|
|
146
|
-
|
|
147
|
-
|
|
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));
|
|
148
190
|
}
|
|
149
191
|
json(404, { error: "not found" });
|
|
150
192
|
});
|
|
@@ -156,11 +198,26 @@ export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toStr
|
|
|
156
198
|
socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
157
199
|
return;
|
|
158
200
|
}
|
|
201
|
+
socket.setNoDelay(true);
|
|
202
|
+
sockets.add(socket);
|
|
203
|
+
socket.on("close", () => sockets.delete(socket));
|
|
159
204
|
const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
160
205
|
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 };
|
|
164
221
|
log(`extension connected (${origin})`);
|
|
165
222
|
let buf = Buffer.alloc(0);
|
|
166
223
|
let parts = [];
|
|
@@ -189,7 +246,10 @@ export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toStr
|
|
|
189
246
|
continue;
|
|
190
247
|
}
|
|
191
248
|
if (msg.type === "hello") {
|
|
192
|
-
if (ext?.socket === socket)
|
|
249
|
+
if (ext?.socket === socket) {
|
|
250
|
+
ext.version = msg.version || "?";
|
|
251
|
+
ext.installType = msg.installType || null;
|
|
252
|
+
}
|
|
193
253
|
for (const w of [...waiters]) w(true);
|
|
194
254
|
} else if (msg.id !== undefined && pending.has(msg.id)) {
|
|
195
255
|
const p = pending.get(msg.id);
|
|
@@ -208,7 +268,7 @@ export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toStr
|
|
|
208
268
|
log("idle — exiting");
|
|
209
269
|
server.close();
|
|
210
270
|
clearInterval(idle);
|
|
211
|
-
|
|
271
|
+
for (const so of sockets) so.destroy();
|
|
212
272
|
server.emit("idle-exit");
|
|
213
273
|
}
|
|
214
274
|
}, Math.min(idleMs, 60000));
|
|
@@ -224,7 +284,8 @@ export function startBridge({ port = DEFAULT_PORT, token = randomBytes(24).toStr
|
|
|
224
284
|
connected: () => Boolean(ext),
|
|
225
285
|
close: () => {
|
|
226
286
|
clearInterval(idle);
|
|
227
|
-
|
|
287
|
+
for (const so of sockets) so.destroy();
|
|
288
|
+
server.closeAllConnections?.();
|
|
228
289
|
return new Promise((r) => server.close(() => r()));
|
|
229
290
|
},
|
|
230
291
|
});
|
package/browser-command.mjs
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// package: an update moves the package and Chrome would lose an unpacked extension loaded there.
|
|
5
5
|
// The files are copied there (again after an update) every time this runs, as the agent does.
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, openSync, closeSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, openSync, closeSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { dirname, join } from "node:path";
|
|
9
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { sameProcess } from "./proc-identity.mjs";
|
|
11
12
|
|
|
12
13
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
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
|
+
export const EXT_FILES = ["manifest.json", "background.js", "actions.js", "popup.html", "popup.js", "icons/icon16.png", "icons/icon48.png", "icons/icon128.png"];
|
|
15
16
|
|
|
16
17
|
const dataDir = () => join(homedir(), ".gonext");
|
|
17
18
|
export const extensionDir = () => process.env.GU_BROWSER_EXT_DIR || join(dataDir(), "browser-extension");
|
|
@@ -72,45 +73,238 @@ export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
|
|
|
72
73
|
return { running: false, connected: false };
|
|
73
74
|
}
|
|
74
75
|
|
|
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).";
|
|
76
119
|
if (connected) {
|
|
120
|
+
const kind = installType === "development" ? ", developer — loaded unpacked" : installType === "normal" ? ", Chrome Web Store" : "";
|
|
77
121
|
return [
|
|
78
|
-
`✓ The gu Chrome extension is connected (v${version}).`,
|
|
79
|
-
"The agent
|
|
80
|
-
"
|
|
81
|
-
|
|
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,
|
|
82
127
|
];
|
|
83
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
|
+
}
|
|
84
147
|
const pick = platform === "darwin"
|
|
85
148
|
? [" In the file dialog press ⌘⇧G, paste the folder (it is on your", " clipboard) and press Enter."]
|
|
86
149
|
: [" In the file dialog type or paste the folder (it starts with a dot,", " so it is hidden)."];
|
|
87
150
|
return [
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
"To install it:",
|
|
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):",
|
|
91
153
|
" 1. Open Chrome and go to chrome://extensions",
|
|
92
154
|
" 2. Turn on Developer mode (top right)",
|
|
93
155
|
" 3. Click \"Load unpacked\" and choose this folder:",
|
|
94
156
|
` ${folder}`,
|
|
95
157
|
...pick,
|
|
96
|
-
" 4. Type /browser
|
|
97
|
-
"Already installed? Open Chrome: it reconnects within 30 seconds",
|
|
98
|
-
"(or click the extension's icon → Connect now).",
|
|
158
|
+
" 4. Type /browser — it should say connected.",
|
|
99
159
|
];
|
|
100
160
|
}
|
|
101
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
|
+
|
|
102
174
|
// macOS: put the folder on the clipboard and open the extensions page, so step 3 is a paste.
|
|
103
175
|
export function helpInstall(folder, platform = process.platform) {
|
|
104
|
-
if (platform !== "darwin") return false;
|
|
176
|
+
if (platform !== "darwin" || process.env.GU_NO_OPEN_APPS) return false;
|
|
105
177
|
try {
|
|
106
178
|
const cp = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
107
179
|
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
180
|
} catch {
|
|
113
181
|
/* best effort */
|
|
114
182
|
}
|
|
115
|
-
return
|
|
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;
|
|
116
310
|
}
|
|
@@ -1,43 +1,75 @@
|
|
|
1
|
-
# gu browser —
|
|
1
|
+
# gu browser — the agent's browser
|
|
2
2
|
|
|
3
|
-
The agent's `browser(...)` tool
|
|
3
|
+
The agent's `browser(...)` tool 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 tasks are #363 (GitHub #144) and #369 (GitHub #150).
|
|
4
|
+
|
|
5
|
+
**Nothing has to be installed.** The first time a turn uses the browser, gu asks you. After you allow it, the agent works in one of two places:
|
|
6
|
+
|
|
7
|
+
- **Your own Chrome**, through this extension, if the extension is installed. The agent works in its own tabs (the "gu" tab group) and in any tab you share, and it has your logins.
|
|
8
|
+
- **gu's own Chrome window**, if the extension is not installed. The bridge starts a Chrome of its own: a visible window on a separate profile (`~/.gonext/browser-profile`), started with its DevTools port open. It is not signed in to your accounts; sign in there once if a test needs it, and the profile keeps it.
|
|
4
9
|
|
|
5
10
|
```
|
|
6
|
-
agent turn (python, a new process each turn)
|
|
11
|
+
agent turn (python, a new process each turn) — asks you first (#150)
|
|
7
12
|
│ HTTP + token ~/.gonext/browser-bridge.json (0600)
|
|
8
13
|
▼
|
|
9
14
|
browser-bridge.mjs 127.0.0.1:4127, started on demand, outlives the turn
|
|
10
|
-
│
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
▼
|
|
15
|
-
gu's own tabs only the tab group "gu" and the popups those tabs open
|
|
15
|
+
│ the extension when it is connected, else gu's own window
|
|
16
|
+
├─ WebSocket (only a chrome-extension:// origin) → this extension → chrome.debugger
|
|
17
|
+
└─ DevTools WebSocket → gu's own Chrome window (browser-chrome.mjs)
|
|
18
|
+
both run the same actions.js: real Input.dispatchMouseEvent / insertText / key events
|
|
16
19
|
```
|
|
17
20
|
|
|
18
|
-
##
|
|
21
|
+
## Use it (a user)
|
|
22
|
+
|
|
23
|
+
- **Ask gu to check a page.** The first browser call of a turn shows:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
🌐 Allow gu to use a web browser?
|
|
27
|
+
It wants to: open http://localhost:5173
|
|
28
|
+
In your Chrome if the gu extension is there, else in gu's own Chrome window.
|
|
29
|
+
▸ Yes, this time
|
|
30
|
+
Yes, always in this folder
|
|
31
|
+
No
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
"Always" adds the folder to `~/.gonext/browser-allow.json`. A "No" holds for the rest of the turn, and the model carries on without the browser.
|
|
35
|
+
- **`/browser`** shows where the agent works and whether it asks in this folder.
|
|
36
|
+
- **`/browser allow` / `/browser forget`** stop or restart the question for this folder.
|
|
37
|
+
- **Share a tab** (extension only): click the gu icon on a tab, then **Let gu use this tab**. The tab joins the "gu" group and the agent can use it. **Stop sharing this tab** takes it back.
|
|
38
|
+
- **Turning it off:** `GU_BROWSER=0` in the worker's environment removes the tool.
|
|
39
|
+
|
|
40
|
+
### The extension, to use your own Chrome (optional)
|
|
41
|
+
|
|
42
|
+
Chrome lets no program turn on Developer mode or load an unpacked extension, which is why the default is gu's own window.
|
|
19
43
|
|
|
20
|
-
|
|
21
|
-
|
|
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`.
|
|
44
|
+
- **Once the extension is on the Chrome Web Store:** `/browser install` opens its page; click **Add to Chrome**. No toggle is involved. Set `GU_BROWSER_STORE_URL`, then put the link in `STORE_URL` once it is published.
|
|
45
|
+
- **Until then, the developer way** (`/browser install` shows it and copies the folder): turn on **Developer mode** in `chrome://extensions`, click **Load unpacked**, and choose `~/.gonext/browser-extension` (on a Mac, ⌘⇧G and paste). `/browser` should then say connected.
|
|
27
46
|
|
|
28
47
|
Some notes:
|
|
29
48
|
|
|
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
|
-
- **
|
|
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.
|
|
49
|
+
- **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. After a gu update, click reload on the extension's card.
|
|
50
|
+
- **Chrome closed or asleep:** the extension reconnects within 30 seconds of Chrome starting, or immediately with its icon's **Connect now** button. It shows a purple `on` badge while connected. Meanwhile the agent uses gu's own window.
|
|
34
51
|
- **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
|
|
52
|
+
- **Other Chromium browsers** (Edge, Brave, Arc, Chromium) load it the same way. gu's own window uses the first of Chrome, Chromium, Edge or Brave it finds; set `GU_BROWSER_CHROME` to choose.
|
|
36
53
|
|
|
37
|
-
|
|
54
|
+
## Switch from the developer install to the store one
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
Run this before installing the Chrome Web Store copy (#145):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
/browser cleanup
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
1. **It lists what it found:**
|
|
63
|
+
- every Chrome, Chrome Beta/Canary, Chromium, Edge and Brave profile with "gu browser" loaded unpacked. It reads each profile's `Preferences` and `Secure Preferences` and never writes them;
|
|
64
|
+
- the connected copy;
|
|
65
|
+
- gu's files in `~/.gonext`: `browser-extension/`, `browser-bridge.json`, `browser-extension-seen`, `browser-bridge.log`.
|
|
66
|
+
2. **On "Clean up", the connected developer copy removes itself** with `chrome.management.uninstallSelf`, which needs no extra permission. A store copy is never removed this way.
|
|
67
|
+
3. **Copies in other profiles need your click.** Chrome signs its profile settings, so no other program can uninstall an extension. gu opens `chrome://extensions` (on a Mac) and waits while you click Remove on "gu browser" in each one.
|
|
68
|
+
4. **Then it stops the bridge, deletes gu's files, scans again,** and says either `✓ The developer install is gone` or what is still there.
|
|
69
|
+
|
|
70
|
+
Once the store copy is installed, `/browser` shows `(… Chrome Web Store)` and does not copy the developer folder back. The agent's not-connected answer then says "open Chrome", not "Load unpacked".
|
|
71
|
+
|
|
72
|
+
If both copies stay installed, the bridge keeps the one that connected first and turns the other away with close code 4001, and the refused copy retries every 30 s instead of every 3 s. `/browser` warns `⚠ A second gu extension … — /browser cleanup removes the developer copy`.
|
|
41
73
|
|
|
42
74
|
## Develop
|
|
43
75
|
|
|
@@ -46,7 +78,8 @@ Turning it off:
|
|
|
46
78
|
| Bridge (frames, origin/host/token checks) | `node --test tests/browser-bridge.test.mjs` |
|
|
47
79
|
| `/browser` text and file copy | `node --test tests/browser-command.test.mjs` |
|
|
48
80
|
| Python client and agent turns, with a stand-in extension | `python3 -m unittest tests.test_browser` |
|
|
49
|
-
| **Real Chrome**, this extension unpacked (
|
|
81
|
+
| **Real Chrome**, two copies of this extension unpacked (20 checks: the click must be trusted; one copy refused; the developer copy removes itself) | see below |
|
|
82
|
+
| **Real Chrome, gu's own window** (no extension; 16 checks) | the same script with `--own` |
|
|
50
83
|
|
|
51
84
|
The real-Chrome check needs **Chrome for Testing** or Chromium. Branded Chrome has ignored `--load-extension` since version 137.
|
|
52
85
|
|
|
@@ -58,7 +91,7 @@ GU_E2E_CHROME="cft/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS
|
|
|
58
91
|
python3 scripts/browser-e2e.py
|
|
59
92
|
```
|
|
60
93
|
|
|
61
|
-
The check
|
|
94
|
+
The check runs on a free port, with copies of the extension pointed at that port, so a gu running on 4127 with your own Chrome is left alone.
|
|
62
95
|
|
|
63
96
|
To work on the extension in your own Chrome:
|
|
64
97
|
|
|
@@ -75,6 +108,13 @@ Which files make the extension is listed in two places, and both must change tog
|
|
|
75
108
|
|
|
76
109
|
## Publish to the Chrome Web Store
|
|
77
110
|
|
|
111
|
+
**The quick way (steps 1–2):** in the deploy app (`electric-app-deploy`), choose patch / minor / major next to **Build Chrome extension** and press it. The button:
|
|
112
|
+
- raises the version in `manifest.json`;
|
|
113
|
+
- checks that the scripts parse, that every file the manifest names is there, and that the bridge and `/browser` tests pass;
|
|
114
|
+
- writes `release/chrome-extension/gu-browser-<version>.zip` (git-ignored) and its `.sha256` at the repo root.
|
|
115
|
+
|
|
116
|
+
If any step fails, the old version is put back. The panel has **Show zip in Finder** and **Open store dashboard**. Nothing is uploaded or committed: upload the zip, then commit `manifest.json`.
|
|
117
|
+
|
|
78
118
|
Publishing is optional; Load unpacked works without it. Once published, a user installs with one click, and Chrome updates the extension by itself.
|
|
79
119
|
|
|
80
120
|
1. **Bump the version.** Raise `"version"` in `manifest.json`. The store refuses a version it has seen.
|