cookbook-bridge 0.1.3 → 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,8 +239,15 @@ 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: [] };
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
@@ -568,14 +568,26 @@ export const RUN_TEMPLATES = Object.freeze({
568
568
 
569
569
  tail_log: (params, ctx) => {
570
570
  const n = Math.min(Math.max(parseInt(params?.lines ?? 120, 10) || 120, 10), 400);
571
- 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");
572
584
  return { local: () => {
573
585
  let text = "";
574
586
  try {
575
587
  const buf = fs.readFileSync(logPath, "utf8");
576
588
  text = buf.split("\n").slice(-n).join("\n");
577
589
  } catch (e) {
578
- return { error: `no bridge.log yet (${e.code || e.message})` };
590
+ return { error: `no Bridge log yet (${e.code || e.message})` };
579
591
  }
580
592
  return { path: logPath, lines: n, text };
581
593
  } };
@@ -922,3 +934,24 @@ export function tomlLooksValid(text) {
922
934
  }
923
935
  return !multi && depth === 0;
924
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 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cookbook-bridge",
3
- "version": "0.1.3",
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": {