cookbook-bridge 0.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/device.mjs ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Cookbook Bridge — `login`, `status`, `connect-agents` (RFC 8628 device-flow client).
3
+ *
4
+ * `login` connects this Bridge to Cookbook WITHOUT you copy-pasting a secret:
5
+ * 1. POST /api/bridge/device → device_code (kept in MEMORY only),
6
+ * user_code (shown to you), interval, …
7
+ * 2. open the browser to the authorize page; you confirm the SAME user_code
8
+ * and click "Authorize this Bridge".
9
+ * 3. poll POST /api/bridge/device/token until it returns the token, then MERGE
10
+ * it into config.json (preserving your agents/pollSeconds/acceptFrom) and
11
+ * verify the connection.
12
+ *
13
+ * `connect-agents` runs the same flow but asks for one attributed token per agent
14
+ * CLI found on this machine, then configures each CLI (Claude, Gemini/agy, Codex,
15
+ * OpenClaw). The pieces are exported separately so Bridge Local (local.mjs) can run
16
+ * the identical flow behind a button: beginDeviceFlow → waitForDeviceToken →
17
+ * saveLoginConfig → configureClis.
18
+ *
19
+ * `status` reports whether the configured token still works and which agent CLIs
20
+ * are installed and ready.
21
+ *
22
+ * SECURITY: the device_code and the minted tokens are NEVER printed and the
23
+ * device_code is NEVER written to disk. config.json is written with mode 0600.
24
+ *
25
+ * Node built-ins only (global fetch, Node 18+). No dependencies.
26
+ */
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import { spawn, spawnSync } from "node:child_process";
31
+ import { fileURLToPath } from "node:url";
32
+ import { listWorkspaces } from "./cookbook.mjs";
33
+
34
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
35
+ const DEFAULT_URL = "https://cookbook.team";
36
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
37
+
38
+ /** Resolve --flag value from an argv array (e.g. ["--url","https://…"]). */
39
+ function flag(argv, name) {
40
+ const i = argv.indexOf(name);
41
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
42
+ }
43
+
44
+ /** Path to config.json: --config <path> or the default bridge/config.json. */
45
+ export function configPath(argv) {
46
+ return flag(argv, "--config")
47
+ ? path.resolve(flag(argv, "--config"))
48
+ : path.join(HERE, "config.json");
49
+ }
50
+
51
+ /** Read existing config (or null if none). */
52
+ function readConfig(p) {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(p, "utf8"));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** Resolve the base URL: --url → COOKBOOK_URL → existing config → default. */
61
+ function resolveBaseUrl(argv, existing) {
62
+ const raw =
63
+ flag(argv, "--url") ||
64
+ process.env.COOKBOOK_URL ||
65
+ (existing && existing.cookbookUrl) ||
66
+ DEFAULT_URL;
67
+ return String(raw).replace(/\/$/, "");
68
+ }
69
+
70
+ /** Best-effort: open a URL in the default browser. Never throws. */
71
+ export function openBrowser(url) {
72
+ try {
73
+ const cmd =
74
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
75
+ const args = process.platform === "win32" ? ["", url] : [url];
76
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: process.platform === "win32" });
77
+ child.on("error", () => {});
78
+ child.unref();
79
+ } catch {
80
+ /* headless — the URL is printed as the fallback */
81
+ }
82
+ }
83
+
84
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
85
+
86
+ async function postForm(url, body) {
87
+ const res = await fetch(url, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify(body),
91
+ });
92
+ const json = await res.json().catch(() => ({}));
93
+ return { status: res.status, json };
94
+ }
95
+
96
+ // ───────────────────────────── device flow, as pieces ─────────────────────────────
97
+
98
+ /**
99
+ * Step 1: start the device flow. Returns what the human needs (code + URL) and what
100
+ * the poller needs (device_code, kept in memory only). `agents` makes the authorize
101
+ * page list exactly which per-agent tokens one click will mint.
102
+ */
103
+ export async function beginDeviceFlow({ baseUrl, agents = null }) {
104
+ const start = await postForm(`${baseUrl}/api/bridge/device`, {
105
+ device_label: os.hostname(),
106
+ ...(agents && agents.length ? { requested_agents: agents } : {}),
107
+ });
108
+ if (start.status !== 200 || !start.json.device_code) {
109
+ throw new Error(
110
+ `Couldn't start login (HTTP ${start.status}). ${start.json.error_description || start.json.error || ""}`.trim(),
111
+ );
112
+ }
113
+ const verifyUri = start.json.verification_uri;
114
+ return {
115
+ deviceCode: start.json.device_code, // memory only — never written/logged
116
+ userCode: start.json.user_code,
117
+ approveUrl: start.json.verification_uri_complete || verifyUri,
118
+ interval: Math.max(1, Number(start.json.interval) || 5),
119
+ expiresAt: Date.now() + (Number(start.json.expires_in) || 600) * 1000,
120
+ };
121
+ }
122
+
123
+ /** Step 2: poll until approved. Resolves { token, agentTokens } or throws. */
124
+ export async function waitForDeviceToken({ baseUrl, deviceCode, interval, expiresAt, onTick = () => {} }) {
125
+ let wait = interval;
126
+ while (Date.now() < expiresAt) {
127
+ await sleep(wait * 1000);
128
+ onTick();
129
+ const poll = await postForm(`${baseUrl}/api/bridge/device/token`, {
130
+ grant_type: DEVICE_GRANT,
131
+ device_code: deviceCode,
132
+ });
133
+ const error = poll.json && poll.json.error;
134
+ if (poll.status === 200 && poll.json.access_token) {
135
+ return { token: poll.json.access_token, agentTokens: poll.json.agent_tokens || null };
136
+ }
137
+ if (error === "authorization_pending") continue;
138
+ if (error === "slow_down") { wait += 5; continue; }
139
+ if (error === "access_denied") throw new Error("Authorization was denied. Nothing was connected.");
140
+ if (error === "expired_token") throw new Error("The code expired before it was approved. Run `login` again.");
141
+ // Unexpected → keep trying until the deadline.
142
+ }
143
+ throw new Error("Timed out waiting for approval. Run `login` again.");
144
+ }
145
+
146
+ /** Step 3: merge the token into config.json (preserving everything else). */
147
+ export function saveLoginConfig(cfgPath, baseUrl, token) {
148
+ let cfg = readConfig(cfgPath);
149
+ if (!cfg) {
150
+ // Seed from config.example.json minus the PASTE token, if present.
151
+ const example = readConfig(path.join(HERE, "config.example.json")) || {};
152
+ delete example.token;
153
+ cfg = example;
154
+ }
155
+ cfg.cookbookUrl = baseUrl;
156
+ cfg.token = token;
157
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
158
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
159
+ try { fs.chmodSync(cfgPath, 0o600); } catch { /* best-effort on platforms without chmod */ }
160
+ return cfg;
161
+ }
162
+
163
+ // ───────────────────────────── CLI detection + configuration ─────────────────────────────
164
+
165
+ function which(cmd) {
166
+ const dirs = (process.env.PATH || "").split(path.delimiter);
167
+ const home = process.env.HOME || os.homedir() || "";
168
+ if (home) dirs.push(path.join(home, ".claude/local"), path.join(home, ".bun/bin"), path.join(home, ".local/bin"), "/opt/homebrew/bin", "/usr/local/bin");
169
+ for (const d of dirs) {
170
+ if (!d) continue;
171
+ const p = path.join(d, cmd);
172
+ try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* keep looking */ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /** Codex ships inside the ChatGPT app (July 2026); older installs had Codex.app; a
178
+ * bare `codex` on PATH also works. First hit wins. */
179
+ export function findCodexBinary() {
180
+ const candidates = [
181
+ "/Applications/ChatGPT.app/Contents/Resources/codex",
182
+ "/Applications/Codex.app/Contents/Resources/codex",
183
+ path.join(os.homedir(), "Applications/ChatGPT.app/Contents/Resources/codex"),
184
+ ];
185
+ for (const c of candidates) {
186
+ try { fs.accessSync(c, fs.constants.X_OK); return c; } catch { /* next */ }
187
+ }
188
+ return which("codex");
189
+ }
190
+
191
+ /** agy (Antigravity) has no `mcp add`; merge-write its documented config file. */
192
+ function agyConfigure(url, token) {
193
+ const cfgPath = path.join(process.env.HOME || os.homedir(), ".gemini", "config", "mcp_config.json");
194
+ let current = {};
195
+ try { current = JSON.parse(fs.readFileSync(cfgPath, "utf8")) ?? {}; } catch { /* fresh file */ }
196
+ if (typeof current !== "object" || Array.isArray(current)) current = {};
197
+ current.mcpServers = {
198
+ ...(current.mcpServers && typeof current.mcpServers === "object" ? current.mcpServers : {}),
199
+ cookbook: { serverUrl: url, headers: { Authorization: `Bearer ${token}` } },
200
+ };
201
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
202
+ fs.writeFileSync(cfgPath, JSON.stringify(current, null, 2) + "\n");
203
+ return cfgPath;
204
+ }
205
+
206
+ /**
207
+ * Codex: a clean CODEX_HOME for the Bridge (~/.codex-bridge) with the Cookbook MCP
208
+ * server and a copy of the member's ChatGPT login, so the app-server runner uses
209
+ * their subscription. Returns { codexHome, hasAuth }.
210
+ */
211
+ export function codexConfigure(url, token, { home = os.homedir() } = {}) {
212
+ const codexHome = path.join(home, ".codex-bridge");
213
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
214
+ const tomlPath = path.join(codexHome, "config.toml");
215
+ let toml = "";
216
+ try { toml = fs.readFileSync(tomlPath, "utf8"); } catch { /* fresh */ }
217
+ // Replace any existing [mcp_servers.cookbook] block; keep everything else.
218
+ toml = toml.replace(/\[mcp_servers\.cookbook\][\s\S]*?(?=\n\[|$)/g, "").trimEnd();
219
+ toml += `${toml ? "\n\n" : ""}[mcp_servers.cookbook]\nurl = "${url}"\nbearer_token_env_var = "COOKBOOK_CODEX_TOKEN"\n`;
220
+ fs.writeFileSync(tomlPath, toml, { mode: 0o600 });
221
+ const srcAuth = path.join(home, ".codex", "auth.json");
222
+ const dstAuth = path.join(codexHome, "auth.json");
223
+ let hasAuth = false;
224
+ try {
225
+ fs.copyFileSync(srcAuth, dstAuth);
226
+ fs.chmodSync(dstAuth, 0o600);
227
+ hasAuth = true;
228
+ } catch {
229
+ hasAuth = fs.existsSync(dstAuth);
230
+ }
231
+ void token; // the token rides in the Bridge config (agent.token), not on disk here
232
+ return { codexHome, hasAuth };
233
+ }
234
+
235
+ /**
236
+ * OpenClaw: merge-write mcp.servers.cookbook with a bearer header and the
237
+ * streamable-http transport (the SSE default hangs; OAuth never reaches its
238
+ * claude-cli backend). Backs up the file first, then asks OpenClaw to reload.
239
+ */
240
+ export function openclawConfigure(url, token, { home = os.homedir() } = {}) {
241
+ const cfgPath = path.join(home, ".openclaw", "openclaw.json");
242
+ let current = {};
243
+ try { current = JSON.parse(fs.readFileSync(cfgPath, "utf8")) ?? {}; } catch { /* fresh */ }
244
+ if (typeof current !== "object" || Array.isArray(current)) current = {};
245
+ try { fs.copyFileSync(cfgPath, `${cfgPath}.bak-cookbook-${Date.now()}`); } catch { /* no existing file */ }
246
+ current.mcp = current.mcp && typeof current.mcp === "object" ? current.mcp : {};
247
+ current.mcp.servers = current.mcp.servers && typeof current.mcp.servers === "object" ? current.mcp.servers : {};
248
+ const prev = current.mcp.servers.cookbook && typeof current.mcp.servers.cookbook === "object" ? current.mcp.servers.cookbook : {};
249
+ const { auth: _a, oauth: _o, ...rest } = prev;
250
+ current.mcp.servers.cookbook = { ...rest, url, transport: "streamable-http", headers: { Authorization: `Bearer ${token}` } };
251
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
252
+ fs.writeFileSync(cfgPath, JSON.stringify(current, null, 2) + "\n", { mode: 0o600 });
253
+ const bin = which("openclaw");
254
+ if (bin) spawnSync(bin, ["mcp", "reload"], { stdio: "ignore", timeout: 20_000 });
255
+ return cfgPath;
256
+ }
257
+
258
+ /**
259
+ * Which agent CLIs are on this machine. Each entry carries how to connect it.
260
+ * Names double as the attribution labels of the tokens the authorize page mints.
261
+ */
262
+ export function detectClis() {
263
+ const out = [];
264
+ const claude = which("claude");
265
+ if (claude) out.push({ agent: "Claude", vendor: "claude", path: claude, kind: "cli-add" });
266
+ const agy = which("agy");
267
+ if (agy) out.push({ agent: "Gemini", vendor: "gemini", path: agy, kind: "file" });
268
+ const codex = findCodexBinary();
269
+ if (codex) out.push({ agent: "Codex", vendor: "codex", path: codex, kind: "codex" });
270
+ const openclaw = which("openclaw");
271
+ if (openclaw) out.push({ agent: "OpenClaw", vendor: "openclaw", path: openclaw, kind: "openclaw" });
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * Configure every detected CLI with its attributed token. Also fixes the Bridge's
277
+ * own config where needed (Claude allowedTools prefix; Codex agent enabled with its
278
+ * binary, CODEX_HOME and token). Returns one result row per CLI; never throws for a
279
+ * single CLI's failure.
280
+ */
281
+ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
282
+ const mcpUrl = `${baseUrl}/api/mcp`;
283
+ const results = [];
284
+ for (const cli of found) {
285
+ const token = agentTokens?.[cli.agent];
286
+ if (!token) { results.push({ agent: cli.agent, ok: false, detail: "no token returned for this agent" }); continue; }
287
+ try {
288
+ if (cli.kind === "cli-add") {
289
+ // Idempotency: drop any existing 'cookbook' server first (best-effort).
290
+ spawnSync(cli.path, ["mcp", "remove", "--scope", "user", "cookbook"], { stdio: "ignore", timeout: 20_000 });
291
+ const add = spawnSync(cli.path, ["mcp", "add", "--scope", "user", "--transport", "http", "cookbook", mcpUrl, "--header", `Authorization: Bearer ${token}`], { encoding: "utf8", timeout: 30_000 });
292
+ if (add.status === 0) results.push({ agent: cli.agent, ok: true, detail: "connected (server 'cookbook', user scope)" });
293
+ else results.push({ agent: cli.agent, ok: false, detail: String(add.stderr || add.stdout || "add failed").trim().slice(0, 200) });
294
+ } else if (cli.kind === "file") {
295
+ const wrote = agyConfigure(mcpUrl, token);
296
+ results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote})` });
297
+ } else if (cli.kind === "codex") {
298
+ const { codexHome, hasAuth } = codexConfigure(mcpUrl, token);
299
+ results.push({
300
+ agent: cli.agent, ok: true,
301
+ detail: hasAuth ? `connected (CODEX_HOME ${codexHome})` : `configured, but no ChatGPT login found: open the ChatGPT app (or run \`codex login\`), then connect again`,
302
+ warn: !hasAuth,
303
+ });
304
+ enableCodexAgent(cfgPath, { binary: cli.path, codexHome, token });
305
+ } else if (cli.kind === "openclaw") {
306
+ const wrote = openclawConfigure(mcpUrl, token);
307
+ results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote}, streamable-http)` });
308
+ }
309
+ } catch (e) {
310
+ results.push({ agent: cli.agent, ok: false, detail: String(e?.message || e).slice(0, 200) });
311
+ }
312
+ }
313
+ fixClaudeToolPrefix(cfgPath);
314
+ return results;
315
+ }
316
+
317
+ /** Bridge config: make sure a Codex agent exists, is enabled, and carries its pieces. */
318
+ function enableCodexAgent(cfgPath, { binary, codexHome, token }) {
319
+ const raw = readConfig(cfgPath);
320
+ if (!raw) return;
321
+ raw.agents = Array.isArray(raw.agents) ? raw.agents : [];
322
+ let agent = raw.agents.find((a) => a && (a.runner === "app-server" || /codex/i.test(String(a.name))));
323
+ if (!agent) {
324
+ const example = readConfig(path.join(HERE, "config.example.json"));
325
+ agent = (example?.agents || []).find((a) => a.runner === "app-server") || { name: "Codex", match: ["codex", "chatgpt"], runner: "app-server", sandbox: "workspace-write" };
326
+ agent = JSON.parse(JSON.stringify(agent));
327
+ raw.agents.push(agent);
328
+ }
329
+ agent.enabled = true;
330
+ agent.command = [binary];
331
+ agent.codexHome = codexHome;
332
+ agent.token = token;
333
+ for (const k of Object.keys(agent)) if (k.startsWith("_")) delete agent[k];
334
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
335
+ }
336
+
337
+ /** connect-agents switches claude to the CLI-added server, whose tools are
338
+ * mcp__cookbook__* — a Bridge config still allowing the connector-style prefix
339
+ * (mcp__claude_ai_Cookbook__*) would have every tool call silently blocked
340
+ * (live failure, 2026-07-03). Fix the config we already own. */
341
+ function fixClaudeToolPrefix(cfgPath) {
342
+ try {
343
+ const raw = readConfig(cfgPath);
344
+ if (!raw) return false;
345
+ let fixed = false;
346
+ for (const a of raw.agents ?? []) {
347
+ if (!Array.isArray(a.command)) continue;
348
+ a.command = a.command.map((arg) => {
349
+ if (typeof arg === "string" && /^mcp__claude_ai_Cookbook__/i.test(arg)) { fixed = true; return "mcp__cookbook__*"; }
350
+ return arg;
351
+ });
352
+ }
353
+ if (fixed) fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
354
+ return fixed;
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * The whole connect-agents flow without a terminal: returns the approve URL right
362
+ * away and a `done` promise that resolves when the human has approved and every
363
+ * CLI is configured. Bridge Local calls this behind POST /connect-agents.
364
+ */
365
+ export async function connectAgentsProgrammatic({ cfgPath, baseUrl: baseUrlIn, openBrowserTab = false }) {
366
+ const existing = readConfig(cfgPath);
367
+ const baseUrl = String(baseUrlIn || existing?.cookbookUrl || DEFAULT_URL).replace(/\/$/, "");
368
+ const found = detectClis();
369
+ const agents = found.map((c) => c.agent);
370
+ const flow = await beginDeviceFlow({ baseUrl, agents });
371
+ if (openBrowserTab) openBrowser(flow.approveUrl);
372
+ const done = (async () => {
373
+ const { token, agentTokens } = await waitForDeviceToken({ baseUrl, ...flow });
374
+ saveLoginConfig(cfgPath, baseUrl, token);
375
+ const results = agentTokens ? configureClis(found, { baseUrl, agentTokens, cfgPath }) : found.map((c) => ({ agent: c.agent, ok: false, detail: "approved, but no agent tokens were returned" }));
376
+ return { token, results };
377
+ })();
378
+ return { approveUrl: flow.approveUrl, userCode: flow.userCode, expiresAt: new Date(flow.expiresAt).toISOString(), agents, done };
379
+ }
380
+
381
+ // ───────────────────────────── CLI commands ─────────────────────────────
382
+
383
+ export async function login(argv, opts = {}) {
384
+ const cfgPath = configPath(argv);
385
+ const existing = readConfig(cfgPath);
386
+ const baseUrl = resolveBaseUrl(argv, existing);
387
+ const agents = Array.isArray(opts.agents) && opts.agents.length ? opts.agents : null;
388
+
389
+ console.log(`\nConnecting this Bridge to Cookbook (${baseUrl})…`);
390
+ if (agents) console.log(` (and connecting agent CLIs: ${agents.join(", ")})`);
391
+ console.log("");
392
+
393
+ const flow = await beginDeviceFlow({ baseUrl, agents });
394
+ console.log(" Your one-time code is:\n");
395
+ console.log(` ┌${"─".repeat(flow.userCode.length + 6)}┐`);
396
+ console.log(` │ ${flow.userCode} │`);
397
+ console.log(` └${"─".repeat(flow.userCode.length + 6)}┘\n`);
398
+ console.log(" Opening your browser to approve this Bridge…");
399
+ console.log(` If it doesn't open, go to: ${flow.approveUrl}`);
400
+ console.log(" Confirm the code above matches, then click \"Authorize this Bridge\".\n");
401
+ openBrowser(flow.approveUrl);
402
+
403
+ process.stdout.write(" Waiting for approval");
404
+ let token;
405
+ let agentTokens;
406
+ try {
407
+ ({ token, agentTokens } = await waitForDeviceToken({ baseUrl, ...flow, onTick: () => process.stdout.write(".") }));
408
+ } finally {
409
+ console.log("");
410
+ }
411
+
412
+ saveLoginConfig(cfgPath, baseUrl, token);
413
+ const loginResult = { baseUrl, token, agentTokens, cfgPath };
414
+ try {
415
+ const ws = await listWorkspaces({ cookbookUrl: baseUrl, token });
416
+ console.log(`\n ✓ Connected — ${ws.length} workspace(s) visible.`);
417
+ } catch (e) {
418
+ console.log(`\n ⚠ Connected and saved config, but a test call failed: ${e.message}`);
419
+ }
420
+ if (!opts.quietOutro) console.log(" Start the Bridge with: node bridge/bridge.mjs\n");
421
+ return loginResult;
422
+ }
423
+
424
+ export async function status(argv) {
425
+ const cfgPath = configPath(argv);
426
+ const cfg = readConfig(cfgPath);
427
+ if (!cfg || !cfg.token || !cfg.cookbookUrl) {
428
+ console.log(`No connected Bridge at ${cfgPath}.\nRun: node bridge/bridge.mjs login`);
429
+ return;
430
+ }
431
+ const cookbookUrl = String(cfg.cookbookUrl).replace(/\/$/, "");
432
+ console.log(`\nBridge config: ${cfgPath}`);
433
+ console.log(`Cookbook: ${cookbookUrl}`);
434
+
435
+ try {
436
+ const ws = await listWorkspaces({ cookbookUrl, token: cfg.token });
437
+ console.log(`Token: ✓ valid — ${ws.length} workspace(s) visible.`);
438
+ } catch (e) {
439
+ console.log(`Token: ✗ ${e.message}`);
440
+ }
441
+
442
+ const agents = (cfg.agents || []).filter((a) => a.enabled !== false);
443
+ if (!agents.length) {
444
+ console.log("Agents: (none configured)");
445
+ return;
446
+ }
447
+ console.log("Agents:");
448
+ for (const a of agents) {
449
+ const cmd = Array.isArray(a.command) ? a.command[0] : a.command;
450
+ const ready = await probeAgent(cmd);
451
+ console.log(` ${ready ? "✓" : "✗"} ${a.name} (${cmd})${ready ? "" : " — not found on PATH"}`);
452
+ }
453
+ console.log("");
454
+ }
455
+
456
+ /** Spawn `<cmd> --version` to check a CLI is installed. Resolves true/false. */
457
+ function probeAgent(cmd) {
458
+ return new Promise((resolve) => {
459
+ let done = false;
460
+ const finish = (v) => {
461
+ if (!done) {
462
+ done = true;
463
+ resolve(v);
464
+ }
465
+ };
466
+ try {
467
+ const child = spawn(cmd, ["--version"], { stdio: "ignore" });
468
+ child.on("error", () => finish(false));
469
+ child.on("close", (code) => finish(code === 0 || code === null));
470
+ setTimeout(() => {
471
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
472
+ finish(false);
473
+ }, 5000);
474
+ } catch {
475
+ finish(false);
476
+ }
477
+ });
478
+ }
479
+
480
+ /**
481
+ * connect-agents — one command, one human approval, every installed agent CLI
482
+ * connected to Cookbook with CORRECT ATTRIBUTION. Detects Claude, Gemini (agy),
483
+ * Codex (ChatGPT app) and OpenClaw; mints one named token per agent in the same
484
+ * approval as the Bridge token; configures each via its official path.
485
+ */
486
+ export async function connectAgents(argv) {
487
+ const found = detectClis();
488
+ if (found.length === 0) {
489
+ console.log("\nNo agent CLIs found (looked for: claude, agy, codex/ChatGPT.app, openclaw).");
490
+ console.log("Install one, then re-run: node bridge/bridge.mjs connect-agents\n");
491
+ return;
492
+ }
493
+ console.log(`\nFound agent CLIs: ${found.map((c) => c.agent).join(", ")}`);
494
+
495
+ // One approval mints the bridge token + one named token per agent.
496
+ const res = await login(argv, { agents: found.map((c) => c.agent), quietOutro: true });
497
+ if (!res || !res.agentTokens) {
498
+ console.log("\n⚠ Approved, but no agent tokens were returned — your Bridge login was still refreshed.");
499
+ console.log(" Re-run `connect-agents`, or create tokens manually at Account → Tokens.\n");
500
+ return;
501
+ }
502
+ const results = configureClis(found, { baseUrl: res.baseUrl, agentTokens: res.agentTokens, cfgPath: res.cfgPath });
503
+ for (const r of results) {
504
+ const mark = r.ok ? (r.warn ? "!" : "✓") : "✗";
505
+ console.log(` ${mark} ${r.agent}: ${r.detail}${r.ok && !r.warn ? ` — work will be attributed "${r.agent} · via you".` : ""}`);
506
+ }
507
+ console.log("\n If a Bridge is currently running on this machine, restart it (its login token was refreshed).");
508
+ console.log(" Verify everything: node bridge/bridge.mjs doctor\n");
509
+ }
package/harden.mjs ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Bridge hardening helpers — side-effect-free on purpose (bridge.mjs dispatches on
3
+ * import, so anything testable lives here; scripts/test-bridge-harden.ts pins this).
4
+ *
5
+ * Two protections, both from the June-2026 threat review ("The Future of the Bridge"):
6
+ *
7
+ * 1. BILLING PROTECTION — agents must run on the subscription the owner already pays
8
+ * for. If a vendor API key (ANTHROPIC_API_KEY etc.) is exported in the user's shell,
9
+ * the official CLIs silently switch to API-metered billing — invisible until the
10
+ * invoice (a documented $1,800-in-2-days failure mode). The Bridge therefore strips
11
+ * vendor billing keys from every agent process it spawns, unless the user explicitly
12
+ * opts in with `"allowApiKeyBilling": true` in config.json.
13
+ *
14
+ * 2. GEMINI VERSION GATE — gemini-cli below 0.39.1 has a CVSS-10.0 prompt-injection RCE
15
+ * (April 2026 advisory). A daemon that feeds workspace content (other people's text)
16
+ * to a vulnerable CLI is the exact attack shape, so the Bridge refuses to run gemini
17
+ * agents on vulnerable versions instead of hoping.
18
+ *
19
+ * 3. AGY VERSION GATE — Antigravity CLI (gemini's successor) below 1.1.1 cannot call
20
+ * MCP tools in headless -p mode: the run LOOKS fine but complete_task never lands,
21
+ * burning every attempt. Confirmed-old versions are refused with a clear message.
22
+ */
23
+ import { spawn } from "node:child_process";
24
+
25
+ /** Env vars that flip the official CLIs from subscription auth to API-key billing. */
26
+ export const API_BILLING_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"];
27
+
28
+ /**
29
+ * The environment agent processes get. By default: the parent env MINUS vendor billing
30
+ * keys. `allowApiKeyBilling: true` opts out (user explicitly wants API billing).
31
+ * Never mutates process.env; returns which keys were stripped so callers can say so.
32
+ */
33
+ export function agentEnv(cfg, base = process.env) {
34
+ const env = { ...base };
35
+ if (cfg?.allowApiKeyBilling === true) return { env, stripped: [] };
36
+ const stripped = API_BILLING_KEYS.filter((k) => env[k] !== undefined && env[k] !== "");
37
+ for (const k of stripped) delete env[k];
38
+ return { env, stripped };
39
+ }
40
+
41
+ /** Minimum safe gemini-cli (the CVSS-10.0 RCE fix landed in 0.39.1, April 2026). */
42
+ export const GEMINI_MIN_VERSION = "0.39.1";
43
+
44
+ /** First X.Y.Z in a CLI's --version output, or null if none. */
45
+ export function parseVersion(text) {
46
+ const m = String(text ?? "").match(/(\d+)\.(\d+)\.(\d+)/);
47
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
48
+ }
49
+
50
+ /** Numeric semver triple compare: a < b. */
51
+ export function versionLt(a, b) {
52
+ const pa = String(a).split(".").map(Number);
53
+ const pb = String(b).split(".").map(Number);
54
+ for (let i = 0; i < 3; i++) {
55
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true;
56
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /** Does this agent's binary look like gemini-cli? (name-based; absolute paths count) */
62
+ export function isGeminiCommand(cmd) {
63
+ if (typeof cmd !== "string" || !cmd) return false;
64
+ const base = cmd.split(/[\\/]/).pop().toLowerCase();
65
+ return base === "gemini" || base.startsWith("gemini-");
66
+ }
67
+
68
+ /** Probe a binary's `--version` (argv array so tests can use ["node", fakeScript]).
69
+ * Resolves the first X.Y.Z found, or null (timeout / spawn-fail / unparseable). */
70
+ function probeVersion(argv, timeoutMs) {
71
+ return new Promise((resolve) => {
72
+ const [cmd, ...rest] = Array.isArray(argv) ? argv : [argv];
73
+ let out = "";
74
+ let done = false;
75
+ const finish = (version) => !done && ((done = true), resolve(version));
76
+ try {
77
+ const child = spawn(cmd, [...rest, "--version"], { stdio: ["ignore", "pipe", "pipe"] });
78
+ const t = setTimeout(() => { child.kill("SIGKILL"); finish(null); }, timeoutMs);
79
+ child.stdout.on("data", (d) => (out += d));
80
+ child.stderr.on("data", (d) => (out += d));
81
+ child.on("error", () => { clearTimeout(t); finish(null); });
82
+ child.on("close", () => { clearTimeout(t); finish(parseVersion(out)); });
83
+ } catch {
84
+ finish(null);
85
+ }
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Probe a gemini binary's version. Returns { version, vulnerable } — version null
91
+ * when unparseable (caller warns, doesn't block; only a CONFIRMED-old version blocks).
92
+ */
93
+ export async function checkGeminiVersion(argv, timeoutMs = 10_000) {
94
+ const version = await probeVersion(argv, timeoutMs);
95
+ return { version, vulnerable: version ? versionLt(version, GEMINI_MIN_VERSION) : false };
96
+ }
97
+
98
+ /** Minimum Antigravity CLI (agy) whose headless `-p` can call MCP tools NATIVELY.
99
+ * Fixed in 1.1.1 (2026-07-10; verified empirically 2026-07-13 — read via
100
+ * list_workspaces and write via create_file under --sandbox both landed). Below
101
+ * 1.1.1 agy ingests tool schemas but can't invoke them, so every task "runs but
102
+ * never completes" — the Bridge's most confusing failure class. Gate like gemini. */
103
+ export const AGY_MIN_VERSION = "1.1.1";
104
+
105
+ /** Does this agent's binary look like the Antigravity CLI? (name-based; paths count) */
106
+ export function isAgyCommand(cmd) {
107
+ if (typeof cmd !== "string" || !cmd) return false;
108
+ const base = cmd.split(/[\\/]/).pop().toLowerCase();
109
+ return base === "agy" || base === "antigravity";
110
+ }
111
+
112
+ /** Probe an agy binary's version. Returns { version, tooOld } — same null semantics
113
+ * as checkGeminiVersion (only a CONFIRMED-old version blocks). */
114
+ export async function checkAgyVersion(argv, timeoutMs = 10_000) {
115
+ const version = await probeVersion(argv, timeoutMs);
116
+ return { version, tooOld: version ? versionLt(version, AGY_MIN_VERSION) : false };
117
+ }