cookbook-bridge 0.1.2 → 0.1.4

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/bridge.mjs CHANGED
@@ -36,8 +36,8 @@ import { spawn } from "node:child_process";
36
36
  // repair itself — the update path depends ONLY on update.mjs (node built-ins only).
37
37
  // The e2e that forced this: a stale install missing volunteer.mjs couldn't even reach
38
38
  // the updater when these were static imports.
39
- let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings;
40
- let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION;
39
+ let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, agentsQuery;
40
+ let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand;
41
41
  let extractUsage, displayText;
42
42
  let volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities;
43
43
  let buildPrompt, buildThreadFollowUpPrompt;
@@ -46,15 +46,15 @@ let hasCodexThread, reapCodexServer, killCodexServer;
46
46
  let checkForUpdate, applyUpdate;
47
47
  let createLocalServer, toolsForMode, modeForTools, vendorOf;
48
48
  let connectAgentsProgrammatic, detectClis;
49
- let serveCalls, describeCall;
49
+ let serveCalls, describeCall, hostingMode;
50
50
  let fetchHands, claimHandsCall, reportHandsResult;
51
51
 
52
52
  async function loadRuntime() {
53
53
  ({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
54
54
  ({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
55
- ({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult } = await import("./cookbook.mjs"));
56
- ({ serveCalls, describeCall } = await import("./hands.mjs"));
57
- ({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION } = await import("./harden.mjs"));
55
+ ({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult, agentsQuery } = await import("./cookbook.mjs"));
56
+ ({ serveCalls, describeCall, hostingMode } = await import("./hands.mjs"));
57
+ ({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand } = await import("./harden.mjs"));
58
58
  ({ extractUsage, displayText } = await import("./usage.mjs"));
59
59
  ({ volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities } = await import("./volunteer.mjs"));
60
60
  ({ buildPrompt, buildThreadFollowUpPrompt } = await import("./prompt.mjs"));
@@ -164,7 +164,7 @@ function loadConfig() {
164
164
  // browser, run granted verbs on this machine? Off unless explicitly enabled —
165
165
  // `cookbook-bridge host` sets it. A Bridge that never hosts never even asks the
166
166
  // server for calls, so this costs nothing when unused.
167
- cfg.hosting = cfg.hosting ?? { enabled: false };
167
+ cfg.hosting = cfg.hosting ?? {}; // enabled: true=always, false=off, absent=grants you approved (hands.mjs hostingMode)
168
168
  cfg.maxAttempts = cfg.maxAttempts ?? 2;
169
169
  // Phase 1 semantics: taskTimeoutSeconds is the ABSOLUTE CEILING (cost backstop),
170
170
  // livenessTimeoutSeconds is the stall detector (no output for this long = dead).
@@ -176,6 +176,7 @@ function loadConfig() {
176
176
  // execution let one long run block every workspace's queue).
177
177
  cfg.maxConcurrentRuns = Math.max(1, cfg.maxConcurrentRuns ?? 2);
178
178
  cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
179
+ for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
179
180
  ensureAgentPath(); // so bare `claude`/`gemini` commands resolve under the app's minimal PATH
180
181
  loadRunState(); // restore attempts/given-up so a restart can't grant doomed tasks fresh attempts
181
182
  return cfg;
@@ -340,7 +341,13 @@ export function resumeCommand(command, sessionId) {
340
341
 
341
342
  function spawnAgent(agent, prompt, timeoutSeconds, env, onProgress, opts = {}) {
342
343
  return new Promise((resolve, reject) => {
343
- const baseCommand = opts.command ?? agent.command;
344
+ // IDENTITY: a claude run with a per-agent token carries its OWN Cookbook
345
+ // connection (--strict-mcp-config), so it acts as this Bridge's member under the
346
+ // agent's name — never as whatever the CLI is logged in as, and blind to stale
347
+ // claude.ai connectors that poison headless runs (2026-08-25).
348
+ const baseCommand = withCookbookMcp
349
+ ? withCookbookMcp(opts.command ?? agent.command, { token: agent.token, cookbookUrl: agent.cookbookUrl }).command
350
+ : (opts.command ?? agent.command);
344
351
  const { command, streaming } = onProgress ? streamingCommand(baseCommand) : { command: baseCommand, streaming: false };
345
352
  const [cmd, ...rawArgs] = command;
346
353
  const args = rawArgs.map((a) => a.replaceAll("{prompt}", prompt));
@@ -628,8 +635,9 @@ function applyConfigFromDisk(cfg) {
628
635
  if (raw.cookbookUrl) cfg.cookbookUrl = String(raw.cookbookUrl).replace(/\/$/, "");
629
636
  cfg.default = raw.default;
630
637
  cfg.localWorkspaces = raw.localWorkspaces ?? {};
631
- cfg.hosting = raw.hosting ?? { enabled: false };
638
+ cfg.hosting = raw.hosting ?? {};
632
639
  const agents = (raw.agents ?? []).filter((a) => a.enabled !== false);
640
+ for (const a of agents) a.cookbookUrl = cfg.cookbookUrl;
633
641
  cfg.agents.splice(0, cfg.agents.length, ...agents);
634
642
  // A reload usually follows connect-agents fixing the token — let the next poll
635
643
  // re-verify from scratch instead of staying stuck in the rejected state.
@@ -1203,7 +1211,7 @@ function noteAwaiting(awaiting) {
1203
1211
  }
1204
1212
 
1205
1213
  async function serveHands(cfg, calls) {
1206
- if (!cfg.hosting?.enabled || handsBusy || !calls || calls.length === 0) return;
1214
+ if (hostingMode(cfg) === "off" || handsBusy || !calls || calls.length === 0) return;
1207
1215
  handsBusy = true;
1208
1216
  try {
1209
1217
  await serveCalls(calls, {
@@ -1236,7 +1244,7 @@ async function serveHands(cfg, calls) {
1236
1244
  /** Poll for granted calls (the net under the push channel, and the whole story on a
1237
1245
  * server or network without SSE). No-ops entirely when not hosting. */
1238
1246
  async function pollHands(cfg) {
1239
- if (!cfg.hosting?.enabled || !hands.supported || handsBusy) return;
1247
+ if (hostingMode(cfg) === "off" || !hands.supported || handsBusy) return;
1240
1248
  try {
1241
1249
  const r = await fetchHands(cfg);
1242
1250
  if (!r.supported) {
@@ -1274,7 +1282,7 @@ async function socketLoop(cfg) {
1274
1282
  let announced = false;
1275
1283
  while (!sseStopped && sse.supported) {
1276
1284
  try {
1277
- const res = await fetch(`${cfg.cookbookUrl}/api/bridge/stream`, {
1285
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/stream${agentsQuery(cfg)}`, {
1278
1286
  headers: { Authorization: `Bearer ${cfg.token}` },
1279
1287
  });
1280
1288
  if (res.status === 404 || res.status === 405) {
@@ -1721,9 +1729,12 @@ async function main() {
1721
1729
  tokenOk = true;
1722
1730
  lastContactAt = Date.now();
1723
1731
  log(`Connected — watching ${ws.length} workspace(s).`);
1724
- if (cfg.hosting?.enabled) {
1725
- log("⌂ Hosting is ON — an agent you invite can run granted checks on this machine. You'll see every step; `cookbook-bridge host --off` closes the door.");
1726
- await pollHands(cfg);
1732
+ {
1733
+ const mode = hostingMode(cfg);
1734
+ if (mode === "always") log("⌂ Hosting is ON — an agent you invite can run granted checks on this machine. You'll see every step; `cookbook-bridge host --off` closes the door.");
1735
+ else if (mode === "grants") log("⌂ Hosting: grants you approve in Cookbook run here (every change still waits for your click). `cookbook-bridge host --off` refuses all.");
1736
+ else log("⌂ Hosting is OFF — no visiting agent can act on this machine. `cookbook-bridge host` opens it.");
1737
+ if (mode !== "off") await pollHands(cfg);
1727
1738
  }
1728
1739
  if (cfg.persistentThreads) {
1729
1740
  for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { killAllRunners(); killCodexServer(); process.exit(0); });
@@ -1773,7 +1784,7 @@ async function main() {
1773
1784
  }
1774
1785
  // HOSTING: granted calls ride the same cadence as work. When the push channel
1775
1786
  // is healthy it has already delivered them; this is the net.
1776
- if (cfg.hosting?.enabled && !pushHealthy) await pollHands(cfg);
1787
+ if (hostingMode(cfg) !== "off" && !pushHealthy) await pollHands(cfg);
1777
1788
  // A clean poll means the token is good — clear any prior rejection so the app's
1778
1789
  // /status flips back to connected once the user fixes it.
1779
1790
  lastContactAt = Date.now();
@@ -1899,6 +1910,7 @@ async function doctorReport(args) {
1899
1910
  cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
1900
1911
  cfg.cookbookUrl = (cfg.cookbookUrl || "").replace(/\/$/, "");
1901
1912
  cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
1913
+ for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
1902
1914
  if (!cfg.cookbookUrl || !cfg.token || String(cfg.token).startsWith("PASTE")) {
1903
1915
  bad("Config is missing cookbookUrl or a real token", "set both in config.json (token from your Cookbook → Tokens page)");
1904
1916
  cfg = null;
@@ -1998,6 +2010,11 @@ async function doctorReport(args) {
1998
2010
  }
1999
2011
  }
2000
2012
 
2013
+ if (isClaudeCommand && isClaudeCommand(agent.command)) {
2014
+ if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
2015
+ else warn(`${agent.name}: no per-agent token — runs use the claude CLI's OWN Cookbook login, which may be a different account and inherits stale claude.ai connectors`,
2016
+ "run `cookbook-bridge connect` (mints a token for this agent) or add \"token\" to this agent in config.json");
2017
+ }
2001
2018
  if ((agent.command || []).join(" ").includes("mcp__claude_ai_Cookbook__")) {
2002
2019
  warn(`${agent.name}: allowedTools uses mcp__claude_ai_Cookbook__* — a CLI-added server is usually mcp__cookbook__*`,
2003
2020
  "if tasks 'run but never complete', switch allowedTools to mcp__cookbook__*");
package/cookbook.mjs CHANGED
@@ -239,14 +239,21 @@ export async function recallAcrossWorkspaces(cfg, query, excludeWorkspaceId, lim
239
239
  // server that predates grants 404s, which every caller treats as "not hosting".
240
240
 
241
241
  /** Pending calls (and the live grants they belong to) for THIS Bridge's token. */
242
+ /** `?agents=Claude,Gemini,Chef` — what this Bridge manages, so the server can say
243
+ * "your Bridge is running but doesn't run X" instead of "start a Bridge". */
244
+ export function agentsQuery(cfg) {
245
+ const names = (cfg?.agents ?? []).filter((a) => a && a.enabled !== false && a.name).map((a) => String(a.name));
246
+ return names.length ? `?agents=${encodeURIComponent(names.join(","))}` : "";
247
+ }
248
+
242
249
  export async function fetchHands(cfg) {
243
- const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
250
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands${agentsQuery(cfg)}`, {
244
251
  headers: { Authorization: `Bearer ${cfg.token}` },
245
252
  });
246
253
  if (res.status === 404) return { supported: false, calls: [], grants: [] };
247
254
  if (!res.ok) throw new Error(`hands ${res.status}`);
248
255
  const j = await res.json().catch(() => ({}));
249
- return { supported: true, calls: j.calls ?? [], grants: j.grants ?? [] };
256
+ return { supported: true, calls: j.calls ?? [], grants: j.grants ?? [], awaiting: j.awaiting ?? [] };
250
257
  }
251
258
 
252
259
  /** Claim one call before running it. The server CASes on status, so two Bridges on
package/device.mjs CHANGED
@@ -291,6 +291,9 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
291
291
  const add = spawnSync(cli.path, ["mcp", "add", "--scope", "user", "--transport", "http", "cookbook", mcpUrl, "--header", `Authorization: Bearer ${token}`], { encoding: "utf8", timeout: 30_000 });
292
292
  if (add.status === 0) results.push({ agent: cli.agent, ok: true, detail: "connected (server 'cookbook', user scope)" });
293
293
  else results.push({ agent: cli.agent, ok: false, detail: String(add.stderr || add.stdout || "add failed").trim().slice(0, 200) });
294
+ // The Bridge's OWN runs must not depend on the CLI's global server: store the
295
+ // token on the agent so spawnAgent pins each run to it (--strict-mcp-config).
296
+ if (/claude/i.test(cli.agent)) setAgentToken(cfgPath, /claude/i, token);
294
297
  } else if (cli.kind === "file") {
295
298
  const wrote = agyConfigure(mcpUrl, token);
296
299
  results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote})` });
@@ -314,6 +317,16 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
314
317
  return results;
315
318
  }
316
319
 
320
+ /** Bridge config: give the agent whose command matches `re` its attributed token. */
321
+ function setAgentToken(cfgPath, re, token) {
322
+ const raw = readConfig(cfgPath);
323
+ if (!raw || !Array.isArray(raw.agents)) return;
324
+ const agent = raw.agents.find((a) => a && re.test(String(a.command?.[0] ?? a.name ?? "")));
325
+ if (!agent) return;
326
+ agent.token = token;
327
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
328
+ }
329
+
317
330
  /** Bridge config: make sure a Codex agent exists, is enabled, and carries its pieces. */
318
331
  function enableCodexAgent(cfgPath, { binary, codexHome, token }) {
319
332
  const raw = readConfig(cfgPath);
package/hands.mjs CHANGED
@@ -97,7 +97,7 @@ function collapseHome(text, home) {
97
97
  const h = String(home ?? "").replace(/\/+$/, "");
98
98
  if (!h || h.length < 4) return text;
99
99
  const esc = h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
- return text.replace(new RegExp(esc, "g"), "~");
100
+ return text.replace(new RegExp(esc + "(?=/|$)", "g"), "~");
101
101
  }
102
102
 
103
103
  /** Redact secrets out of anything this machine produced. Pure. */
@@ -181,7 +181,10 @@ const NEVER_READ = Object.freeze([
181
181
  /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
182
182
  /(^|\/)local\.json$/i, // the Bridge Local loopback token
183
183
  /(^|\/)\.git\/config$/i, // can carry credentials in a remote URL
184
- /(^|\/)credentials?(\.[A-Za-z0-9]+)?$/i,
184
+ // `credentials.json` AND its dotfile spelling `.credentials.json` — the anchor
185
+ // used to accept only `/` before the word, so a dotfile in a granted project
186
+ // folder walked through the wall (ultrareview #123, bug_003).
187
+ /(^|[/.])credentials?(\.[A-Za-z0-9]+)?$/i,
185
188
  ]);
186
189
 
187
190
  /** Every spelling of a path this filesystem might consider the same file. */
@@ -565,14 +568,26 @@ export const RUN_TEMPLATES = Object.freeze({
565
568
 
566
569
  tail_log: (params, ctx) => {
567
570
  const n = Math.min(Math.max(parseInt(params?.lines ?? 120, 10) || 120, 10), 400);
568
- const logPath = path.join(path.dirname(ctx.cfgPath || ""), "bridge.log");
571
+ // Where this Bridge's log actually is: `bridge.log` beside the config (terminal),
572
+ // the newest `*.log` beside it (a LaunchAgent), or the newest in `logs/` (the
573
+ // desktop app). Chef's first look at a desktop-app machine got "no bridge.log
574
+ // yet" for a Bridge that was logging fine (2026-08-25).
575
+ const dir = path.dirname(ctx.cfgPath || "");
576
+ const newest = (d) => {
577
+ try {
578
+ return fs.readdirSync(d).filter((f) => f.endsWith(".log")).map((f) => path.join(d, f))
579
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] ?? null;
580
+ } catch { return null; }
581
+ };
582
+ const logPath = [path.join(dir, "bridge.log"), newest(path.join(dir, "logs")), newest(dir)]
583
+ .find((p) => p && fs.existsSync(p)) ?? path.join(dir, "bridge.log");
569
584
  return { local: () => {
570
585
  let text = "";
571
586
  try {
572
587
  const buf = fs.readFileSync(logPath, "utf8");
573
588
  text = buf.split("\n").slice(-n).join("\n");
574
589
  } catch (e) {
575
- return { error: `no bridge.log yet (${e.code || e.message})` };
590
+ return { error: `no Bridge log yet (${e.code || e.message})` };
576
591
  }
577
592
  return { path: logPath, lines: n, text };
578
593
  } };
@@ -691,6 +706,13 @@ const VERBS = {
691
706
  if (ext === ".json") {
692
707
  try { JSON.parse(content); } catch (e) { return { error: `That isn't valid JSON, so it wasn't written: ${e.message.slice(0, 120)}` }; }
693
708
  }
709
+ // The setup allowlist carries two TOML files (Codex). The "won't parse ⇒ not
710
+ // written" promise covered only JSON until ultrareview #123 (bug_008) — this is
711
+ // a structural check, not a full parser: unbalanced quotes/brackets, lines that
712
+ // are neither a table header nor `key = value`, unterminated multi-line strings.
713
+ if (ext === ".toml" && !tomlLooksValid(content)) {
714
+ return { error: "That doesn't look like valid TOML (unbalanced quotes/brackets or a malformed line), so it wasn't written." };
715
+ }
694
716
  if (content.includes("\0")) return { error: "Content contains a null byte." };
695
717
 
696
718
  let backup = null;
@@ -713,9 +735,15 @@ const VERBS = {
713
735
  if (!r.ok) return { error: r.error };
714
736
  const name = String(args.backup ?? "");
715
737
  if (!BACKUP_RE.test(name)) return { error: "Pass the backup filename this session created (…​.bak-chef-<timestamp>)." };
716
- const backupPath = path.join(path.dirname(r.path), name);
717
- if (path.basename(backupPath) !== name) return { error: "Invalid backup name." };
718
- if (!fs.existsSync(backupPath)) return { error: "That backup isn't there any more." };
738
+ // write_file placed the backup next to the RESOLVED file (r.real), so a
739
+ // symlinked setup file dotfile managers do this constantly — keeps its backup
740
+ // in the target directory. Look there first, then beside the symlink itself
741
+ // (ultrareview #123, bug_009: restore used to look only beside the symlink and
742
+ // report "isn't there any more" about a backup that existed).
743
+ const candidates = [path.join(path.dirname(r.real), name), path.join(path.dirname(r.path), name)];
744
+ if (candidates.some((c) => path.basename(c) !== name)) return { error: "Invalid backup name." };
745
+ const backupPath = candidates.find((c) => fs.existsSync(c));
746
+ if (!backupPath) return { error: "That backup isn't there any more." };
719
747
  try { fs.copyFileSync(backupPath, r.path); } catch (e) { return { error: `restore failed: ${e.code || e.message}` }; }
720
748
  return { path: args.path, restored_from: name };
721
749
  },
@@ -863,6 +891,67 @@ export function describeCall(call) {
863
891
  case "run": return `run ${a.template}`;
864
892
  case "doctor": return "run the setup doctor";
865
893
  case "env": return "look at what's installed";
894
+ // The three verbs a host must approve are the three that used to render as a
895
+ // bare verb name (ultrareview #123, bug_007). Say WHAT, not just which.
896
+ case "write_file": return `write ${a.path}${typeof a.content === "string" ? ` (${a.content.length} chars)` : ""}`;
897
+ case "restore_backup": return `restore ${a.path} from ${a.backup ?? "its backup"}`;
898
+ case "open_url": {
899
+ try { const u = new URL(String(a.url ?? "")); return `open ${u.host}${u.pathname === "/" ? "" : u.pathname} in your browser`; }
900
+ catch { return `open ${a.url ?? "a page"} in your browser`; }
901
+ }
866
902
  default: return String(call?.verb ?? "?");
867
903
  }
868
904
  }
905
+
906
+ /**
907
+ * Structural TOML sanity check for write_file. Deliberately NOT a parser: it
908
+ * rejects the ways a distracted agent breaks a config (unbalanced quotes or
909
+ * brackets, an unterminated multi-line string, a line that is neither a table
910
+ * header nor `key = value`) and accepts anything that has that shape.
911
+ */
912
+ export function tomlLooksValid(text) {
913
+ let multi = null; // the open multi-line string delimiter, if any
914
+ let depth = 0; // open [ / { across lines (multi-line arrays and inline tables)
915
+ for (const raw of String(text).split(/\r?\n/)) {
916
+ const line = raw.trim();
917
+ if (multi) { if (line.includes(multi)) multi = null; continue; }
918
+ if (!line || line.startsWith("#")) continue;
919
+ if (depth > 0) {
920
+ for (const ch of line) { if (ch === "[" || ch === "{") depth++; else if (ch === "]" || ch === "}") depth--; if (depth < 0) return false; }
921
+ continue;
922
+ }
923
+ if (/^\[\[?[^\]]+\]\]?\s*(#.*)?$/.test(line)) continue;
924
+ const m = /^[A-Za-z0-9_\-."']+\s*=\s*(.+)$/.exec(line);
925
+ if (!m) return false;
926
+ const v = m[1].trim();
927
+ if (v.startsWith('"""') || v.startsWith("'''")) {
928
+ const q = v.slice(0, 3);
929
+ if (!(v.length > 3 && v.endsWith(q))) multi = q;
930
+ continue;
931
+ }
932
+ if (((v.match(/(?<!\\)"/g) || []).length) % 2 !== 0) return false;
933
+ for (const ch of v) { if (ch === "[" || ch === "{") depth++; else if (ch === "]" || ch === "}") depth--; if (depth < 0) return false; }
934
+ }
935
+ return !multi && depth === 0;
936
+ }
937
+
938
+ /**
939
+ * HOSTING MODE — may this Bridge serve a visiting agent's calls?
940
+ *
941
+ * "always" — `hosting.enabled: true` (`cookbook-bridge host`): serve any grant.
942
+ * "off" — `hosting.enabled: false` (`cookbook-bridge host --off`): refuse all.
943
+ * "grants" — nothing configured: serve the grants this member approved themselves.
944
+ *
945
+ * The third is the default ON PURPOSE (2026-08-25). A grant only exists because the
946
+ * host clicked Allow, in their own account, and it binds to this Bridge's own
947
+ * credential — that click IS the consent. Making it also require a separate "hosting"
948
+ * switch produced the failure Diego hit: Allow in a browser, a Bridge with the switch
949
+ * off, and every call sat queued until the grant expired. The Bridge's LOCAL_CEILING
950
+ * still holds every write at "ask", whatever the mode.
951
+ */
952
+ export function hostingMode(cfg) {
953
+ const v = cfg?.hosting?.enabled;
954
+ if (v === true) return "always";
955
+ if (v === false) return "off";
956
+ return "grants";
957
+ }
package/harden.mjs CHANGED
@@ -115,3 +115,42 @@ export async function checkAgyVersion(argv, timeoutMs = 10_000) {
115
115
  const version = await probeVersion(argv, timeoutMs);
116
116
  return { version, tooOld: version ? versionLt(version, AGY_MIN_VERSION) : false };
117
117
  }
118
+
119
+ // ── identity: a run acts as the Bridge's member, not as whoever the CLI is ──────
120
+ //
121
+ // Verified failure (2026-08-25): a Bridge-run `claude -p` refused every Cookbook
122
+ // tool because a stale claude.ai-synced connector poisoned the headless session,
123
+ // and when tools did work they acted as the CLI's own login — a different account
124
+ // that couldn't see the workspace, so complete_task returned "Not found". Both are
125
+ // the same mistake: letting the run inherit the CLI's global MCP state.
126
+ //
127
+ // `--strict-mcp-config --mcp-config <json>` makes the run see ONLY Cookbook, as the
128
+ // member whose token this is, attributed under that token's name.
129
+
130
+ /** True when this command runs the claude CLI (any path, any wrapper flags). */
131
+ export function isClaudeCommand(command) {
132
+ const base = String(command?.[0] ?? "").split(/[\\/]/).pop().toLowerCase();
133
+ return base === "claude";
134
+ }
135
+
136
+ /**
137
+ * Rewrite a claude command so the run carries its own Cookbook connection.
138
+ * Pure. No-op (and says so) when it isn't claude, has no token, or the command
139
+ * already pins an MCP config by hand.
140
+ */
141
+ export function withCookbookMcp(command, { token, cookbookUrl } = {}) {
142
+ if (!Array.isArray(command) || !isClaudeCommand(command)) return { command, injected: false, reason: "not claude" };
143
+ if (!token) return { command, injected: false, reason: "no token" };
144
+ if (!cookbookUrl) return { command, injected: false, reason: "no cookbookUrl" };
145
+ if (command.includes("--mcp-config") || command.includes("--strict-mcp-config")) return { command, injected: false, reason: "already pinned" };
146
+ const cfg = JSON.stringify({
147
+ mcpServers: {
148
+ cookbook: {
149
+ type: "http",
150
+ url: `${String(cookbookUrl).replace(/\/$/, "")}/api/mcp`,
151
+ headers: { Authorization: `Bearer ${token}` },
152
+ },
153
+ },
154
+ });
155
+ return { command: [command[0], "--strict-mcp-config", "--mcp-config", cfg, ...command.slice(1)], injected: true, reason: null };
156
+ }
package/local.mjs CHANGED
@@ -238,7 +238,7 @@ export function createLocalServer(deps) {
238
238
  localWorkspaces: localWorkspacesView(),
239
239
  // HARDWARE GRANTS (0069): is this machine currently willing to host a visiting
240
240
  // agent, and what is live right now? The desktop app renders this as the door.
241
- hosting: { enabled: !!cfg.hosting?.enabled, activeGrants: deps.activeGrants ? deps.activeGrants() : [] },
241
+ hosting: { enabled: !!cfg.hosting?.enabled, mode: cfg.hosting?.enabled === true ? "always" : cfg.hosting?.enabled === false ? "off" : "grants", activeGrants: deps.activeGrants ? deps.activeGrants() : [] },
242
242
  hotWorkspaceIds: deps.hotWorkspaceIds ? [...deps.hotWorkspaceIds()] : [],
243
243
  lastError: deps.lastError ? deps.lastError() : null,
244
244
  connect: { state: connect.state },
@@ -352,7 +352,10 @@ export function createLocalServer(deps) {
352
352
  // picks it up immediately (no restart, so a host can close the door NOW).
353
353
  if (route === "POST /hosting") {
354
354
  const body = (await readBody(req)) ?? {};
355
- const enabled = body.enabled !== false;
355
+ // A security toggle whose default is OFF must not fail open: an empty or
356
+ // malformed body used to mean "turn it on" (ultrareview #123, bug_011).
357
+ if (typeof body.enabled !== "boolean") return json(res, 400, { ok: false, error: "`enabled` must be true or false" });
358
+ const enabled = body.enabled;
356
359
  cfg.hosting = { ...(cfg.hosting ?? {}), enabled };
357
360
  try {
358
361
  saveConfigPatch((raw) => { raw.hosting = { ...(raw.hosting ?? {}), enabled }; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cookbook-bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Run your own Claude, Codex and Gemini subscriptions against your Cookbook workspaces. One approval connects every agent CLI on your machine, with a receipt for every run.",
5
5
  "type": "module",
6
6
  "bin": {