cookbook-bridge 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.mjs +33 -16
- package/cookbook.mjs +9 -2
- package/device.mjs +13 -0
- package/hands.mjs +96 -7
- package/harden.mjs +39 -0
- package/local.mjs +5 -2
- package/package.json +1 -1
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
|
-
|
|
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 ?? {
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
|
1787
|
+
if (hostingMode(cfg) !== "off" && !pushHealthy) await pollHands(cfg);
|
|
1777
1788
|
// A clean poll means the token is good — clear any prior rejection so the app's
|
|
1778
1789
|
// /status flips back to connected once the user fixes it.
|
|
1779
1790
|
lastContactAt = Date.now();
|
|
@@ -1899,6 +1910,7 @@ async function doctorReport(args) {
|
|
|
1899
1910
|
cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
1900
1911
|
cfg.cookbookUrl = (cfg.cookbookUrl || "").replace(/\/$/, "");
|
|
1901
1912
|
cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
|
|
1913
|
+
for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
|
|
1902
1914
|
if (!cfg.cookbookUrl || !cfg.token || String(cfg.token).startsWith("PASTE")) {
|
|
1903
1915
|
bad("Config is missing cookbookUrl or a real token", "set both in config.json (token from your Cookbook → Tokens page)");
|
|
1904
1916
|
cfg = null;
|
|
@@ -1998,6 +2010,11 @@ async function doctorReport(args) {
|
|
|
1998
2010
|
}
|
|
1999
2011
|
}
|
|
2000
2012
|
|
|
2013
|
+
if (isClaudeCommand && isClaudeCommand(agent.command)) {
|
|
2014
|
+
if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
|
|
2015
|
+
else warn(`${agent.name}: no per-agent token — runs use the claude CLI's OWN Cookbook login, which may be a different account and inherits stale claude.ai connectors`,
|
|
2016
|
+
"run `cookbook-bridge connect` (mints a token for this agent) or add \"token\" to this agent in config.json");
|
|
2017
|
+
}
|
|
2001
2018
|
if ((agent.command || []).join(" ").includes("mcp__claude_ai_Cookbook__")) {
|
|
2002
2019
|
warn(`${agent.name}: allowedTools uses mcp__claude_ai_Cookbook__* — a CLI-added server is usually mcp__cookbook__*`,
|
|
2003
2020
|
"if tasks 'run but never complete', switch allowedTools to mcp__cookbook__*");
|
package/cookbook.mjs
CHANGED
|
@@ -239,14 +239,21 @@ export async function recallAcrossWorkspaces(cfg, query, excludeWorkspaceId, lim
|
|
|
239
239
|
// server that predates grants 404s, which every caller treats as "not hosting".
|
|
240
240
|
|
|
241
241
|
/** Pending calls (and the live grants they belong to) for THIS Bridge's token. */
|
|
242
|
+
/** `?agents=Claude,Gemini,Chef` — what this Bridge manages, so the server can say
|
|
243
|
+
* "your Bridge is running but doesn't run X" instead of "start a Bridge". */
|
|
244
|
+
export function agentsQuery(cfg) {
|
|
245
|
+
const names = (cfg?.agents ?? []).filter((a) => a && a.enabled !== false && a.name).map((a) => String(a.name));
|
|
246
|
+
return names.length ? `?agents=${encodeURIComponent(names.join(","))}` : "";
|
|
247
|
+
}
|
|
248
|
+
|
|
242
249
|
export async function fetchHands(cfg) {
|
|
243
|
-
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
|
|
250
|
+
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands${agentsQuery(cfg)}`, {
|
|
244
251
|
headers: { Authorization: `Bearer ${cfg.token}` },
|
|
245
252
|
});
|
|
246
253
|
if (res.status === 404) return { supported: false, calls: [], grants: [] };
|
|
247
254
|
if (!res.ok) throw new Error(`hands ${res.status}`);
|
|
248
255
|
const j = await res.json().catch(() => ({}));
|
|
249
|
-
return { supported: true, calls: j.calls ?? [], grants: j.grants ?? [] };
|
|
256
|
+
return { supported: true, calls: j.calls ?? [], grants: j.grants ?? [], awaiting: j.awaiting ?? [] };
|
|
250
257
|
}
|
|
251
258
|
|
|
252
259
|
/** Claim one call before running it. The server CASes on status, so two Bridges on
|
package/device.mjs
CHANGED
|
@@ -291,6 +291,9 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
|
|
|
291
291
|
const add = spawnSync(cli.path, ["mcp", "add", "--scope", "user", "--transport", "http", "cookbook", mcpUrl, "--header", `Authorization: Bearer ${token}`], { encoding: "utf8", timeout: 30_000 });
|
|
292
292
|
if (add.status === 0) results.push({ agent: cli.agent, ok: true, detail: "connected (server 'cookbook', user scope)" });
|
|
293
293
|
else results.push({ agent: cli.agent, ok: false, detail: String(add.stderr || add.stdout || "add failed").trim().slice(0, 200) });
|
|
294
|
+
// The Bridge's OWN runs must not depend on the CLI's global server: store the
|
|
295
|
+
// token on the agent so spawnAgent pins each run to it (--strict-mcp-config).
|
|
296
|
+
if (/claude/i.test(cli.agent)) setAgentToken(cfgPath, /claude/i, token);
|
|
294
297
|
} else if (cli.kind === "file") {
|
|
295
298
|
const wrote = agyConfigure(mcpUrl, token);
|
|
296
299
|
results.push({ agent: cli.agent, ok: true, detail: `connected (${wrote})` });
|
|
@@ -314,6 +317,16 @@ export function configureClis(found, { baseUrl, agentTokens, cfgPath }) {
|
|
|
314
317
|
return results;
|
|
315
318
|
}
|
|
316
319
|
|
|
320
|
+
/** Bridge config: give the agent whose command matches `re` its attributed token. */
|
|
321
|
+
function setAgentToken(cfgPath, re, token) {
|
|
322
|
+
const raw = readConfig(cfgPath);
|
|
323
|
+
if (!raw || !Array.isArray(raw.agents)) return;
|
|
324
|
+
const agent = raw.agents.find((a) => a && re.test(String(a.command?.[0] ?? a.name ?? "")));
|
|
325
|
+
if (!agent) return;
|
|
326
|
+
agent.token = token;
|
|
327
|
+
fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
|
|
328
|
+
}
|
|
329
|
+
|
|
317
330
|
/** Bridge config: make sure a Codex agent exists, is enabled, and carries its pieces. */
|
|
318
331
|
function enableCodexAgent(cfgPath, { binary, codexHome, token }) {
|
|
319
332
|
const raw = readConfig(cfgPath);
|
package/hands.mjs
CHANGED
|
@@ -97,7 +97,7 @@ function collapseHome(text, home) {
|
|
|
97
97
|
const h = String(home ?? "").replace(/\/+$/, "");
|
|
98
98
|
if (!h || h.length < 4) return text;
|
|
99
99
|
const esc = h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
-
return text.replace(new RegExp(esc, "g"), "~");
|
|
100
|
+
return text.replace(new RegExp(esc + "(?=/|$)", "g"), "~");
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/** Redact secrets out of anything this machine produced. Pure. */
|
|
@@ -181,7 +181,10 @@ const NEVER_READ = Object.freeze([
|
|
|
181
181
|
/(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
|
|
182
182
|
/(^|\/)local\.json$/i, // the Bridge Local loopback token
|
|
183
183
|
/(^|\/)\.git\/config$/i, // can carry credentials in a remote URL
|
|
184
|
-
|
|
184
|
+
// `credentials.json` AND its dotfile spelling `.credentials.json` — the anchor
|
|
185
|
+
// used to accept only `/` before the word, so a dotfile in a granted project
|
|
186
|
+
// folder walked through the wall (ultrareview #123, bug_003).
|
|
187
|
+
/(^|[/.])credentials?(\.[A-Za-z0-9]+)?$/i,
|
|
185
188
|
]);
|
|
186
189
|
|
|
187
190
|
/** Every spelling of a path this filesystem might consider the same file. */
|
|
@@ -565,14 +568,26 @@ export const RUN_TEMPLATES = Object.freeze({
|
|
|
565
568
|
|
|
566
569
|
tail_log: (params, ctx) => {
|
|
567
570
|
const n = Math.min(Math.max(parseInt(params?.lines ?? 120, 10) || 120, 10), 400);
|
|
568
|
-
|
|
571
|
+
// Where this Bridge's log actually is: `bridge.log` beside the config (terminal),
|
|
572
|
+
// the newest `*.log` beside it (a LaunchAgent), or the newest in `logs/` (the
|
|
573
|
+
// desktop app). Chef's first look at a desktop-app machine got "no bridge.log
|
|
574
|
+
// yet" for a Bridge that was logging fine (2026-08-25).
|
|
575
|
+
const dir = path.dirname(ctx.cfgPath || "");
|
|
576
|
+
const newest = (d) => {
|
|
577
|
+
try {
|
|
578
|
+
return fs.readdirSync(d).filter((f) => f.endsWith(".log")).map((f) => path.join(d, f))
|
|
579
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] ?? null;
|
|
580
|
+
} catch { return null; }
|
|
581
|
+
};
|
|
582
|
+
const logPath = [path.join(dir, "bridge.log"), newest(path.join(dir, "logs")), newest(dir)]
|
|
583
|
+
.find((p) => p && fs.existsSync(p)) ?? path.join(dir, "bridge.log");
|
|
569
584
|
return { local: () => {
|
|
570
585
|
let text = "";
|
|
571
586
|
try {
|
|
572
587
|
const buf = fs.readFileSync(logPath, "utf8");
|
|
573
588
|
text = buf.split("\n").slice(-n).join("\n");
|
|
574
589
|
} catch (e) {
|
|
575
|
-
return { error: `no
|
|
590
|
+
return { error: `no Bridge log yet (${e.code || e.message})` };
|
|
576
591
|
}
|
|
577
592
|
return { path: logPath, lines: n, text };
|
|
578
593
|
} };
|
|
@@ -691,6 +706,13 @@ const VERBS = {
|
|
|
691
706
|
if (ext === ".json") {
|
|
692
707
|
try { JSON.parse(content); } catch (e) { return { error: `That isn't valid JSON, so it wasn't written: ${e.message.slice(0, 120)}` }; }
|
|
693
708
|
}
|
|
709
|
+
// The setup allowlist carries two TOML files (Codex). The "won't parse ⇒ not
|
|
710
|
+
// written" promise covered only JSON until ultrareview #123 (bug_008) — this is
|
|
711
|
+
// a structural check, not a full parser: unbalanced quotes/brackets, lines that
|
|
712
|
+
// are neither a table header nor `key = value`, unterminated multi-line strings.
|
|
713
|
+
if (ext === ".toml" && !tomlLooksValid(content)) {
|
|
714
|
+
return { error: "That doesn't look like valid TOML (unbalanced quotes/brackets or a malformed line), so it wasn't written." };
|
|
715
|
+
}
|
|
694
716
|
if (content.includes("\0")) return { error: "Content contains a null byte." };
|
|
695
717
|
|
|
696
718
|
let backup = null;
|
|
@@ -713,9 +735,15 @@ const VERBS = {
|
|
|
713
735
|
if (!r.ok) return { error: r.error };
|
|
714
736
|
const name = String(args.backup ?? "");
|
|
715
737
|
if (!BACKUP_RE.test(name)) return { error: "Pass the backup filename this session created (….bak-chef-<timestamp>)." };
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
738
|
+
// write_file placed the backup next to the RESOLVED file (r.real), so a
|
|
739
|
+
// symlinked setup file — dotfile managers do this constantly — keeps its backup
|
|
740
|
+
// in the target directory. Look there first, then beside the symlink itself
|
|
741
|
+
// (ultrareview #123, bug_009: restore used to look only beside the symlink and
|
|
742
|
+
// report "isn't there any more" about a backup that existed).
|
|
743
|
+
const candidates = [path.join(path.dirname(r.real), name), path.join(path.dirname(r.path), name)];
|
|
744
|
+
if (candidates.some((c) => path.basename(c) !== name)) return { error: "Invalid backup name." };
|
|
745
|
+
const backupPath = candidates.find((c) => fs.existsSync(c));
|
|
746
|
+
if (!backupPath) return { error: "That backup isn't there any more." };
|
|
719
747
|
try { fs.copyFileSync(backupPath, r.path); } catch (e) { return { error: `restore failed: ${e.code || e.message}` }; }
|
|
720
748
|
return { path: args.path, restored_from: name };
|
|
721
749
|
},
|
|
@@ -863,6 +891,67 @@ export function describeCall(call) {
|
|
|
863
891
|
case "run": return `run ${a.template}`;
|
|
864
892
|
case "doctor": return "run the setup doctor";
|
|
865
893
|
case "env": return "look at what's installed";
|
|
894
|
+
// The three verbs a host must approve are the three that used to render as a
|
|
895
|
+
// bare verb name (ultrareview #123, bug_007). Say WHAT, not just which.
|
|
896
|
+
case "write_file": return `write ${a.path}${typeof a.content === "string" ? ` (${a.content.length} chars)` : ""}`;
|
|
897
|
+
case "restore_backup": return `restore ${a.path} from ${a.backup ?? "its backup"}`;
|
|
898
|
+
case "open_url": {
|
|
899
|
+
try { const u = new URL(String(a.url ?? "")); return `open ${u.host}${u.pathname === "/" ? "" : u.pathname} in your browser`; }
|
|
900
|
+
catch { return `open ${a.url ?? "a page"} in your browser`; }
|
|
901
|
+
}
|
|
866
902
|
default: return String(call?.verb ?? "?");
|
|
867
903
|
}
|
|
868
904
|
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Structural TOML sanity check for write_file. Deliberately NOT a parser: it
|
|
908
|
+
* rejects the ways a distracted agent breaks a config (unbalanced quotes or
|
|
909
|
+
* brackets, an unterminated multi-line string, a line that is neither a table
|
|
910
|
+
* header nor `key = value`) and accepts anything that has that shape.
|
|
911
|
+
*/
|
|
912
|
+
export function tomlLooksValid(text) {
|
|
913
|
+
let multi = null; // the open multi-line string delimiter, if any
|
|
914
|
+
let depth = 0; // open [ / { across lines (multi-line arrays and inline tables)
|
|
915
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
916
|
+
const line = raw.trim();
|
|
917
|
+
if (multi) { if (line.includes(multi)) multi = null; continue; }
|
|
918
|
+
if (!line || line.startsWith("#")) continue;
|
|
919
|
+
if (depth > 0) {
|
|
920
|
+
for (const ch of line) { if (ch === "[" || ch === "{") depth++; else if (ch === "]" || ch === "}") depth--; if (depth < 0) return false; }
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (/^\[\[?[^\]]+\]\]?\s*(#.*)?$/.test(line)) continue;
|
|
924
|
+
const m = /^[A-Za-z0-9_\-."']+\s*=\s*(.+)$/.exec(line);
|
|
925
|
+
if (!m) return false;
|
|
926
|
+
const v = m[1].trim();
|
|
927
|
+
if (v.startsWith('"""') || v.startsWith("'''")) {
|
|
928
|
+
const q = v.slice(0, 3);
|
|
929
|
+
if (!(v.length > 3 && v.endsWith(q))) multi = q;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (((v.match(/(?<!\\)"/g) || []).length) % 2 !== 0) return false;
|
|
933
|
+
for (const ch of v) { if (ch === "[" || ch === "{") depth++; else if (ch === "]" || ch === "}") depth--; if (depth < 0) return false; }
|
|
934
|
+
}
|
|
935
|
+
return !multi && depth === 0;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* HOSTING MODE — may this Bridge serve a visiting agent's calls?
|
|
940
|
+
*
|
|
941
|
+
* "always" — `hosting.enabled: true` (`cookbook-bridge host`): serve any grant.
|
|
942
|
+
* "off" — `hosting.enabled: false` (`cookbook-bridge host --off`): refuse all.
|
|
943
|
+
* "grants" — nothing configured: serve the grants this member approved themselves.
|
|
944
|
+
*
|
|
945
|
+
* The third is the default ON PURPOSE (2026-08-25). A grant only exists because the
|
|
946
|
+
* host clicked Allow, in their own account, and it binds to this Bridge's own
|
|
947
|
+
* credential — that click IS the consent. Making it also require a separate "hosting"
|
|
948
|
+
* switch produced the failure Diego hit: Allow in a browser, a Bridge with the switch
|
|
949
|
+
* off, and every call sat queued until the grant expired. The Bridge's LOCAL_CEILING
|
|
950
|
+
* still holds every write at "ask", whatever the mode.
|
|
951
|
+
*/
|
|
952
|
+
export function hostingMode(cfg) {
|
|
953
|
+
const v = cfg?.hosting?.enabled;
|
|
954
|
+
if (v === true) return "always";
|
|
955
|
+
if (v === false) return "off";
|
|
956
|
+
return "grants";
|
|
957
|
+
}
|
package/harden.mjs
CHANGED
|
@@ -115,3 +115,42 @@ export async function checkAgyVersion(argv, timeoutMs = 10_000) {
|
|
|
115
115
|
const version = await probeVersion(argv, timeoutMs);
|
|
116
116
|
return { version, tooOld: version ? versionLt(version, AGY_MIN_VERSION) : false };
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
// ── identity: a run acts as the Bridge's member, not as whoever the CLI is ──────
|
|
120
|
+
//
|
|
121
|
+
// Verified failure (2026-08-25): a Bridge-run `claude -p` refused every Cookbook
|
|
122
|
+
// tool because a stale claude.ai-synced connector poisoned the headless session,
|
|
123
|
+
// and when tools did work they acted as the CLI's own login — a different account
|
|
124
|
+
// that couldn't see the workspace, so complete_task returned "Not found". Both are
|
|
125
|
+
// the same mistake: letting the run inherit the CLI's global MCP state.
|
|
126
|
+
//
|
|
127
|
+
// `--strict-mcp-config --mcp-config <json>` makes the run see ONLY Cookbook, as the
|
|
128
|
+
// member whose token this is, attributed under that token's name.
|
|
129
|
+
|
|
130
|
+
/** True when this command runs the claude CLI (any path, any wrapper flags). */
|
|
131
|
+
export function isClaudeCommand(command) {
|
|
132
|
+
const base = String(command?.[0] ?? "").split(/[\\/]/).pop().toLowerCase();
|
|
133
|
+
return base === "claude";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Rewrite a claude command so the run carries its own Cookbook connection.
|
|
138
|
+
* Pure. No-op (and says so) when it isn't claude, has no token, or the command
|
|
139
|
+
* already pins an MCP config by hand.
|
|
140
|
+
*/
|
|
141
|
+
export function withCookbookMcp(command, { token, cookbookUrl } = {}) {
|
|
142
|
+
if (!Array.isArray(command) || !isClaudeCommand(command)) return { command, injected: false, reason: "not claude" };
|
|
143
|
+
if (!token) return { command, injected: false, reason: "no token" };
|
|
144
|
+
if (!cookbookUrl) return { command, injected: false, reason: "no cookbookUrl" };
|
|
145
|
+
if (command.includes("--mcp-config") || command.includes("--strict-mcp-config")) return { command, injected: false, reason: "already pinned" };
|
|
146
|
+
const cfg = JSON.stringify({
|
|
147
|
+
mcpServers: {
|
|
148
|
+
cookbook: {
|
|
149
|
+
type: "http",
|
|
150
|
+
url: `${String(cookbookUrl).replace(/\/$/, "")}/api/mcp`,
|
|
151
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
return { command: [command[0], "--strict-mcp-config", "--mcp-config", cfg, ...command.slice(1)], injected: true, reason: null };
|
|
156
|
+
}
|
package/local.mjs
CHANGED
|
@@ -238,7 +238,7 @@ export function createLocalServer(deps) {
|
|
|
238
238
|
localWorkspaces: localWorkspacesView(),
|
|
239
239
|
// HARDWARE GRANTS (0069): is this machine currently willing to host a visiting
|
|
240
240
|
// agent, and what is live right now? The desktop app renders this as the door.
|
|
241
|
-
hosting: { enabled: !!cfg.hosting?.enabled, activeGrants: deps.activeGrants ? deps.activeGrants() : [] },
|
|
241
|
+
hosting: { enabled: !!cfg.hosting?.enabled, mode: cfg.hosting?.enabled === true ? "always" : cfg.hosting?.enabled === false ? "off" : "grants", activeGrants: deps.activeGrants ? deps.activeGrants() : [] },
|
|
242
242
|
hotWorkspaceIds: deps.hotWorkspaceIds ? [...deps.hotWorkspaceIds()] : [],
|
|
243
243
|
lastError: deps.lastError ? deps.lastError() : null,
|
|
244
244
|
connect: { state: connect.state },
|
|
@@ -352,7 +352,10 @@ export function createLocalServer(deps) {
|
|
|
352
352
|
// picks it up immediately (no restart, so a host can close the door NOW).
|
|
353
353
|
if (route === "POST /hosting") {
|
|
354
354
|
const body = (await readBody(req)) ?? {};
|
|
355
|
-
|
|
355
|
+
// A security toggle whose default is OFF must not fail open: an empty or
|
|
356
|
+
// malformed body used to mean "turn it on" (ultrareview #123, bug_011).
|
|
357
|
+
if (typeof body.enabled !== "boolean") return json(res, 400, { ok: false, error: "`enabled` must be true or false" });
|
|
358
|
+
const enabled = body.enabled;
|
|
356
359
|
cfg.hosting = { ...(cfg.hosting ?? {}), enabled };
|
|
357
360
|
try {
|
|
358
361
|
saveConfigPatch((raw) => { raw.hosting = { ...(raw.hosting ?? {}), enabled }; });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cookbook-bridge",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|