cookbook-bridge 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,6 +75,7 @@ you exactly which parts are ready and how to fix the rest.
75
75
  | `node bridge.mjs doctor` | Preflight every prerequisite with exact fixes |
76
76
  | `node bridge.mjs status` | Liveness + agent readiness |
77
77
  | `node bridge.mjs update` | Update the Bridge to match the app (see Self-updating) |
78
+ | `cookbook-bridge host` | Open the door: let an agent someone else runs help you set this machine up, inside a grant you approve. `--off` closes it. |
78
79
 
79
80
  ## Self-updating
80
81
 
@@ -147,6 +148,47 @@ volunteered goals, verifies completion, and reports usage. If an agent runs but
147
148
  finish, the Bridge retries up to `maxAttempts`, then leaves the task open — with the
148
149
  likely cause in the log (login missing, MCP not connected, tool blocked).
149
150
 
151
+ ## Letting someone else's agent help (grants)
152
+
153
+ Setting up agents is the one thing you can't ask your agents to do, because they're
154
+ what's broken. So a **hardware grant** lets someone else's agent do it:
155
+
156
+ ```bash
157
+ npx cookbook-bridge host # open the door (Node + one browser approval; no agents needed)
158
+ ```
159
+
160
+ Then invite one from Cookbook. What that actually means:
161
+
162
+ - **Their brain stays on their machine.** The visiting agent keeps running on its
163
+ owner's computer and subscription. Only its *hands* travel, and **your** Bridge is
164
+ what executes them.
165
+ - **There is no shell.** It can only invoke named verbs from a fixed table — read a
166
+ setup file, list a folder, run the doctor, run one of a few fixed commands. It
167
+ cannot compose a command line.
168
+ - **Credential files are never readable.** `.credentials.json`, `auth.json`,
169
+ `oauth_creds.json`, `~/.ssh`, anything `.env`, any `.pem`. That list wins over
170
+ every grant, including one where you shared your whole home folder.
171
+ - **Secrets are stripped before anything leaves.** Tokens, keys and your real paths
172
+ are redacted here, on your machine, and again on the server.
173
+ - **This machine decides.** Every rule is re-checked locally against the grant. An
174
+ action that needs your approval runs only after you approved it — a server that
175
+ said otherwise would be refused right here.
176
+ - **You watch, and it's on the record.** Every call shows up before it runs and
177
+ settles into a permanent receipt. `--off`, or End in the browser, closes the door
178
+ immediately.
179
+
180
+ Two kinds of grant, and you pick when you invite:
181
+
182
+ - **Look only** — the visitor can diagnose and cannot change one byte.
183
+ - **Look and fix** — it can also repair things, and *every change waits for your
184
+ click*. It proposes (`write_file`, `clear_needs_auth_cache`, `bridge_connect_agents`,
185
+ a Bridge restart, installing `cookbook-bridge`), you approve or refuse. Files are
186
+ backed up before they change, and a config that wouldn't parse is refused rather
187
+ than written.
188
+
189
+ The Bridge caps write/install/login at "ask" in its own local ceiling, so no grant
190
+ and no server can promote one to automatic. The click is a property of your machine.
191
+
150
192
  ## Honest limits
151
193
 
152
194
  - **Local only** — tasks run while your machine and the Bridge are up.
package/bridge.mjs CHANGED
@@ -26,6 +26,7 @@
26
26
  * Node built-ins only. No dependencies.
27
27
  */
28
28
  import fs from "node:fs";
29
+ import os from "node:os";
29
30
  import path from "node:path";
30
31
  import { fileURLToPath } from "node:url";
31
32
  import { spawn } from "node:child_process";
@@ -45,11 +46,14 @@ let hasCodexThread, reapCodexServer, killCodexServer;
45
46
  let checkForUpdate, applyUpdate;
46
47
  let createLocalServer, toolsForMode, modeForTools, vendorOf;
47
48
  let connectAgentsProgrammatic, detectClis;
49
+ let serveCalls, describeCall;
50
+ let fetchHands, claimHandsCall, reportHandsResult;
48
51
 
