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 +42 -0
- package/bridge.mjs +279 -8
- package/config.example.json +29 -10
- package/cookbook.mjs +37 -0
- package/device.mjs +144 -2
- package/hands.mjs +868 -0
- package/local.mjs +21 -0
- package/openclaw-runner.mjs +299 -0
- package/package.json +6 -4
- package/usage.mjs +19 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
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)))
|
package/config.example.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)
|
|
38
|
-
"_usage": "agy emits no token/usage JSON
|
|
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
|
|
58
|
-
"_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board
|
|
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)
|
|
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
|
|
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
|
+
}
|