unbrowse 12.0.1 → 12.1.0
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/SKILL.md +11 -0
- package/dist/cli.js +357 -163
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -25,6 +25,17 @@ CLI installation and SDK instructions: https://github.com/unbrowse-ai/unbrowse-s
|
|
|
25
25
|
Use the client version identified there; older npm versions may target a different service.
|
|
26
26
|
For automation, supply an API key via `UNBROWSE_API_KEY` using the caller's secret manager. Do not put keys into committed config, prompts, command arguments or logs. Create a key in the signed-in console at https://unbrowse.ai/app.
|
|
27
27
|
|
|
28
|
+
## Host plugins
|
|
29
|
+
|
|
30
|
+
Packaged installs that bundle this skill, connect Unbrowse and redirect the host's own browser to it (https://github.com/unbrowse-ai/unbrowse/tree/main/plugins):
|
|
31
|
+
|
|
32
|
+
- Claude Code: `claude plugin marketplace add unbrowse-ai/unbrowse`, then `claude plugin install unbrowse@unbrowse`.
|
|
33
|
+
- Codex: `codex plugin marketplace add unbrowse-ai/unbrowse`, then `codex plugin add unbrowse@unbrowse`; set `web_search = "disabled"` to drop built-in search.
|
|
34
|
+
- Grok Build: `grok plugin install unbrowse-ai/unbrowse#plugins/grok-build --trust`.
|
|
35
|
+
- OpenClaw (`@unbrowse/openclaw`), Hermes (`plugins/hermes`), elizaOS (`@unbrowse/plugin-unbrowse`): native tools or actions over the same MCP.
|
|
36
|
+
|
|
37
|
+
Hosts that need a stdio server or reject dotted tool names can run `npx unbrowse mcp`: tool names there use `_` (`unbrowse_scrape`). With a plugin installed, built-in web fetch, web search and browser navigation to non-local URLs are refused with a pointer to the matching Unbrowse tool; `UNBROWSE_ALLOW_BUILTIN_BROWSER=1` lifts that for a session.
|
|
38
|
+
|
|
28
39
|
## Choose the interface
|
|
29
40
|
|
|
30
41
|
- MCP: discovery, runs, page reading, cloud browsing, saved-login requests and live canvas cards.
|
package/dist/cli.js
CHANGED
|
@@ -1,169 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { readFileSync as readFileSync2 } from "node:fs";
|
|
7
|
-
|
|
8
|
-
// src/auth.ts
|
|
9
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { createServer } from "node:http";
|
|
12
|
-
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
14
|
-
function configPath() {
|
|
15
|
-
const root = process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
|
|
16
|
-
return join(root, "cli.json");
|
|
17
|
-
}
|
|
18
|
-
function load() {
|
|
19
|
-
const path = configPath();
|
|
20
|
-
if (!existsSync(path))
|
|
21
|
-
return {};
|
|
22
|
-
try {
|
|
23
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
-
} catch {
|
|
25
|
-
return {};
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
function save(stored) {
|
|
29
|
-
const path = configPath();
|
|
30
|
-
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
31
|
-
writeFileSync(path, JSON.stringify(stored, null, 2), { mode: 384 });
|
|
32
|
-
chmodSync(path, 384);
|
|
33
|
-
}
|
|
34
|
-
function clear() {
|
|
35
|
-
rmSync(configPath(), { force: true });
|
|
36
|
-
}
|
|
37
|
-
async function currentToken(baseUrl, fetchImpl = fetch) {
|
|
38
|
-
if (process.env.UNBROWSE_API_KEY)
|
|
39
|
-
return process.env.UNBROWSE_API_KEY;
|
|
40
|
-
const stored = load();
|
|
41
|
-
if (stored.baseUrl && stored.baseUrl !== baseUrl)
|
|
42
|
-
return;
|
|
43
|
-
if (stored.apiKey)
|
|
44
|
-
return stored.apiKey;
|
|
45
|
-
const o = stored.oauth;
|
|
46
|
-
if (!o)
|
|
47
|
-
return;
|
|
48
|
-
if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
|
|
2
|
+
var __esm = (fn, res, err) => () => {
|
|
3
|
+
if (fn)
|
|
49
4
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
} catch {
|
|
54
|
-
return o.accessToken;
|
|
5
|
+
res = fn(fn = 0);
|
|
6
|
+
} catch (e) {
|
|
7
|
+
err = [e];
|
|
55
8
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (process.env.UNBROWSE_API_KEY)
|
|
61
|
-
return "API key from UNBROWSE_API_KEY";
|
|
62
|
-
const stored = load();
|
|
63
|
-
if (stored.baseUrl && stored.baseUrl !== baseUrl)
|
|
64
|
-
return `not signed in to ${baseUrl} (stored login is for ${stored.baseUrl})`;
|
|
65
|
-
if (stored.apiKey)
|
|
66
|
-
return `API key ${mask(stored.apiKey)} in ${configPath()}`;
|
|
67
|
-
if (stored.oauth)
|
|
68
|
-
return `OAuth sign-in in ${configPath()}`;
|
|
69
|
-
return "not signed in";
|
|
70
|
-
}
|
|
71
|
-
function mask(key) {
|
|
72
|
-
return key.length <= 12 ? "…" : `${key.slice(0, 8)}…${key.slice(-4)}`;
|
|
73
|
-
}
|
|
74
|
-
function apiResource(baseUrl) {
|
|
75
|
-
return `${baseUrl.replace(/\/+$/, "")}/api`;
|
|
76
|
-
}
|
|
77
|
-
async function tokenRequest(baseUrl, form, fetchImpl) {
|
|
78
|
-
const res = await fetchImpl(`${baseUrl}/oauth/token`, {
|
|
79
|
-
method: "POST",
|
|
80
|
-
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
81
|
-
body: new URLSearchParams(form).toString()
|
|
82
|
-
});
|
|
83
|
-
const body = await res.json().catch(() => ({}));
|
|
84
|
-
if (!res.ok || !body.access_token)
|
|
85
|
-
throw new Error(body.error_description ?? body.error ?? `token endpoint answered HTTP ${res.status}`);
|
|
86
|
-
return { accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined };
|
|
87
|
-
}
|
|
88
|
-
var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
89
|
-
async function oauthLogin(baseUrl, opts) {
|
|
90
|
-
const fetchImpl = opts.fetch ?? fetch;
|
|
91
|
-
const verifier = b64url(randomBytes(32));
|
|
92
|
-
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
93
|
-
const state = b64url(randomBytes(16));
|
|
94
|
-
let settle;
|
|
95
|
-
const got = new Promise((r) => settle = r);
|
|
96
|
-
const server = createServer((req, res) => {
|
|
97
|
-
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
98
|
-
if (u.pathname !== "/callback") {
|
|
99
|
-
res.writeHead(404).end();
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
|
|
103
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
104
|
-
res.end(ok ? "<p>Signed in to Unbrowse. You can close this tab.</p>" : "<p>Sign-in failed. Return to the terminal.</p>");
|
|
105
|
-
settle(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? "state mismatch" });
|
|
106
|
-
});
|
|
107
|
-
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
108
|
-
const port = server.address().port;
|
|
109
|
-
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
110
|
-
try {
|
|
111
|
-
const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
|
|
112
|
-
method: "POST",
|
|
113
|
-
headers: { "content-type": "application/json", accept: "application/json" },
|
|
114
|
-
body: JSON.stringify({
|
|
115
|
-
client_name: "Unbrowse CLI",
|
|
116
|
-
redirect_uris: [redirectUri],
|
|
117
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
118
|
-
response_types: ["code"],
|
|
119
|
-
token_endpoint_auth_method: "none"
|
|
120
|
-
})
|
|
121
|
-
});
|
|
122
|
-
const client = await reg.json().catch(() => ({}));
|
|
123
|
-
if (!reg.ok || !client.client_id)
|
|
124
|
-
throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
|
|
125
|
-
const url = new URL(`${baseUrl}/authorize`);
|
|
126
|
-
url.search = new URLSearchParams({
|
|
127
|
-
response_type: "code",
|
|
128
|
-
client_id: client.client_id,
|
|
129
|
-
redirect_uri: redirectUri,
|
|
130
|
-
code_challenge: challenge,
|
|
131
|
-
code_challenge_method: "S256",
|
|
132
|
-
state,
|
|
133
|
-
scope: "unbrowse",
|
|
134
|
-
resource: apiResource(baseUrl)
|
|
135
|
-
}).toString();
|
|
136
|
-
opts.log(`Opening ${url.origin}/authorize in your browser. If it does not open, visit:
|
|
137
|
-
${url.toString()}`);
|
|
138
|
-
opts.open(url.toString());
|
|
139
|
-
const timer = setTimeout(() => settle({ error: "timed out waiting for the browser sign-in" }), opts.timeoutMs ?? 300000);
|
|
140
|
-
const answer = await got;
|
|
141
|
-
clearTimeout(timer);
|
|
142
|
-
if (!answer.code)
|
|
143
|
-
throw new Error(answer.error ?? "sign-in failed");
|
|
144
|
-
const tokens = await tokenRequest(baseUrl, { grant_type: "authorization_code", code: answer.code, redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier, resource: apiResource(baseUrl) }, fetchImpl);
|
|
145
|
-
save({ baseUrl, oauth: { clientId: client.client_id, ...tokens } });
|
|
146
|
-
} finally {
|
|
147
|
-
server.close();
|
|
148
|
-
}
|
|
149
|
-
}
|
|
9
|
+
if (err)
|
|
10
|
+
throw err[0];
|
|
11
|
+
return res;
|
|
12
|
+
};
|
|
150
13
|
|
|
151
14
|
// ../sdk/src/client.ts
|
|
152
|
-
var DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1";
|
|
153
|
-
|
|
154
|
-
class UnbrowseError extends Error {
|
|
155
|
-
status;
|
|
156
|
-
code;
|
|
157
|
-
body;
|
|
158
|
-
constructor(message, status, code, body) {
|
|
159
|
-
super(message);
|
|
160
|
-
this.status = status;
|
|
161
|
-
this.code = code;
|
|
162
|
-
this.body = body;
|
|
163
|
-
this.name = "UnbrowseError";
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
var env = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
|
|
167
15
|
function apiBase(url) {
|
|
168
16
|
const u = url.replace(/\/+$/, "");
|
|
169
17
|
return /\/api\/v1$/.test(u) ? u : `${u}/api/v1`;
|
|
@@ -311,8 +159,341 @@ function normalizeHost(site) {
|
|
|
311
159
|
host = new URL(host).hostname;
|
|
312
160
|
return host.replace(/\/.*$/, "").replace(/^www\./, "");
|
|
313
161
|
}
|
|
162
|
+
var DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1", UnbrowseError, env = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
|
|
163
|
+
var init_client = __esm(() => {
|
|
164
|
+
UnbrowseError = class UnbrowseError extends Error {
|
|
165
|
+
status;
|
|
166
|
+
code;
|
|
167
|
+
body;
|
|
168
|
+
constructor(message, status, code, body) {
|
|
169
|
+
super(message);
|
|
170
|
+
this.status = status;
|
|
171
|
+
this.code = code;
|
|
172
|
+
this.body = body;
|
|
173
|
+
this.name = "UnbrowseError";
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
// ../sdk/src/mcp.ts
|
|
178
|
+
function parseRpcBody(text) {
|
|
179
|
+
const trimmed = text.trim();
|
|
180
|
+
if (trimmed.startsWith("{"))
|
|
181
|
+
return JSON.parse(trimmed);
|
|
182
|
+
const events = trimmed.split(/\n\n+/).flatMap((block) => {
|
|
183
|
+
const data = block.split(`
|
|
184
|
+
`).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join(`
|
|
185
|
+
`);
|
|
186
|
+
return data ? [data] : [];
|
|
187
|
+
});
|
|
188
|
+
if (!events.length)
|
|
189
|
+
throw new UnbrowseError("Empty MCP response", 502, "mcp_empty");
|
|
190
|
+
return JSON.parse(events[events.length - 1]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
class UnbrowseMcp {
|
|
194
|
+
url;
|
|
195
|
+
apiKey;
|
|
196
|
+
endUser;
|
|
197
|
+
client;
|
|
198
|
+
fetchImpl;
|
|
199
|
+
nextId = 1;
|
|
200
|
+
constructor(opts = {}) {
|
|
201
|
+
this.url = (opts.url ?? env2("UNBROWSE_MCP_URL") ?? DEFAULT_MCP_URL).replace(/\/+$/, "");
|
|
202
|
+
this.apiKey = opts.apiKey ?? env2("UNBROWSE_API_KEY");
|
|
203
|
+
this.endUser = opts.endUser ?? env2("UNBROWSE_END_USER");
|
|
204
|
+
this.client = opts.client ?? "sdk";
|
|
205
|
+
this.fetchImpl = opts.fetch ?? ((...a) => globalThis.fetch(...a));
|
|
206
|
+
}
|
|
207
|
+
async rpc(method, params) {
|
|
208
|
+
const res = await this.fetchImpl(this.url, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {},
|
|
212
|
+
...this.endUser ? { "x-unbrowse-end-user": this.endUser } : {},
|
|
213
|
+
"content-type": "application/json",
|
|
214
|
+
accept: "application/json, text/event-stream",
|
|
215
|
+
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
216
|
+
"user-agent": `unbrowse-${this.client}`
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method, ...params ? { params } : {} })
|
|
219
|
+
});
|
|
220
|
+
const text = await res.text();
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
let body = {};
|
|
223
|
+
try {
|
|
224
|
+
body = JSON.parse(text);
|
|
225
|
+
} catch {}
|
|
226
|
+
const err = typeof body.error === "object" ? body.error : undefined;
|
|
227
|
+
throw new UnbrowseError(err?.message ?? body.error_description ?? (res.statusText || `HTTP ${res.status}`), res.status, err?.code ?? (typeof body.error === "string" ? body.error : "http_error"), body);
|
|
228
|
+
}
|
|
229
|
+
const msg = parseRpcBody(text);
|
|
230
|
+
if (msg.error)
|
|
231
|
+
throw new UnbrowseError(msg.error.message, 200, msg.error.data?.code ?? `rpc_${msg.error.code}`, msg.error);
|
|
232
|
+
return msg.result;
|
|
233
|
+
}
|
|
234
|
+
async listTools() {
|
|
235
|
+
const tools = [];
|
|
236
|
+
let cursor;
|
|
237
|
+
do {
|
|
238
|
+
const page = await this.rpc("tools/list", cursor ? { cursor } : undefined);
|
|
239
|
+
tools.push(...page.tools);
|
|
240
|
+
cursor = page.nextCursor;
|
|
241
|
+
} while (cursor);
|
|
242
|
+
return tools;
|
|
243
|
+
}
|
|
244
|
+
callTool(name, args = {}) {
|
|
245
|
+
return this.rpc("tools/call", { name, arguments: args });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
var DEFAULT_MCP_URL = "https://unbrowse.ai/mcp", MCP_PROTOCOL_VERSION = "2025-11-25", env2 = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
|
|
249
|
+
var init_mcp = __esm(() => {
|
|
250
|
+
init_client();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ../sdk/src/index.ts
|
|
254
|
+
var init_src = __esm(() => {
|
|
255
|
+
init_client();
|
|
256
|
+
init_mcp();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// src/mcp-proxy.ts
|
|
260
|
+
import { createInterface } from "node:readline";
|
|
261
|
+
function safeToolName(name) {
|
|
262
|
+
return name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
|
|
263
|
+
}
|
|
264
|
+
function createProxy(upstream, version) {
|
|
265
|
+
const names = new Map;
|
|
266
|
+
async function list() {
|
|
267
|
+
const tools = await (await upstream()).listTools();
|
|
268
|
+
names.clear();
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const t of tools) {
|
|
271
|
+
const safe = safeToolName(t.name);
|
|
272
|
+
if (names.has(safe))
|
|
273
|
+
continue;
|
|
274
|
+
names.set(safe, t.name);
|
|
275
|
+
out.push({ ...t, name: safe });
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
async function original(safe) {
|
|
280
|
+
if (!names.has(safe))
|
|
281
|
+
await list();
|
|
282
|
+
return names.get(safe) ?? safe;
|
|
283
|
+
}
|
|
284
|
+
return async function handle(msg) {
|
|
285
|
+
if (msg.id === undefined || msg.id === null)
|
|
286
|
+
return;
|
|
287
|
+
const reply = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
|
|
288
|
+
try {
|
|
289
|
+
switch (msg.method) {
|
|
290
|
+
case "initialize": {
|
|
291
|
+
const asked = String(msg.params?.protocolVersion ?? "");
|
|
292
|
+
return reply({
|
|
293
|
+
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
294
|
+
capabilities: { tools: { listChanged: false } },
|
|
295
|
+
serverInfo: { name: "unbrowse", title: "Unbrowse (local proxy to the hosted MCP)", version },
|
|
296
|
+
instructions: "Unbrowse is the browserless browser. Prefer unbrowse_discover then unbrowse_run; unbrowse_scrape reads a page; unbrowse_browse_* drive the cloud browser. input_required is not a failure: answer it with unbrowse_resume."
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
case "ping":
|
|
300
|
+
return reply({});
|
|
301
|
+
case "tools/list":
|
|
302
|
+
return reply({ tools: await list() });
|
|
303
|
+
case "tools/call": {
|
|
304
|
+
const name = String(msg.params?.name ?? "");
|
|
305
|
+
return reply(await (await upstream()).rpc("tools/call", { ...msg.params, name: await original(name) }));
|
|
306
|
+
}
|
|
307
|
+
default:
|
|
308
|
+
return reply(await (await upstream()).rpc(String(msg.method), msg.params));
|
|
309
|
+
}
|
|
310
|
+
} catch (e) {
|
|
311
|
+
const err = e;
|
|
312
|
+
const code = err instanceof UnbrowseError ? err.code : "proxy_error";
|
|
313
|
+
const hint = err instanceof UnbrowseError && err.status === 401 ? " Run `unbrowse login` (or set UNBROWSE_API_KEY) and restart this server." : "";
|
|
314
|
+
return { jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: `${err.message}${hint}`, data: { code } } };
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
async function serveStdio(opts) {
|
|
319
|
+
const handle = createProxy(async () => new UnbrowseMcp({ url: opts.url, apiKey: await opts.token() ?? "", endUser: opts.endUser, client: `cli-mcp/${opts.version}` }), opts.version);
|
|
320
|
+
const write = (m) => new Promise((resolve) => process.stdout.write(`${JSON.stringify(m)}
|
|
321
|
+
`, () => resolve()));
|
|
322
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
323
|
+
const pending = new Set;
|
|
324
|
+
for await (const line of rl) {
|
|
325
|
+
if (!line.trim())
|
|
326
|
+
continue;
|
|
327
|
+
let msg;
|
|
328
|
+
try {
|
|
329
|
+
msg = JSON.parse(line);
|
|
330
|
+
} catch {
|
|
331
|
+
pending.add(write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }));
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const p = handle(msg).then((res) => res ? write(res) : undefined);
|
|
335
|
+
pending.add(p);
|
|
336
|
+
p.finally(() => pending.delete(p));
|
|
337
|
+
}
|
|
338
|
+
await Promise.all(pending);
|
|
339
|
+
}
|
|
340
|
+
var PROTOCOLS;
|
|
341
|
+
var init_mcp_proxy = __esm(() => {
|
|
342
|
+
init_src();
|
|
343
|
+
PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// src/cli.ts
|
|
347
|
+
import { spawn } from "node:child_process";
|
|
348
|
+
import { randomUUID } from "node:crypto";
|
|
349
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
350
|
+
|
|
351
|
+
// src/auth.ts
|
|
352
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
353
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
354
|
+
import { createServer } from "node:http";
|
|
355
|
+
import { homedir } from "node:os";
|
|
356
|
+
import { dirname, join } from "node:path";
|
|
357
|
+
function configPath() {
|
|
358
|
+
const root = process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
|
|
359
|
+
return join(root, "cli.json");
|
|
360
|
+
}
|
|
361
|
+
function load() {
|
|
362
|
+
const path = configPath();
|
|
363
|
+
if (!existsSync(path))
|
|
364
|
+
return {};
|
|
365
|
+
try {
|
|
366
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
367
|
+
} catch {
|
|
368
|
+
return {};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function save(stored) {
|
|
372
|
+
const path = configPath();
|
|
373
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
374
|
+
writeFileSync(path, JSON.stringify(stored, null, 2), { mode: 384 });
|
|
375
|
+
chmodSync(path, 384);
|
|
376
|
+
}
|
|
377
|
+
function clear() {
|
|
378
|
+
rmSync(configPath(), { force: true });
|
|
379
|
+
}
|
|
380
|
+
async function currentToken(baseUrl, fetchImpl = fetch) {
|
|
381
|
+
if (process.env.UNBROWSE_API_KEY)
|
|
382
|
+
return process.env.UNBROWSE_API_KEY;
|
|
383
|
+
const stored = load();
|
|
384
|
+
if (stored.baseUrl && stored.baseUrl !== baseUrl)
|
|
385
|
+
return;
|
|
386
|
+
if (stored.apiKey)
|
|
387
|
+
return stored.apiKey;
|
|
388
|
+
const o = stored.oauth;
|
|
389
|
+
if (!o)
|
|
390
|
+
return;
|
|
391
|
+
if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
|
|
392
|
+
try {
|
|
393
|
+
const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
|
|
394
|
+
save({ ...stored, oauth: { clientId: o.clientId, ...fresh, refreshToken: fresh.refreshToken ?? o.refreshToken } });
|
|
395
|
+
return fresh.accessToken;
|
|
396
|
+
} catch {
|
|
397
|
+
return o.accessToken;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return o.accessToken;
|
|
401
|
+
}
|
|
402
|
+
function describe(baseUrl) {
|
|
403
|
+
if (process.env.UNBROWSE_API_KEY)
|
|
404
|
+
return "API key from UNBROWSE_API_KEY";
|
|
405
|
+
const stored = load();
|
|
406
|
+
if (stored.baseUrl && stored.baseUrl !== baseUrl)
|
|
407
|
+
return `not signed in to ${baseUrl} (stored login is for ${stored.baseUrl})`;
|
|
408
|
+
if (stored.apiKey)
|
|
409
|
+
return `API key ${mask(stored.apiKey)} in ${configPath()}`;
|
|
410
|
+
if (stored.oauth)
|
|
411
|
+
return `OAuth sign-in in ${configPath()}`;
|
|
412
|
+
return "not signed in";
|
|
413
|
+
}
|
|
414
|
+
function mask(key) {
|
|
415
|
+
return key.length <= 12 ? "…" : `${key.slice(0, 8)}…${key.slice(-4)}`;
|
|
416
|
+
}
|
|
417
|
+
function apiResource(baseUrl) {
|
|
418
|
+
return `${baseUrl.replace(/\/+$/, "")}/api`;
|
|
419
|
+
}
|
|
420
|
+
async function tokenRequest(baseUrl, form, fetchImpl) {
|
|
421
|
+
const res = await fetchImpl(`${baseUrl}/oauth/token`, {
|
|
422
|
+
method: "POST",
|
|
423
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
424
|
+
body: new URLSearchParams(form).toString()
|
|
425
|
+
});
|
|
426
|
+
const body = await res.json().catch(() => ({}));
|
|
427
|
+
if (!res.ok || !body.access_token)
|
|
428
|
+
throw new Error(body.error_description ?? body.error ?? `token endpoint answered HTTP ${res.status}`);
|
|
429
|
+
return { accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined };
|
|
430
|
+
}
|
|
431
|
+
var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
432
|
+
async function oauthLogin(baseUrl, opts) {
|
|
433
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
434
|
+
const verifier = b64url(randomBytes(32));
|
|
435
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
436
|
+
const state = b64url(randomBytes(16));
|
|
437
|
+
let settle;
|
|
438
|
+
const got = new Promise((r) => settle = r);
|
|
439
|
+
const server = createServer((req, res) => {
|
|
440
|
+
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
441
|
+
if (u.pathname !== "/callback") {
|
|
442
|
+
res.writeHead(404).end();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
|
|
446
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
447
|
+
res.end(ok ? "<p>Signed in to Unbrowse. You can close this tab.</p>" : "<p>Sign-in failed. Return to the terminal.</p>");
|
|
448
|
+
settle(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? "state mismatch" });
|
|
449
|
+
});
|
|
450
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
451
|
+
const port = server.address().port;
|
|
452
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
453
|
+
try {
|
|
454
|
+
const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
|
|
455
|
+
method: "POST",
|
|
456
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
457
|
+
body: JSON.stringify({
|
|
458
|
+
client_name: "Unbrowse CLI",
|
|
459
|
+
redirect_uris: [redirectUri],
|
|
460
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
461
|
+
response_types: ["code"],
|
|
462
|
+
token_endpoint_auth_method: "none"
|
|
463
|
+
})
|
|
464
|
+
});
|
|
465
|
+
const client = await reg.json().catch(() => ({}));
|
|
466
|
+
if (!reg.ok || !client.client_id)
|
|
467
|
+
throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
|
|
468
|
+
const url = new URL(`${baseUrl}/authorize`);
|
|
469
|
+
url.search = new URLSearchParams({
|
|
470
|
+
response_type: "code",
|
|
471
|
+
client_id: client.client_id,
|
|
472
|
+
redirect_uri: redirectUri,
|
|
473
|
+
code_challenge: challenge,
|
|
474
|
+
code_challenge_method: "S256",
|
|
475
|
+
state,
|
|
476
|
+
scope: "unbrowse",
|
|
477
|
+
resource: apiResource(baseUrl)
|
|
478
|
+
}).toString();
|
|
479
|
+
opts.log(`Opening ${url.origin}/authorize in your browser. If it does not open, visit:
|
|
480
|
+
${url.toString()}`);
|
|
481
|
+
opts.open(url.toString());
|
|
482
|
+
const timer = setTimeout(() => settle({ error: "timed out waiting for the browser sign-in" }), opts.timeoutMs ?? 300000);
|
|
483
|
+
const answer = await got;
|
|
484
|
+
clearTimeout(timer);
|
|
485
|
+
if (!answer.code)
|
|
486
|
+
throw new Error(answer.error ?? "sign-in failed");
|
|
487
|
+
const tokens = await tokenRequest(baseUrl, { grant_type: "authorization_code", code: answer.code, redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier, resource: apiResource(baseUrl) }, fetchImpl);
|
|
488
|
+
save({ baseUrl, oauth: { clientId: client.client_id, ...tokens } });
|
|
489
|
+
} finally {
|
|
490
|
+
server.close();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
314
494
|
// src/cli.ts
|
|
315
|
-
|
|
495
|
+
init_src();
|
|
496
|
+
var VERSION = "12.1.0";
|
|
316
497
|
var DEFAULT_ORIGIN = "https://unbrowse.ai";
|
|
317
498
|
var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
|
|
318
499
|
|
|
@@ -336,8 +517,11 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
|
|
|
336
517
|
registry [query] Public compiled sites (no account)
|
|
337
518
|
site <host> One site's tools (no account)
|
|
338
519
|
|
|
520
|
+
mcp Local stdio MCP server proxying the hosted one (tool names use _ not .)
|
|
521
|
+
[--url URL] [--end-user ID] for agent hosts that need stdio or strict tool names (Grok Build)
|
|
522
|
+
|
|
339
523
|
Options: --json (errors as JSON) · --base-url URL · --no-open
|
|
340
|
-
Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN})
|
|
524
|
+
Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN}), UNBROWSE_MCP_URL, UNBROWSE_END_USER
|
|
341
525
|
Exit: 0 ok · 1 error · 2 input required · 3 sign-in or login needed · 4 not verified`;
|
|
342
526
|
var BOOLEAN = new Set(["json", "help", "version", "no-open", "no-wait", "unattended"]);
|
|
343
527
|
function parseArgs(argv) {
|
|
@@ -383,6 +567,16 @@ async function main(argv, io = stdio) {
|
|
|
383
567
|
const flag = (k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
|
|
384
568
|
const print = (v) => io.out(JSON.stringify(v, null, 2));
|
|
385
569
|
const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin });
|
|
570
|
+
if (cmd === "mcp") {
|
|
571
|
+
await Promise.resolve().then(() => init_mcp_proxy());
|
|
572
|
+
await serveStdio({
|
|
573
|
+
url: flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`,
|
|
574
|
+
token: async () => await currentToken(origin) ?? undefined,
|
|
575
|
+
version: VERSION,
|
|
576
|
+
endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER
|
|
577
|
+
});
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
386
580
|
try {
|
|
387
581
|
const ub = await client();
|
|
388
582
|
switch (cmd) {
|