49
52
  async function loadRuntime() {
50
53
  ({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
51
54
  ({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
52
- ({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings } = await import("./cookbook.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"));
53
57
  ({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION } = await import("./harden.mjs"));
54
58
  ({ extractUsage, displayText } = await import("./usage.mjs"));
55
59
  ({ volunteeringEnabled, volunteerCandidates, decisionPrompt, parseDecision, MAX_DECISIONS_PER_POLL, mergeVolunteerSettings, effectiveCapabilities } = await import("./volunteer.mjs"));
@@ -63,7 +67,16 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
63
67
 
64
68
  function log(msg) {
65
69
  const ts = new Date().toISOString().slice(11, 19);
66
- console.log(`[${ts}] ${msg}`);
70
+ // NEVER let logging kill the Bridge. If stdout is gone (a re-exec that inherited a
71
+ // dying socket, a closed pipe, a parent that exited) console.log THROWS, and a throw
72
+ // in here propagates out of the poll loop's own catch handler — which killed the loop
73
+ // while leaving the process alive and Bridge Local still answering "connected".
74
+ // Diagnosed live 2026-08-24: a wedged Bridge with no fd 1 or 2, 14 minutes silent.
75
+ try {
76
+ console.log(`[${ts}] ${msg}`);
77
+ } catch {
78
+ /* stdout is unusable; a Bridge with no voice must still do its job */
79
+ }
67
80
  }
68
81
 
69
82
  /**
@@ -147,6 +160,11 @@ function loadConfig() {
147
160
  // terminal, because they asked. Teammate-assigned tasks NEVER get local access;
148
161
  // they stay jailed to workspace tools. This is the terminal-parity wall.
149
162
  cfg.localWorkspaces = cfg.localWorkspaces ?? {};
163
+ // HOSTING (hardware grants, 0069): may a teammate's agent, invited by YOU in the
164
+ // browser, run granted verbs on this machine? Off unless explicitly enabled —
165
+ // `cookbook-bridge host` sets it. A Bridge that never hosts never even asks the
166
+ // server for calls, so this costs nothing when unused.
167
+ cfg.hosting = cfg.hosting ?? { enabled: false };
150
168
  cfg.maxAttempts = cfg.maxAttempts ?? 2;
151
169
  // Phase 1 semantics: taskTimeoutSeconds is the ABSOLUTE CEILING (cost backstop),
152
170
  // livenessTimeoutSeconds is the stall detector (no output for this long = dead).
@@ -441,6 +459,16 @@ async function runAgent(cfg, agent, prompt, onProgress, retry = null, taskCtx =
441
459
  const threadKey = taskCtx.task ? taskCtx.task.thread_root_id ?? taskCtx.task.id : undefined;
442
460
  return runCodexTask(agent, prompt, cfg.taskTimeoutSeconds, agent.token || cfg.codexToken, env, onProgress, { threadKey, log });
443
461
  }
462
+ if (agent.runner === "openclaw") {
463
+ // The visiting-agent lane: one Gateway-backed turn, resumed by session id so a
464
+ // thread stays a conversation. Its own token (agent.token) is what reaches
465
+ // Cookbook — for Chef that is the scoped VISITOR credential, never the owner's.
466
+ const { runOpenclawTask } = await import("./openclaw-runner.mjs");
467
+ return runOpenclawTask(agent, prompt, cfg.taskTimeoutSeconds, env, onProgress, {
468
+ sessionId: retry?.sessionId ?? taskCtx.sessionRef ?? null,
469
+ log,
470
+ });
471
+ }
444
472
  if (agent.runner === "robot") {
445
473
  // Embodied runner (sim-first): structured task env, not a prompt — the robot's
446
474
  // "brain" is a skill program. Same verify-via-getTask contract as every runner.
@@ -563,6 +591,11 @@ let lastRunError = null;
563
591
  /** Whether the Cookbook token has ever verified this run (gates /status.connected and
564
592
  * keeps a token-rejected desktop Bridge alive instead of exiting). */
565
593
  let tokenOk = false;
594
+ /** When this Bridge last actually REACHED Cookbook. `tokenOk` only records that the
595
+ * token verified once, at startup — so a Bridge whose poll loop has died still
596
+ * reported "connected" forever (diagnosed live 2026-08-24: 14 minutes mute, process
597
+ * alive, Bridge Local cheerfully green). Liveness has to be a timestamp, not a flag. */
598
+ let lastContactAt = 0;
566
599
  /** The desktop app sets COOKBOOK_DESKTOP=1 when it spawns the Bridge. Only then do we
567
600
  * stay alive on a revoked token (so the user can fix it from the app's Connect UI). A
568
601
  * headless/terminal Bridge still exits loudly with the fix — never a silent zombie. */
@@ -595,6 +628,7 @@ function applyConfigFromDisk(cfg) {
595
628
  if (raw.cookbookUrl) cfg.cookbookUrl = String(raw.cookbookUrl).replace(/\/$/, "");
596
629
  cfg.default = raw.default;
597
630
  cfg.localWorkspaces = raw.localWorkspaces ?? {};
631
+ cfg.hosting = raw.hosting ?? { enabled: false };
598
632
  const agents = (raw.agents ?? []).filter((a) => a.enabled !== false);
599
633
  cfg.agents.splice(0, cfg.agents.length, ...agents);
600
634
  // A reload usually follows connect-agents fixing the token — let the next poll
@@ -606,7 +640,29 @@ function applyConfigFromDisk(cfg) {
606
640
  /** Re-exec this Bridge on the same argv (the self-update restart path). */
607
641
  function reexecSelf() {
608
642
  log("↻ restarting…");
609
- const child = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "inherit" });
643
+ // NEVER inherit stdio here. This is called from Bridge Local's POST /restart — i.e.
644
+ // from inside an HTTP request handler — so "inherit" hands the child the dying
645
+ // response socket as its stdout. Once that socket closes the child has no fd 1 or 2,
646
+ // every log() throws, and the poll loop dies while the process stays alive and
647
+ // Bridge Local keeps reporting "connected". That is the desktop app's Restart button
648
+ // silently wedging the Bridge, and it is exactly the failure that looks like health.
649
+ // Diagnosed live 2026-08-24 on a Bridge that had been mute for 14 minutes.
650
+ //
651
+ // Append to the log file instead, so a restarted Bridge keeps a voice.
652
+ let out = "ignore";
653
+ let err = "ignore";
654
+ try {
655
+ const logPath = path.join(path.dirname(CONFIG_PATH || HERE), "bridge.log");
656
+ const fd = fs.openSync(logPath, "a");
657
+ out = fd;
658
+ err = fd;
659
+ } catch {
660
+ /* no writable log — silence beats a wedged child */
661
+ }
662
+ const child = spawn(process.execPath, process.argv.slice(1), {
663
+ detached: true,
664
+ stdio: ["ignore", out, err],
665
+ });
610
666
  child.unref();
611
667
  process.exit(0);
612
668
  }
@@ -1092,6 +1148,110 @@ async function quickScan(cfg) {
1092
1148
  return true;
1093
1149
  }
1094
1150
 
1151
+ // ── HOSTING: a visiting agent's hands on THIS machine (hardware grants, 0069) ─
1152
+ //
1153
+ // Someone the host invited — in a grant the host approved, with a scope the host
1154
+ // set — queues calls; this Bridge executes them locally and posts back what
1155
+ // happened. Every rule is re-checked here (bridge/hands.mjs authorizeCall), because
1156
+ // the machine that runs a thing is the only honest place to decide whether it may.
1157
+ // Serialized: one visiting agent, one pair of hands, one thing at a time — which is
1158
+ // also what keeps the live log readable to the human watching it.
1159
+ let handsBusy = false;
1160
+ const hands = { supported: true, activeGrants: [] };
1161
+ // Module-scoped ON PURPOSE. It used to be `let stopped` inside main(), and the
1162
+ // hands path above referenced it from here — a ReferenceError on EVERY hands poll,
1163
+ // so a host Bridge with hosting on accepted grants and executed nothing. Found in
1164
+ // the first real rehearsal (2026-08-25); every hermetic test simulated the host
1165
+ // loop with library code and never ran this file.
1166
+ let stopped = false;
1167
+
1168
+ /** Run lifecycle → Bridge Local subscribers (the desktop tray + notifications). */
1169
+ function emitHands(state, call, result) {
1170
+ if (!localServer) return;
1171
+ try {
1172
+ localServer.emit("hands", {
1173
+ state,
1174
+ callId: call?.id ?? null,
1175
+ grantId: call?.grant_id ?? null,
1176
+ workspaceId: call?.workspace_id ?? null,
1177
+ visitor: call?.visitor ?? null,
1178
+ verb: call?.verb ?? null,
1179
+ summary: describeCall ? describeCall(call) : (call?.verb ?? null),
1180
+ risk: call?.risk ?? null,
1181
+ ...(result ? { error: result.error ?? null } : {}),
1182
+ at: new Date().toISOString(),
1183
+ });
1184
+ } catch { /* notifications are best-effort */ }
1185
+ }
1186
+
1187
+ /**
1188
+ * Something a visiting agent proposed is waiting on the HOST. It is not runnable
1189
+ * here — the whole point is that it waits — but this machine is the right place to
1190
+ * TELL them, so the desktop app can raise a notification instead of the host having
1191
+ * to be watching the browser tab. Announced once per call.
1192
+ */
1193
+ const announcedAwaiting = new Set();
1194
+ function noteAwaiting(awaiting) {
1195
+ for (const call of awaiting ?? []) {
1196
+ if (announcedAwaiting.has(call.id)) continue;
1197
+ announcedAwaiting.add(call.id);
1198
+ const what = describeCall ? describeCall(call) : call.verb;
1199
+ log(`⏳ ${call.visitor ?? "A visiting agent"} wants to ${what} — approve or refuse it in Cookbook.`);
1200
+ emitHands("needs-approval", call);
1201
+ }
1202
+ if (announcedAwaiting.size > 500) announcedAwaiting.clear();
1203
+ }
1204
+
1205
+ async function serveHands(cfg, calls) {
1206
+ if (!cfg.hosting?.enabled || handsBusy || !calls || calls.length === 0) return;
1207
+ handsBusy = true;
1208
+ try {
1209
+ await serveCalls(calls, {
1210
+ cfg,
1211
+ cfgPath: CONFIG_PATH,
1212
+ home: os.homedir(),
1213
+ // The host's OWN folder list. A granted folder is honoured only if it is here
1214
+ // (or inside one), so the server can never hand a visitor a directory.
1215
+ hostFolders: cfg.hosting?.folders ?? [],
1216
+ doctor: () => doctorReport(["--config", CONFIG_PATH]),
1217
+ claim: (id) => claimHandsCall(cfg, id),
1218
+ report: (id, r) => reportHandsResult(cfg, id, r),
1219
+ // Repair templates reach the same machinery the desktop app's buttons use, so
1220
+ // a fix an agent performs is exactly the fix the host could have clicked.
1221
+ restart: () => reexecSelf(),
1222
+ startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
1223
+ applyConfig: () => applyConfigFromDisk(cfg),
1224
+ log,
1225
+ stopped: () => stopped,
1226
+ visitorLabel: (c) => c.visitor ?? "a visiting agent",
1227
+ onCall: emitHands,
1228
+ });
1229
+ } catch (e) {
1230
+ log(`! hands error: ${e.message}`);
1231
+ } finally {
1232
+ handsBusy = false;
1233
+ }
1234
+ }
1235
+
1236
+ /** Poll for granted calls (the net under the push channel, and the whole story on a
1237
+ * server or network without SSE). No-ops entirely when not hosting. */
1238
+ async function pollHands(cfg) {
1239
+ if (!cfg.hosting?.enabled || !hands.supported || handsBusy) return;
1240
+ try {
1241
+ const r = await fetchHands(cfg);
1242
+ if (!r.supported) {
1243
+ hands.supported = false;
1244
+ log("· this Cookbook has no hardware-grant channel — hosting is unavailable");
1245
+ return;
1246
+ }
1247
+ hands.activeGrants = r.grants ?? [];
1248
+ noteAwaiting(r.awaiting ?? []);
1249
+ await serveHands(cfg, r.calls);
1250
+ } catch (e) {
1251
+ if (!/40[13]/.test(e.message)) log(`! couldn't check for granted work: ${e.message}`);
1252
+ }
1253
+ }
1254
+
1095
1255
  // ── THE PUSH CHANNEL (SSE) ────────────────────────────────────────────────────
1096
1256
  // One outbound connection; the server pushes open work + warm hints the moment
1097
1257
  // they change (~sub-second dispatch). Connections are short-lived by design
@@ -1149,6 +1309,16 @@ async function socketLoop(cfg) {
1149
1309
  void dispatchWork(cfg, j.work ?? [], j.warm_hints ?? []);
1150
1310
  } catch { /* malformed frame — next snapshot covers */ }
1151
1311
  }
1312
+ // A visiting agent queued a call on a grant this machine hosts. Push is
1313
+ // what makes "watch an agent work on your laptop" feel live.
1314
+ if (ev.event === "hands" && ev.data) {
1315
+ try {
1316
+ const j = JSON.parse(ev.data);
1317
+ hands.activeGrants = j.grants ?? hands.activeGrants;
1318
+ noteAwaiting(j.awaiting ?? []);
1319
+ void serveHands(cfg, j.calls ?? []);
1320
+ } catch { /* malformed frame — the poll covers it */ }
1321
+ }
1152
1322
  }
1153
1323
  }
1154
1324
  } catch { /* transient — reconnect below */ }
@@ -1436,12 +1606,20 @@ async function main() {
1436
1606
  if (cfg.default && dflt.name !== cfg.default) {
1437
1607
  log(`! config "default" is "${cfg.default}" but no enabled agent has that name — "any"-assigned tasks route to ${dflt.name}.`);
1438
1608
  }
1439
- if (!resolveBin(Array.isArray(dflt.command) ? dflt.command[0] : null)) {
1609
+ // Same binary derivation as the doctor: an openclaw agent has no `command` on
1610
+ // purpose (the runner builds its argv), so check the binary it will actually use
1611
+ // instead of warning "isn't installed" about a perfectly healthy agent.
1612
+ const dfltBin = Array.isArray(dflt.command) && dflt.command.length
1613
+ ? dflt.command[0]
1614
+ : dflt.runner === "openclaw"
1615
+ ? (dflt.bin || "openclaw")
1616
+ : null;
1617
+ if (!resolveBin(dfltBin)) {
1440
1618
  log(`! default agent "${dflt.name}" isn't installed/on PATH — "any"-assigned tasks will fail. Run \`node bridge.mjs doctor\`.`);
1441
1619
  }
1442
1620
  }
1443
1621
 
1444
- let stopped = false;
1622
+ stopped = false; // module-scoped; see the hands section
1445
1623
  process.on("SIGINT", () => {
1446
1624
  stopped = true;
1447
1625
  log("Stopping…");
@@ -1512,8 +1690,19 @@ async function main() {
1512
1690
  applyConfig: () => applyConfigFromDisk(cfg),
1513
1691
  restart: () => reexecSelf(),
1514
1692
  hotWorkspaceIds: () => hotWorkspaceIds(),
1515
- connected: () => tokenOk && consecutive401s === 0,
1516
- lastError: () => lastRunError,
1693
+ activeGrants: () => hands.activeGrants,
1694
+ // HONEST LIVENESS: a Bridge that has not reached Cookbook in two minutes is not
1695
+ // connected, whatever a startup flag says. The poll loop runs at most every
1696
+ // pollSeconds (default 15) and the push channel reconnects each minute, so two
1697
+ // minutes of silence means something is wrong — and saying so is the whole
1698
+ // point of a status endpoint.
1699
+ connected: () => tokenOk && consecutive401s === 0 && Date.now() - lastContactAt < 120_000,
1700
+ lastError: () => {
1701
+ if (tokenOk && Date.now() - lastContactAt > 120_000) {
1702
+ return `no contact with Cookbook for ${Math.round((Date.now() - lastContactAt) / 1000)}s — the Bridge may be stuck; restart it`;
1703
+ }
1704
+ return lastRunError;
1705
+ },
1517
1706
  });
1518
1707
  await localServer.start();
1519
1708
  for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { try { localServer.stop(); } catch { /* exiting */ } });
@@ -1530,7 +1719,12 @@ async function main() {
1530
1719
  try {
1531
1720
  const ws = await listWorkspaces(cfg);
1532
1721
  tokenOk = true;
1722
+ lastContactAt = Date.now();
1533
1723
  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);
1727
+ }
1534
1728
  if (cfg.persistentThreads) {
1535
1729
  for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { killAllRunners(); killCodexServer(); process.exit(0); });
1536
1730
  process.on("exit", () => { killAllRunners(); killCodexServer(); });
@@ -1577,8 +1771,12 @@ async function main() {
1577
1771
  await pollOnce(cfg);
1578
1772
  }
1579
1773
  }
1774
+ // HOSTING: granted calls ride the same cadence as work. When the push channel
1775
+ // is healthy it has already delivered them; this is the net.
1776
+ if (cfg.hosting?.enabled && !pushHealthy) await pollHands(cfg);
1580
1777
  // A clean poll means the token is good — clear any prior rejection so the app's
1581
1778
  // /status flips back to connected once the user fixes it.
1779
+ lastContactAt = Date.now();
1582
1780
  if (consecutive401s || !tokenOk) { consecutive401s = 0; tokenOk = true; lastRunError = null; }
1583
1781
  } catch (e) {
1584
1782
  log(`! poll error: ${e.message}`);
@@ -1646,6 +1844,35 @@ async function runDoctor(args) {
1646
1844
  process.exit(fails === 0 ? 0 : 1);
1647
1845
  }
1648
1846
 
1847
+ /**
1848
+ * What the doctor can say about a visiting (openclaw) agent WITHOUT starting a turn
1849
+ * on its owner's subscription: is the profile configured, and does the agent id the
1850
+ * Bridge will pass actually exist. Both are local and free.
1851
+ */
1852
+ async function doctorOpenclaw(agent, { ok, warn, bad }) {
1853
+ const { inspectOpenclawProfile, listOpenclawAgents } = await import("./openclaw-runner.mjs");
1854
+ for (const f of inspectOpenclawProfile(agent)) {
1855
+ if (f.level === "bad") bad(f.message, f.fix);
1856
+ else if (f.level === "warn") warn(f.message, f.fix);
1857
+ else ok(f.message);
1858
+ }
1859
+ const wanted = agent.openclawAgent || null;
1860
+ const listed = await listOpenclawAgents(agent);
1861
+ if (!listed.ok) {
1862
+ warn(`${agent.name}: couldn't list OpenClaw agents`, listed.error || "is `openclaw` on PATH?");
1863
+ return;
1864
+ }
1865
+ if (!wanted) {
1866
+ warn(`${agent.name}: no openclawAgent set — the run has no --agent and openclaw will refuse to pick one`,
1867
+ `set "openclawAgent" to one of: ${listed.ids.join(", ") || "(none listed)"}`);
1868
+ } else if (!listed.ids.includes(String(wanted))) {
1869
+ bad(`${agent.name}: openclawAgent "${wanted}" doesn't exist in profile "${agent.profile || "default"}"`,
1870
+ `known agents: ${listed.ids.join(", ") || "(none)"}`);
1871
+ } else {
1872
+ ok(`${agent.name}: agent "${wanted}" exists in profile "${agent.profile || "default"}" (no turn spent)`);
1873
+ }
1874
+ }
1875
+
1649
1876
  /** The doctor's checks as data: { cfgPath, rows: [{ level, label, fix }], fails, warns }.
1650
1877
  * Shared by the CLI (`doctor`, `doctor --json`) and Bridge Local's POST /doctor. */
1651
1878
  async function doctorReport(args) {
@@ -1705,7 +1932,15 @@ async function doctorReport(args) {
1705
1932
 
1706
1933
  // 5. Per agent: binary, allowedTools sanity, and a real login probe
1707
1934
  for (const agent of cfg.agents) {
1708
- const cmd = Array.isArray(agent.command) ? agent.command[0] : null;
1935
+ // An openclaw agent has no `command`: its argv is assembled by the runner from
1936
+ // profile + openclawAgent, precisely so a hand-written command can't drop
1937
+ // --agent. Derive its binary the same way the runner does, or the doctor
1938
+ // reports `binary \`null\` not found` for a perfectly good config.
1939
+ const cmd = Array.isArray(agent.command) && agent.command.length
1940
+ ? agent.command[0]
1941
+ : agent.runner === "openclaw"
1942
+ ? (agent.bin || "openclaw")
1943
+ : null;
1709
1944
  const bin = resolveBin(cmd);
1710
1945
  if (!bin) {
1711
1946
  bad(`${agent.name}: binary \`${cmd}\` not found on PATH`,
@@ -1769,6 +2004,16 @@ async function doctorReport(args) {
1769
2004
  }
1770
2005
 
1771
2006
  if (agent.runner === "app-server") { warn(`${agent.name}: app-server runner — start it to verify login/MCP (probe skipped)`); continue; }
2007
+ // A visiting agent (openclaw) is driven through the Gateway with its own
2008
+ // token, so we will not spend a turn on someone's subscription to probe it.
2009
+ // But "probe skipped" used to mean NOTHING was checked, and a Chef whose
2010
+ // profile had no gateway credentials, no model and no such agent id sat here
2011
+ // reported as fine until the first real question hit it (2026-08-24). Every
2012
+ // check below is local and free, and each one is a failure seen for real.
2013
+ if (agent.runner === "openclaw") {
2014
+ await doctorOpenclaw(agent, { ok, warn, bad });
2015
+ continue;
2016
+ }
1772
2017
  if (agent.runner === "robot") {
1773
2018
  const r = await spawnAgent(agent, "", 15, agentEnv(cfg).env);
1774
2019
  if (r.code === 0 && String(r.out).trim().endsWith("ok")) ok(`${agent.name}: robot agent responds (probe ok)`);
@@ -1863,6 +2108,17 @@ if (!IS_MAIN) {
1863
2108
  console.error(e.message);
1864
2109
  process.exit(1);
1865
2110
  });
2111
+ } else if (sub === "chat") {
2112
+ // Talk to your workspace's agents without leaving the terminal. chat.mjs is a
2113
+ // standalone script that reads its config from argv[2], so reshape argv to make
2114
+ // `cookbook-bridge chat [config.json]` behave like `node chat.mjs [config.json]`.
2115
+ // Without this subcommand the file shipped in every install but was unreachable
2116
+ // by any obvious command.
2117
+ process.argv = [process.argv[0], path.join(HERE, "chat.mjs"), ...process.argv.slice(3)];
2118
+ import("./chat.mjs").catch((e) => {
2119
+ console.error(e.message);
2120
+ process.exit(1);
2121
+ });
1866
2122
  } else if (sub === "connect" || sub === "connect-agents") {
1867
2123
  // `connect` is the documented first command (the connect page and the npm bin both
1868
2124
  // say it); `connect-agents` is the original name, kept working forever.
@@ -1872,6 +2128,21 @@ if (!IS_MAIN) {
1872
2128
  console.error(e.message);
1873
2129
  process.exit(1);
1874
2130
  });
2131
+ } else if (sub === "host") {
2132
+ // Open the door for a visiting agent (hardware grants). Ends with a RUNNING
2133
+ // Bridge, because a door nobody is standing behind isn't open.
2134
+ import("./device.mjs")
2135
+ .then(async (m) => {
2136
+ const r = await m.host(process.argv.slice(3));
2137
+ if (r && r.startBridge) {
2138
+ process.argv = [process.argv[0], process.argv[1]];
2139
+ await main();
2140
+ }
2141
+ })
2142
+ .catch((e) => {
2143
+ console.error(e.message);
2144
+ process.exit(1);
2145
+ });
1875
2146
  } else if (sub === "status") {
1876
2147
  import("./device.mjs")
1877
2148
  .then((m) => m.status(process.argv.slice(3)))
@@ -4,18 +4,18 @@
4
4
  "pollSeconds": 15,
5
5
  "maxAttempts": 2,
6
6
  "taskTimeoutSeconds": 3600,
7
- "_timeouts": "taskTimeoutSeconds is the ABSOLUTE ceiling per run (cost backstop). livenessTimeoutSeconds kills a STALLED run \u2014 no output for this many seconds (streaming runs only). Healthy long work runs to the ceiling; silence dies fast.",
7
+ "_timeouts": "taskTimeoutSeconds is the ABSOLUTE ceiling per run (cost backstop). livenessTimeoutSeconds kills a STALLED run no output for this many seconds (streaming runs only). Healthy long work runs to the ceiling; silence dies fast.",
8
8
  "livenessTimeoutSeconds": 300,
9
9
  "_concurrency": "How many task runs may be in flight at once. Runs launch in parallel up to this cap; the atomic pre-claim keeps every task single-runner.",
10
10
  "maxConcurrentRuns": 2,
11
11
  "_billing": "Agents run on the CLI subscriptions you already pay for. The Bridge hides ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY from agent processes so a task can never silently bill your API account instead. Set allowApiKeyBilling to true ONLY if you explicitly want API-key billing.",
12
12
  "allowApiKeyBilling": false,
13
- "_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities \u2014 decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
13
+ "_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
14
14
  "volunteering": true,
15
15
  "_autoUpdate": "The Bridge follows the app: it checks the deploy's file manifest at startup + every 6h, and self-updates (hash-verified, originals kept in bridge.backup/, your config/token never touched) then restarts itself. Set false to pin your version and update manually with `node bridge.mjs update`.",
16
16
  "autoUpdate": true,
17
17
  "default": "Gemini",
18
- "_acceptFrom": "Who may auto-run tasks on this Bridge: \"anyone\", or a list of EXACT member names or profile ids (case-insensitive, no partial matching \u2014 consent never guesses).",
18
+ "_acceptFrom": "Who may auto-run tasks on this Bridge: \"anyone\", or a list of EXACT member names or profile ids (case-insensitive, no partial matching consent never guesses).",
19
19
  "acceptFrom": "anyone",
20
20
  "agents": [
21
21
  {
@@ -34,8 +34,8 @@
34
34
  "--print-timeout",
35
35
  "3600s"
36
36
  ],
37
- "_setup": "Gemini runs via the Antigravity CLI (agy) \u2014 the old `gemini` CLI stopped serving individual accounts June 18 2026. Requires agy >= 1.1.1 (first version whose headless -p can call MCP tools; the Bridge refuses older). Auth: run `agy` once interactively and sign in with Google. MCP: `node bridge/bridge.mjs connect-agents` writes ~/.gemini/config/mcp_config.json (agy has no `mcp add`). --sandbox keeps terminal restrictions on \u2014 Cookbook work flows through MCP tools, not the shell. Keep --print-timeout >= taskTimeoutSeconds (ceiling) so agy doesn't cut off before the Bridge's own timeout.",
38
- "_usage": "agy emits no token/usage JSON \u2014 the board shows wall-clock duration for its runs (no token count)."
37
+ "_setup": "Gemini runs via the Antigravity CLI (agy) the old `gemini` CLI stopped serving individual accounts June 18 2026. Requires agy >= 1.1.1 (first version whose headless -p can call MCP tools; the Bridge refuses older). Auth: run `agy` once interactively and sign in with Google. MCP: `node bridge/bridge.mjs connect-agents` writes ~/.gemini/config/mcp_config.json (agy has no `mcp add`). --sandbox keeps terminal restrictions on Cookbook work flows through MCP tools, not the shell. Keep --print-timeout >= taskTimeoutSeconds (ceiling) so agy doesn't cut off before the Bridge's own timeout.",
38
+ "_usage": "agy emits no token/usage JSON the board shows wall-clock duration for its runs (no token count)."
39
39
  },
40
40
  {
41
41
  "name": "Claude",
@@ -54,8 +54,8 @@
54
54
  "--output-format",
55
55
  "json"
56
56
  ],
57
- "_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause \u2014 `node bridge.mjs doctor` checks it.",
58
- "_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board \u2014 text works too, you just lose the usage report"
57
+ "_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause `node bridge.mjs doctor` checks it.",
58
+ "_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board text works too, you just lose the usage report"
59
59
  },
60
60
  {
61
61
  "name": "Codex",
@@ -68,9 +68,24 @@
68
68
  "command": [
69
69
  "/Applications/ChatGPT.app/Contents/Resources/codex"
70
70
  ],
71
- "_binary": "Codex merged into the ChatGPT desktop app (July 2026) \u2014 older installs had /Applications/Codex.app/Contents/Resources/codex. If doctor says 'binary not found', check both paths.",
71
+ "_binary": "Codex merged into the ChatGPT desktop app (July 2026) older installs had /Applications/Codex.app/Contents/Resources/codex. If doctor says 'binary not found', check both paths.",
72
72
  "sandbox": "workspace-write",
73
- "_setup": "Codex (ChatGPT) via codex app-server \u2014 its headless `exec` can't call MCP tools (OpenAI #16685), so the Bridge drives the app-server protocol and auto-approves tool elicitations (bridge/codex-runner.mjs). To enable: (1) make a clean CODEX_HOME at ~/.codex-bridge with config.toml [mcp_servers.cookbook] (url=<cookbookUrl>/api/mcp, bearer_token_env_var=COOKBOOK_CODEX_TOKEN) and a copy of ~/.codex/auth.json so it uses your ChatGPT login; (2) add a Cookbook token here as \"token\" (separate from the Bridge token, so Codex's work is attributed to Codex); (3) optionally set \"codexHome\" if not ~/.codex-bridge; (4) set enabled:true."
73
+ "_setup": "Codex (ChatGPT) via codex app-server its headless `exec` can't call MCP tools (OpenAI #16685), so the Bridge drives the app-server protocol and auto-approves tool elicitations (bridge/codex-runner.mjs). To enable: (1) make a clean CODEX_HOME at ~/.codex-bridge with config.toml [mcp_servers.cookbook] (url=<cookbookUrl>/api/mcp, bearer_token_env_var=COOKBOOK_CODEX_TOKEN) and a copy of ~/.codex/auth.json so it uses your ChatGPT login; (2) add a Cookbook token here as \"token\" (separate from the Bridge token, so Codex's work is attributed to Codex); (3) optionally set \"codexHome\" if not ~/.codex-bridge; (4) set enabled:true."
74
+ },
75
+ {
76
+ "name": "Chef",
77
+ "match": [
78
+ "chef",
79
+ "openclaw"
80
+ ],
81
+ "enabled": false,
82
+ "runner": "openclaw",
83
+ "_setup": "A VISITING agent: it works on other people's machines through hardware grants. Its Cookbook credential does NOT go here — OpenClaw's MCP servers are global to a config file, so a token in this entry would look like isolation without being it. Instead give Chef its own PROFILE: `openclaw --profile chef mcp add cookbook --url <site>/api/mcp --transport streamable-http --header \"Authorization=Bearer <VISITOR token>\"`. That profile has its own config and state under ~/.openclaw-chef, so this Chef carries the scoped visitor credential while your own OpenClaw keeps your personal one. Mint the visitor token with `npm run setup:chef`; the tokens page can't make one.",
84
+ "_profile_needs": "The profile must also have gateway.auth (its own token AND its own gateway.port) and agents.defaults.model.primary — without them every run dies with GatewayCredentialsRequiredError. `bridge doctor` checks all of this for free.",
85
+ "_no_command": "Deliberately no \"command\": the runner builds the argv from profile + openclawAgent. A hand-written command that omits --agent loses the session selector and openclaw exits with 'No target session selected' — which is exactly how this shipped broken once.",
86
+ "_usage": "OpenClaw's --json envelope carries usage + sessionId, so runs get a real receipt and threads resume.",
87
+ "profile": "chef",
88
+ "openclawAgent": "main"
74
89
  }
75
90
  ],
76
91
  "persistentThreads": true,
@@ -79,5 +94,9 @@
79
94
  "cwd": "/absolute/path/to/project",
80
95
  "allowedTools": null
81
96
  }
97
+ },
98
+ "_hosting": "HARDWARE GRANTS: may an agent someone else runs act on THIS machine? Off by default. `cookbook-bridge host` turns it on; the desktop app has a switch. Even ON, nothing happens until YOU approve a grant in the browser: you set the scope, you watch every call in the log, and `cookbook-bridge host --off` (or End in the UI) closes the door immediately.",
99
+ "hosting": {
100
+ "enabled": false
82
101
  }
83
- }
102
+ }
package/cookbook.mjs CHANGED
@@ -232,3 +232,40 @@ export async function recallAcrossWorkspaces(cfg, query, excludeWorkspaceId, lim
232
232
  return [];
233
233
  }
234
234
  }
235
+
236
+ // ── HARDWARE GRANTS (0069) — the host side ───────────────────────────────────
237
+ // These are plain REST, not MCP tools: they are the HOST's channel, and the host
238
+ // is not an agent here — it is a machine executing calls its owner invited in. A
239
+ // server that predates grants 404s, which every caller treats as "not hosting".
240
+
241
+ /** Pending calls (and the live grants they belong to) for THIS Bridge's token. */
242
+ export async function fetchHands(cfg) {
243
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
244
+ headers: { Authorization: `Bearer ${cfg.token}` },
245
+ });
246
+ if (res.status === 404) return { supported: false, calls: [], grants: [] };
247
+ if (!res.ok) throw new Error(`hands ${res.status}`);
248
+ const j = await res.json().catch(() => ({}));
249
+ return { supported: true, calls: j.calls ?? [], grants: j.grants ?? [] };
250
+ }
251
+
252
+ /** Claim one call before running it. The server CASes on status, so two Bridges on
253
+ * the same token (or a re-delivered push frame) can never double-execute. */
254
+ export async function claimHandsCall(cfg, callId) {
255
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
256
+ method: "POST",
257
+ headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
258
+ body: JSON.stringify({ call_id: callId }),
259
+ });
260
+ return res.ok;
261
+ }
262
+
263
+ /** Post the (already-redacted) outcome. The server redacts again before storing. */
264
+ export async function reportHandsResult(cfg, callId, result) {
265
+ const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands/${encodeURIComponent(callId)}`, {
266
+ method: "POST",
267
+ headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
268
+ body: JSON.stringify({ status: result.status, output: result.output, error: result.error }),
269
+ });
270
+ return res.ok;
271
+ }