cookbook-bridge 0.1.12 → 0.1.13
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 +5 -0
- package/bridge.mjs +82 -25
- package/chef-persona.md +2 -0
- package/hands.mjs +255 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Changelog
|
|
4
4
|
|
|
5
|
+
**0.1.13** (2026-09-02)
|
|
6
|
+
- Pre-flight: the moment a grant this Bridge hosts becomes active, it runs `env`, the doctor, `cli_versions` and the shape of its own config (tokens stripped) once, locally and read-only, and posts the result as a host-initiated `preflight` call, so the visiting agent starts with what the machine already knows. Remembered per grant in `bridge.state.json`; a failed post is retried once per process.
|
|
7
|
+
- One-click plans: a `plan` call carries `why` and an ordered list of steps. The Bridge recomputes the plan hash before running (a step added after the click is refused as `plan hash mismatch`), runs each step through the same authorization wall as a single call, with the plan's approval standing in for each write-class step's click, stops at the first failure, and reports every step with its duration. Plans cannot nest, cannot contain `preflight`, and run one at a time like every other call.
|
|
8
|
+
- Every result leaving the machine is capped at 64 KB, the same cap the server applies.
|
|
9
|
+
|
|
5
10
|
**0.1.12** (2026-09-02)
|
|
6
11
|
- Kimi Code is the fourth agent: `connect` finds `kimi`, mints a "Kimi" token and writes `~/.kimi-code/mcp.json` (owner-only, other servers kept). Runs use `kimi -p ... --output-format stream-json`; the board gets live text and the work log, resume by session id, and a duration-only receipt (kimi reports no token counts).
|
|
7
12
|
- A headless kimi run approves every tool and has no `--allowedTools` flag, so the Bridge turns the config's `--allowedTools` into a per-run agent file (`--agent-file`, a 0600 temp file) whose tools allowlist is exactly that list. `doctor` fails a Kimi agent that has no `--allowedTools`.
|
package/bridge.mjs
CHANGED
|
@@ -68,14 +68,14 @@ let hasCodexThread, reapCodexServer, killCodexServer;
|
|
|
68
68
|
let checkForUpdate, applyUpdate;
|
|
69
69
|
let createLocalServer, toolsForMode, modeForTools, vendorOf;
|
|
70
70
|
let connectAgentsProgrammatic, detectClis;
|
|
71
|
-
let serveCalls, describeCall, hostingMode, whichExec, argvForSpawn, redactText, resolveCmdShim, killTree;
|
|
71
|
+
let serveCalls, describeCall, hostingMode, whichExec, argvForSpawn, redactText, resolveCmdShim, killTree, runPreflight, grantsNeedingPreflight;
|
|
72
72
|
let fetchHands, claimHandsCall, reportHandsResult;
|
|
73
73
|
|
|
74
74
|
async function loadRuntime() {
|
|
75
75
|
({ createLocalServer, toolsForMode, modeForTools, vendorOf } = await import("./local.mjs"));
|
|
76
76
|
({ connectAgentsProgrammatic, detectClis } = await import("./device.mjs"));
|
|
77
77
|
({ listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, fetchHands, claimHandsCall, reportHandsResult, agentsQuery } = await import("./cookbook.mjs"));
|
|
78
|
-
({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn, redact: redactText, resolveCmdShim, killTree } = await import("./hands.mjs"));
|
|
78
|
+
({ serveCalls, describeCall, hostingMode, which: whichExec, argvForSpawn, redact: redactText, resolveCmdShim, killTree, runPreflight, grantsNeedingPreflight } = await import("./hands.mjs"));
|
|
79
79
|
({ agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig,
|
|
80
80
|
isKimiCommand, kimiFromLine, kimiResultEnvelope, kimiCommand, kimiLoginState, kimiMcpState, checkKimiVersion } = await import("./harden.mjs"));
|
|
81
81
|
({ extractUsage, displayText } = await import("./usage.mjs"));
|
|
@@ -815,6 +815,7 @@ function loadRunState() {
|
|
|
815
815
|
for (const [id, n] of Object.entries(raw.attempts ?? {})) attempts.set(id, Number(n) || 0);
|
|
816
816
|
for (const id of raw.givenUp ?? []) givenUp.add(id);
|
|
817
817
|
for (const [id, ctx] of Object.entries(raw.retryCtx ?? {})) retryCtx.set(id, ctx);
|
|
818
|
+
for (const id of raw.preflighted ?? []) if (typeof id === "string") preflighted.add(id);
|
|
818
819
|
} catch { /* first run / unreadable — start clean */ }
|
|
819
820
|
}
|
|
820
821
|
function saveRunState() {
|
|
@@ -823,11 +824,15 @@ function saveRunState() {
|
|
|
823
824
|
const attEntries = [...attempts.entries()].slice(-500);
|
|
824
825
|
const given = [...givenUp].slice(-500);
|
|
825
826
|
const retries = [...retryCtx.entries()].slice(-200);
|
|
826
|
-
|
|
827
|
+
const flown = [...preflighted].slice(-500);
|
|
828
|
+
fs.writeFileSync(statePath(), JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries), preflighted: flown }));
|
|
827
829
|
} catch { /* best-effort — never let state persistence break a run */ }
|
|
828
830
|
}
|
|
829
831
|
|
|
830
832
|
const attempts = new Map(); // taskId -> count
|
|
833
|
+
// Grants this host has already pre-flighted (hands section). Persisted: a restart
|
|
834
|
+
// must not post a second pre-flight for a grant the visitor already read.
|
|
835
|
+
const preflighted = new Set();
|
|
831
836
|
// Retry context (Phase 1): what the LAST failed attempt knew — the claude session
|
|
832
837
|
// to resume and the failure to feed back — so a retry continues instead of redoing.
|
|
833
838
|
const retryCtx = new Map(); // taskId -> { sessionId, reason }
|
|
@@ -1607,30 +1612,36 @@ function noteAwaiting(awaiting) {
|
|
|
1607
1612
|
if (announcedAwaiting.size > 500) announcedAwaiting.clear();
|
|
1608
1613
|
}
|
|
1609
1614
|
|
|
1615
|
+
/** The host context every hands path shares: serveCalls (visitor calls, plans) and
|
|
1616
|
+
* runPreflight (the host's own first call on a new grant). */
|
|
1617
|
+
function handsContext(cfg) {
|
|
1618
|
+
return {
|
|
1619
|
+
cfg,
|
|
1620
|
+
cfgPath: CONFIG_PATH,
|
|
1621
|
+
home: os.homedir(),
|
|
1622
|
+
// The host's OWN folder list. A granted folder is honoured only if it is here
|
|
1623
|
+
// (or inside one), so the server can never hand a visitor a directory.
|
|
1624
|
+
hostFolders: cfg.hosting?.folders ?? [],
|
|
1625
|
+
doctor: () => doctorReport(["--config", CONFIG_PATH]),
|
|
1626
|
+
claim: (id) => claimHandsCall(cfg, id),
|
|
1627
|
+
report: (id, r) => reportHandsResult(cfg, id, r),
|
|
1628
|
+
// Repair templates reach the same machinery the desktop app's buttons use, so
|
|
1629
|
+
// a fix an agent performs is exactly the fix the host could have clicked.
|
|
1630
|
+
restart: () => reexecSelf(),
|
|
1631
|
+
startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
|
|
1632
|
+
applyConfig: () => applyConfigFromDisk(cfg),
|
|
1633
|
+
log,
|
|
1634
|
+
stopped: () => stopped,
|
|
1635
|
+
visitorLabel: (c) => c.visitor ?? "a visiting agent",
|
|
1636
|
+
onCall: emitHands,
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1610
1640
|
async function serveHands(cfg, calls) {
|
|
1611
1641
|
if (hostingMode(cfg) === "off" || handsBusy || !calls || calls.length === 0) return;
|
|
1612
1642
|
handsBusy = true;
|
|
1613
1643
|
try {
|
|
1614
|
-
await serveCalls(calls,
|
|
1615
|
-
cfg,
|
|
1616
|
-
cfgPath: CONFIG_PATH,
|
|
1617
|
-
home: os.homedir(),
|
|
1618
|
-
// The host's OWN folder list. A granted folder is honoured only if it is here
|
|
1619
|
-
// (or inside one), so the server can never hand a visitor a directory.
|
|
1620
|
-
hostFolders: cfg.hosting?.folders ?? [],
|
|
1621
|
-
doctor: () => doctorReport(["--config", CONFIG_PATH]),
|
|
1622
|
-
claim: (id) => claimHandsCall(cfg, id),
|
|
1623
|
-
report: (id, r) => reportHandsResult(cfg, id, r),
|
|
1624
|
-
// Repair templates reach the same machinery the desktop app's buttons use, so
|
|
1625
|
-
// a fix an agent performs is exactly the fix the host could have clicked.
|
|
1626
|
-
restart: () => reexecSelf(),
|
|
1627
|
-
startConnect: () => connectAgentsProgrammatic({ cfgPath: CONFIG_PATH, baseUrl: cfg.cookbookUrl }),
|
|
1628
|
-
applyConfig: () => applyConfigFromDisk(cfg),
|
|
1629
|
-
log,
|
|
1630
|
-
stopped: () => stopped,
|
|
1631
|
-
visitorLabel: (c) => c.visitor ?? "a visiting agent",
|
|
1632
|
-
onCall: emitHands,
|
|
1633
|
-
});
|
|
1644
|
+
await serveCalls(calls, handsContext(cfg));
|
|
1634
1645
|
} catch (e) {
|
|
1635
1646
|
log(`! hands error: ${e.message}`);
|
|
1636
1647
|
} finally {
|
|
@@ -1638,6 +1649,51 @@ async function serveHands(cfg, calls) {
|
|
|
1638
1649
|
}
|
|
1639
1650
|
}
|
|
1640
1651
|
|
|
1652
|
+
/** Open a HOST-initiated call row on a grant (the pre-flight). Same bearer, same
|
|
1653
|
+
* route the claim uses; the server answers { call_id }. */
|
|
1654
|
+
async function createHostCall(cfg, grantId, verb) {
|
|
1655
|
+
const res = await fetch(`${cfg.cookbookUrl}/api/bridge/hands`, {
|
|
1656
|
+
method: "POST",
|
|
1657
|
+
headers: { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json" },
|
|
1658
|
+
body: JSON.stringify({ grant_id: grantId, verb, host_initiated: true }),
|
|
1659
|
+
});
|
|
1660
|
+
if (!res.ok) throw new Error(`hands ${res.status}`);
|
|
1661
|
+
const j = await res.json().catch(() => ({}));
|
|
1662
|
+
return typeof j.call_id === "string" ? j.call_id : typeof j.call?.id === "string" ? j.call.id : null;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ── PRE-FLIGHT: the first thing a new grant gets is what the machine already knows.
|
|
1666
|
+
// When a grant this Bridge hosts becomes active, the host runs env + doctor +
|
|
1667
|
+
// cli_versions + its own config's shape (tokens stripped) once, locally, read-only,
|
|
1668
|
+
// and posts it as a host-initiated `preflight` call. The visitor reads it from
|
|
1669
|
+
// grant_get before asking anything the machine already answered. Persisted per
|
|
1670
|
+
// grant (bridge.state.json); at most two tries per grant per process.
|
|
1671
|
+
const preflightTries = new Map(); // grantId -> attempts this process
|
|
1672
|
+
async function preflightNewGrants(cfg) {
|
|
1673
|
+
if (hostingMode(cfg) === "off" || handsBusy || !runPreflight) return;
|
|
1674
|
+
const fresh = grantsNeedingPreflight(hands.activeGrants, { preflighted, tried: preflightTries });
|
|
1675
|
+
if (fresh.length === 0) return;
|
|
1676
|
+
handsBusy = true; // never alongside a visitor's call
|
|
1677
|
+
try {
|
|
1678
|
+
for (const grantId of fresh) {
|
|
1679
|
+
if (stopped) break;
|
|
1680
|
+
const n = (preflightTries.get(grantId) ?? 0) + 1;
|
|
1681
|
+
preflightTries.set(grantId, n);
|
|
1682
|
+
log(`◇ pre-flight for grant ${grantId.slice(0, 8)}: env, doctor, CLI versions, config shape`);
|
|
1683
|
+
try {
|
|
1684
|
+
const { callId } = await runPreflight(grantId, { ...handsContext(cfg), create: (gid) => createHostCall(cfg, gid, "preflight") });
|
|
1685
|
+
preflighted.add(grantId);
|
|
1686
|
+
saveRunState();
|
|
1687
|
+
log(` ↳ pre-flight posted (call ${String(callId).slice(0, 8)})`);
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
log(`! pre-flight for grant ${grantId.slice(0, 8)} failed: ${e.message}${n >= 2 ? " (not retrying until the Bridge restarts)" : " (will retry once)"}`);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
} finally {
|
|
1693
|
+
handsBusy = false;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1641
1697
|
/** Poll for granted calls (the net under the push channel, and the whole story on a
|
|
1642
1698
|
* server or network without SSE). No-ops entirely when not hosting. */
|
|
1643
1699
|
async function pollHands(cfg) {
|
|
@@ -1651,6 +1707,7 @@ async function pollHands(cfg) {
|
|
|
1651
1707
|
}
|
|
1652
1708
|
hands.activeGrants = r.grants ?? [];
|
|
1653
1709
|
noteAwaiting(r.awaiting ?? []);
|
|
1710
|
+
await preflightNewGrants(cfg);
|
|
1654
1711
|
await serveHands(cfg, r.calls);
|
|
1655
1712
|
} catch (e) {
|
|
1656
1713
|
if (!/40[13]/.test(e.message)) log(`! couldn't check for granted work: ${e.message}`);
|
|
@@ -1688,7 +1745,7 @@ async function pullOnce(cfg, { boot = false } = {}) {
|
|
|
1688
1745
|
if (j.hands && typeof j.hands === "object") {
|
|
1689
1746
|
hands.activeGrants = j.hands.grants ?? hands.activeGrants;
|
|
1690
1747
|
noteAwaiting(j.hands.awaiting ?? []);
|
|
1691
|
-
if (hostingMode(cfg) !== "off") void serveHands(cfg, j.hands.calls ?? []);
|
|
1748
|
+
if (hostingMode(cfg) !== "off") void preflightNewGrants(cfg).then(() => serveHands(cfg, j.hands.calls ?? []));
|
|
1692
1749
|
}
|
|
1693
1750
|
// 0092: synthesis (summary, caption, vision, answer) thinks HERE, on this
|
|
1694
1751
|
// member's subscription. Each job carries kind, model and image straight
|
|
@@ -1815,7 +1872,7 @@ async function socketLoop(cfg) {
|
|
|
1815
1872
|
const j = JSON.parse(ev.data);
|
|
1816
1873
|
hands.activeGrants = j.grants ?? hands.activeGrants;
|
|
1817
1874
|
noteAwaiting(j.awaiting ?? []);
|
|
1818
|
-
void serveHands(cfg, j.calls ?? []);
|
|
1875
|
+
void preflightNewGrants(cfg).then(() => serveHands(cfg, j.calls ?? []));
|
|
1819
1876
|
} catch { /* malformed frame — the poll covers it */ }
|
|
1820
1877
|
}
|
|
1821
1878
|
}
|
package/chef-persona.md
CHANGED
|
@@ -7,3 +7,5 @@ What you know: Cookbook is the shared brain a team's AI agents plug into. Every
|
|
|
7
7
|
How you work: the message you receive carries the rules for this conversation (a grant id, whether you may ask for machine access, and Cookbook Help notes that match the question). Follow those rules exactly; they override anything here. Use the Cookbook tools only. When the answer depends on the person's machine, ask for access the way the rules describe. Never send them to a terminal.
|
|
8
8
|
|
|
9
9
|
You are running on this person's own machine and subscription as their agent. You can see their workspaces. When you need to look at or change their setup, ask for a hands grant in the usual way; every action still waits for their click.
|
|
10
|
+
|
|
11
|
+
When a fix needs more than one change, do not ask for each one. Submit ONE hands_plan with a plain `why` and the steps in order, then wait for the person's single approval; the Bridge runs the steps one at a time and stops at the first failure. Before you ask about the machine, read the pre-flight the host posted on the grant (grant_get): what is installed, the doctor rows, CLI versions and the shape of the Bridge config are already there. Never ask for something the machine has already answered.
|
package/hands.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import os from "node:os";
|
|
|
31
31
|
import { kimiLoginState } from "./harden.mjs";
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import { spawn, spawnSync } from "node:child_process";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
34
35
|
|
|
35
36
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
37
|
// REDACTION
|
|
@@ -314,6 +315,17 @@ const PROJECTIONS = Object.freeze({
|
|
|
314
315
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
315
316
|
const MAX_OUTPUT_CHARS = 40_000;
|
|
316
317
|
const MAX_DIR_ENTRIES = 400;
|
|
318
|
+
/** The most a single call result may weigh when it leaves this machine. The server
|
|
319
|
+
* applies the same cap (src/lib/workspaces/grants.ts MAX_OUTPUT_BYTES); doing it
|
|
320
|
+
* here too means a plan of twenty steps or a pre-flight cannot outgrow a receipt. */
|
|
321
|
+
export const MAX_UPLOAD_BYTES = 64 * 1024;
|
|
322
|
+
/** Trim an output to MAX_UPLOAD_BYTES the same way the server does. Pure. */
|
|
323
|
+
export function capOutput(output) {
|
|
324
|
+
if (output === null || output === undefined) return output;
|
|
325
|
+
const json = JSON.stringify(output);
|
|
326
|
+
if (typeof json !== "string" || json.length <= MAX_UPLOAD_BYTES) return output;
|
|
327
|
+
return { truncated: true, bytes: json.length, preview: json.slice(0, MAX_UPLOAD_BYTES) };
|
|
328
|
+
}
|
|
317
329
|
|
|
318
330
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
319
331
|
// THE LOCAL CEILING — what this machine will EVER do, regardless of what it is told
|
|
@@ -329,7 +341,10 @@ const MAX_DIR_ENTRIES = 400;
|
|
|
329
341
|
* hand a visitor a directory — only the host's OWN config can (cfg.hosting.folders).
|
|
330
342
|
*/
|
|
331
343
|
export const LOCAL_CEILING = Object.freeze({
|
|
332
|
-
|
|
344
|
+
// `plan` is a container, not a capability: its steps are each checked on their own
|
|
345
|
+
// (executePlan), so listing it here widens nothing. `preflight` is deliberately
|
|
346
|
+
// absent: the host runs it on its own initiative and never accepts it as a call.
|
|
347
|
+
verbs: Object.freeze(["doctor", "env", "read_file", "list_dir", "run", "write_file", "restore_backup", "open_url", "plan"]),
|
|
333
348
|
run_allow: Object.freeze([
|
|
334
349
|
// read
|
|
335
350
|
"node_version", "claude_mcp_list", "cli_versions", "tail_log",
|
|
@@ -401,6 +416,11 @@ export const VERB_RISK = Object.freeze({
|
|
|
401
416
|
write_file: "write",
|
|
402
417
|
restore_backup: "write",
|
|
403
418
|
open_url: "login",
|
|
419
|
+
// A plan always waits for the host's single click, whatever its steps: "write"
|
|
420
|
+
// is the label that makes the generic wall say so. Pre-flight is read-only by
|
|
421
|
+
// construction (env, doctor, CLI versions, the config's shape).
|
|
422
|
+
plan: "write",
|
|
423
|
+
preflight: "read",
|
|
404
424
|
});
|
|
405
425
|
|
|
406
426
|
export const RUN_TEMPLATE_RISK = Object.freeze({
|
|
@@ -954,9 +974,27 @@ const VERBS = {
|
|
|
954
974
|
stderr: String(res.stderr).slice(0, 4000),
|
|
955
975
|
};
|
|
956
976
|
},
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* PRE-FLIGHT: everything a visiting agent would otherwise spend its first four
|
|
980
|
+
* calls asking. Read-only by construction. The host runs it on its own initiative
|
|
981
|
+
* the moment a grant becomes active (runPreflight) and posts it as a
|
|
982
|
+
* host-initiated call; a queued `preflight` from the server is refused by the wall.
|
|
983
|
+
*/
|
|
984
|
+
async preflight(_args, ctx) {
|
|
985
|
+
return collectPreflight(ctx);
|
|
986
|
+
},
|
|
987
|
+
|
|
988
|
+
/** A plan is executed by executePlan, never as a bare verb: executeCall routes
|
|
989
|
+
* it there before this is reached. Kept in the table so the wall knows the name. */
|
|
990
|
+
async plan() {
|
|
991
|
+
return { error: "A plan runs through executePlan, one step at a time." };
|
|
992
|
+
},
|
|
957
993
|
};
|
|
958
994
|
|
|
959
995
|
export const VERB_NAMES = Object.freeze(Object.keys(VERBS));
|
|
996
|
+
/** Verbs that contain or replace other calls. Never allowed inside a plan. */
|
|
997
|
+
export const META_VERBS = Object.freeze(["plan", "preflight"]);
|
|
960
998
|
|
|
961
999
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
962
1000
|
// EXECUTION
|
|
@@ -967,11 +1005,21 @@ export const VERB_NAMES = Object.freeze(Object.keys(VERBS));
|
|
|
967
1005
|
* whole authorization decision is unit-testable without a machine to break.
|
|
968
1006
|
* Returns { ok: true, risk } or { ok: false, error }.
|
|
969
1007
|
*/
|
|
970
|
-
export function authorizeCall(call, scope) {
|
|
1008
|
+
export function authorizeCall(call, scope, opts = {}) {
|
|
971
1009
|
if (!call || typeof call.verb !== "string") return { ok: false, error: "Malformed call." };
|
|
972
1010
|
if (!scope || typeof scope !== "object") return { ok: false, error: "No grant scope." };
|
|
973
1011
|
const verb = call.verb;
|
|
974
1012
|
if (!VERBS[verb]) return { ok: false, error: `This Bridge has no verb '${verb}'.` };
|
|
1013
|
+
// The host runs its own pre-flight; it is never something a visitor queues.
|
|
1014
|
+
if (verb === "preflight") return { ok: false, error: "Pre-flight runs on the host's own initiative, never as a queued call." };
|
|
1015
|
+
// A PLAN is a container. Its permission is exactly the permission of its steps,
|
|
1016
|
+
// each of which is checked on its own inside executePlan, so the plan row itself
|
|
1017
|
+
// needs only the one thing a container can carry: the host's click.
|
|
1018
|
+
if (verb === "plan") {
|
|
1019
|
+
if (opts.approvedByPlan) return { ok: false, error: "A plan can't contain another plan." };
|
|
1020
|
+
if (call.status !== "approved") return { ok: false, error: "A plan needs the host's approval before it can run here." };
|
|
1021
|
+
return { ok: true, risk: VERB_RISK.plan };
|
|
1022
|
+
}
|
|
975
1023
|
if (!Array.isArray(scope.verbs) || !scope.verbs.includes(verb)) {
|
|
976
1024
|
return { ok: false, error: `The grant doesn't allow '${verb}'.` };
|
|
977
1025
|
}
|
|
@@ -991,8 +1039,10 @@ export function authorizeCall(call, scope) {
|
|
|
991
1039
|
}
|
|
992
1040
|
// THE CONSENT WALL: when the grant says a class needs a human, this machine runs
|
|
993
1041
|
// it only if the host has already flipped the row to `approved`. A UI bug, or a
|
|
994
|
-
// server that sends it as `queued`, is refused here.
|
|
995
|
-
|
|
1042
|
+
// server that sends it as `queued`, is refused here. Inside a plan the host's one
|
|
1043
|
+
// click on the plan row is the click for every step (`approvedByPlan`), and
|
|
1044
|
+
// executePlan sets that flag only when the plan row itself said `approved`.
|
|
1045
|
+
if (policy === "ask" && call.status !== "approved" && opts.approvedByPlan !== true) {
|
|
996
1046
|
return { ok: false, error: `That needs the host's approval before it can run here.` };
|
|
997
1047
|
}
|
|
998
1048
|
return { ok: true, risk };
|
|
@@ -1003,12 +1053,13 @@ export function authorizeCall(call, scope) {
|
|
|
1003
1053
|
* ALWAYS resolves — a thrown verb becomes a failed call, never a crashed Bridge.
|
|
1004
1054
|
*/
|
|
1005
1055
|
export async function executeCall(call, ctx) {
|
|
1056
|
+
if (call?.verb === "plan") return executePlan(call, ctx);
|
|
1006
1057
|
const home = ctx.home ?? os.homedir();
|
|
1007
1058
|
// NEVER the server's scope as sent — always its intersection with what this
|
|
1008
1059
|
// machine will do at all. This is the line that makes "a lying server cannot make
|
|
1009
1060
|
// your laptop run something you didn't grant" a true statement instead of a hope.
|
|
1010
1061
|
const scope = effectiveScope(call.scope ?? ctx.scope, { hostFolders: ctx.hostFolders ?? [], home });
|
|
1011
|
-
const auth = authorizeCall(call, scope);
|
|
1062
|
+
const auth = authorizeCall(call, scope, { approvedByPlan: ctx.approvedByPlan === true });
|
|
1012
1063
|
if (!auth.ok) return { status: "denied", output: null, error: auth.error };
|
|
1013
1064
|
|
|
1014
1065
|
const args = call.args && typeof call.args === "object" ? call.args : {};
|
|
@@ -1020,12 +1071,204 @@ export async function executeCall(call, ctx) {
|
|
|
1020
1071
|
// the visitor should see it as such and try something else.
|
|
1021
1072
|
return { status: "failed", output: null, error: clean.error };
|
|
1022
1073
|
}
|
|
1023
|
-
return { status: "done", output: clean, error: null };
|
|
1074
|
+
return { status: "done", output: capOutput(clean), error: null };
|
|
1024
1075
|
} catch (e) {
|
|
1025
1076
|
return { status: "failed", output: null, error: redact(String(e?.message ?? e), { home }).slice(0, 500) };
|
|
1026
1077
|
}
|
|
1027
1078
|
}
|
|
1028
1079
|
|
|
1080
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1081
|
+
// PLANS — one click, several steps, the same wall for each
|
|
1082
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1083
|
+
|
|
1084
|
+
/** The most steps one click may cover. A fix needs three or four; twenty is a script. */
|
|
1085
|
+
export const PLAN_MAX_STEPS = 20;
|
|
1086
|
+
|
|
1087
|
+
/** The hash the server stamps on a plan row: sha256 of the steps EXACTLY as sent.
|
|
1088
|
+
* Recomputed here before a plan runs, so nothing can be appended after the click. Pure. */
|
|
1089
|
+
export function planHash(steps) {
|
|
1090
|
+
return createHash("sha256").update(JSON.stringify(steps)).digest("hex");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Is this plan the plan the host clicked? Structure, size, no nested containers, and
|
|
1095
|
+
* the hash. Returns { ok: true, steps, why } or { ok: false, error }. Pure.
|
|
1096
|
+
*/
|
|
1097
|
+
export function validatePlan(call) {
|
|
1098
|
+
const args = call?.args && typeof call.args === "object" ? call.args : {};
|
|
1099
|
+
const steps = args.steps;
|
|
1100
|
+
if (!Array.isArray(steps) || steps.length === 0) return { ok: false, error: "A plan needs at least one step." };
|
|
1101
|
+
if (steps.length > PLAN_MAX_STEPS) return { ok: false, error: `A plan may have at most ${PLAN_MAX_STEPS} steps; this one has ${steps.length}.` };
|
|
1102
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1103
|
+
const step = steps[i];
|
|
1104
|
+
if (!step || typeof step !== "object" || typeof step.verb !== "string") return { ok: false, error: `Step ${i + 1} is malformed (needs a verb).` };
|
|
1105
|
+
if (META_VERBS.includes(step.verb)) return { ok: false, error: `Step ${i + 1} is '${step.verb}', which can't be inside a plan.` };
|
|
1106
|
+
if (step.args !== undefined && (step.args === null || typeof step.args !== "object" || Array.isArray(step.args))) {
|
|
1107
|
+
return { ok: false, error: `Step ${i + 1} has malformed args.` };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// THE HASH: the server cannot append a step after the host clicked, because the
|
|
1111
|
+
// click was on this exact list. A missing hash is a mismatch too.
|
|
1112
|
+
if (typeof call.plan_hash !== "string" || planHash(steps) !== call.plan_hash) return { ok: false, error: "plan hash mismatch" };
|
|
1113
|
+
return { ok: true, steps, why: typeof args.why === "string" ? args.why : "" };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The word for a step in the log: the template for a run, the verb otherwise. */
|
|
1117
|
+
function stepLabel(step) {
|
|
1118
|
+
if (step?.verb === "run") return String(step.args?.template ?? "run");
|
|
1119
|
+
return String(step?.verb ?? "?");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Run an approved plan step by step, through the SAME authorizeCall + executeCall
|
|
1124
|
+
* path a single call takes. The one difference is `approvedByPlan`: the host's click
|
|
1125
|
+
* on the plan row is the click for every write-class step inside it, and it is set
|
|
1126
|
+
* only when the plan row itself is `approved`. Every step is still measured against
|
|
1127
|
+
* effectiveScope and LOCAL_CEILING on its own, so a step the ceiling forbids fails
|
|
1128
|
+
* the plan at that index. Stops at the first failure. ALWAYS resolves.
|
|
1129
|
+
* `ctx.executeStep` (tests) replaces executeCall for the steps.
|
|
1130
|
+
*/
|
|
1131
|
+
export async function executePlan(call, ctx) {
|
|
1132
|
+
const home = ctx.home ?? os.homedir();
|
|
1133
|
+
const scope = effectiveScope(call.scope ?? ctx.scope, { hostFolders: ctx.hostFolders ?? [], home });
|
|
1134
|
+
const auth = authorizeCall(call, scope, { approvedByPlan: ctx.approvedByPlan === true });
|
|
1135
|
+
if (!auth.ok) return { status: "denied", output: null, error: auth.error };
|
|
1136
|
+
const v = validatePlan(call);
|
|
1137
|
+
if (!v.ok) return { status: "denied", output: null, error: v.error };
|
|
1138
|
+
|
|
1139
|
+
const runStep = typeof ctx.executeStep === "function" ? ctx.executeStep : executeCall;
|
|
1140
|
+
const total = v.steps.length;
|
|
1141
|
+
const steps = [];
|
|
1142
|
+
let stoppedAt;
|
|
1143
|
+
for (let i = 0; i < total; i++) {
|
|
1144
|
+
if (ctx.stopped?.()) { stoppedAt = i; steps.push({ verb: v.steps[i].verb, ok: false, error: "The Bridge is stopping.", duration_ms: 0 }); break; }
|
|
1145
|
+
const step = v.steps[i];
|
|
1146
|
+
// A step never inherits the row's status: the plan's click reaches it ONLY as
|
|
1147
|
+
// approvedByPlan, which is what the wall is written to look at.
|
|
1148
|
+
const stepCall = {
|
|
1149
|
+
id: `${call.id ?? "plan"}#${i + 1}`,
|
|
1150
|
+
grant_id: call.grant_id,
|
|
1151
|
+
workspace_id: call.workspace_id,
|
|
1152
|
+
visitor: call.visitor,
|
|
1153
|
+
verb: step.verb,
|
|
1154
|
+
args: step.args && typeof step.args === "object" ? step.args : {},
|
|
1155
|
+
status: "queued",
|
|
1156
|
+
scope: call.scope ?? ctx.scope,
|
|
1157
|
+
};
|
|
1158
|
+
const t0 = Date.now();
|
|
1159
|
+
let r;
|
|
1160
|
+
try {
|
|
1161
|
+
r = await runStep(stepCall, { ...ctx, approvedByPlan: call.status === "approved" });
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
r = { status: "failed", output: null, error: String(e?.message ?? e).slice(0, 500) };
|
|
1164
|
+
}
|
|
1165
|
+
const duration_ms = Date.now() - t0;
|
|
1166
|
+
const ok = r?.status === "done";
|
|
1167
|
+
const row = { verb: step.verb, ...(step.verb === "run" ? { template: stepLabel(step) } : {}), ok, duration_ms };
|
|
1168
|
+
if (ok) row.output = r.output ?? null;
|
|
1169
|
+
else row.error = String(r?.error ?? `step ${r?.status ?? "failed"}`);
|
|
1170
|
+
steps.push(row);
|
|
1171
|
+
ctx.log?.(`◇ plan step ${i + 1}/${total}: ${stepLabel(step)} ... ${ok ? "ok" : `failed: ${row.error}`} (${(duration_ms / 1000).toFixed(1)}s)`);
|
|
1172
|
+
if (!ok) { stoppedAt = i; break; }
|
|
1173
|
+
}
|
|
1174
|
+
const output = capOutput(redactDeep({ steps, ...(stoppedAt !== undefined ? { stopped_at: stoppedAt } : {}) }, { home }));
|
|
1175
|
+
if (stoppedAt !== undefined) {
|
|
1176
|
+
const failed = steps[stoppedAt];
|
|
1177
|
+
return { status: "failed", output, error: redact(`Stopped at step ${stoppedAt + 1} of ${total} (${stepLabel(v.steps[stoppedAt])}): ${failed?.error ?? "failed"}`, { home }).slice(0, 500) };
|
|
1178
|
+
}
|
|
1179
|
+
return { status: "done", output, error: null };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1183
|
+
// PRE-FLIGHT — what the host tells a visitor before it asks
|
|
1184
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The four read-only pieces of a pre-flight. Each is a function of the host ctx so a
|
|
1188
|
+
* test can swap one (cli_versions spawns every vendor CLI, which is slow and machine
|
|
1189
|
+
* dependent). Every piece fails on its own: a doctor that throws still leaves env.
|
|
1190
|
+
*/
|
|
1191
|
+
export const PREFLIGHT_PARTS = Object.freeze({
|
|
1192
|
+
env: (ctx) => VERBS.env({}, ctx),
|
|
1193
|
+
doctor: async (ctx) => {
|
|
1194
|
+
if (typeof ctx.doctor !== "function") return { error: "This Bridge can't run its doctor." };
|
|
1195
|
+
const report = await ctx.doctor();
|
|
1196
|
+
const rows = Array.isArray(report?.rows) ? report.rows : [];
|
|
1197
|
+
return {
|
|
1198
|
+
rows: rows.map((r) => ({ label: String(r?.label ?? ""), ok: r?.level === "ok", detail: typeof r?.fix === "string" ? r.fix : null })),
|
|
1199
|
+
fails: Number(report?.fails ?? rows.filter((r) => r?.level === "bad").length),
|
|
1200
|
+
warns: Number(report?.warns ?? rows.filter((r) => r?.level === "warn").length),
|
|
1201
|
+
};
|
|
1202
|
+
},
|
|
1203
|
+
cli_versions: async (ctx) => {
|
|
1204
|
+
const spec = RUN_TEMPLATES.cli_versions({}, ctx);
|
|
1205
|
+
return spec.local ? spec.local() : { error: spec.error ?? "cli_versions is unavailable" };
|
|
1206
|
+
},
|
|
1207
|
+
bridge_config: (ctx) => {
|
|
1208
|
+
if (!ctx.cfgPath) return { error: "This Bridge has no config path." };
|
|
1209
|
+
let text;
|
|
1210
|
+
try { text = fs.readFileSync(ctx.cfgPath, "utf8"); } catch (e) { return { error: `can't read the config: ${e.code || e.message}` }; }
|
|
1211
|
+
return projectedConfig(text);
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Gather the pre-flight: env, doctor rows, CLI versions and this Bridge's own config
|
|
1217
|
+
* with every token stripped, then redacted and capped exactly like any other result.
|
|
1218
|
+
* Read-only. ALWAYS resolves.
|
|
1219
|
+
*/
|
|
1220
|
+
export async function collectPreflight(ctx, parts = PREFLIGHT_PARTS) {
|
|
1221
|
+
const home = ctx.home ?? os.homedir();
|
|
1222
|
+
const out = {};
|
|
1223
|
+
for (const key of ["env", "doctor", "cli_versions", "bridge_config"]) {
|
|
1224
|
+
try {
|
|
1225
|
+
out[key] = await parts[key]({ ...ctx, home });
|
|
1226
|
+
} catch (e) {
|
|
1227
|
+
out[key] = { error: String(e?.message ?? e).slice(0, 300) };
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
out.at = new Date().toISOString();
|
|
1231
|
+
return capOutput(redactDeep(out, { home }));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Which active grants still owe a pre-flight from this host. A grant counts once it
|
|
1236
|
+
* is `active`, has verbs (a conversation-only grant has no machine), and is neither
|
|
1237
|
+
* already pre-flighted (persisted) nor out of attempts for this process. Pure.
|
|
1238
|
+
*/
|
|
1239
|
+
export function grantsNeedingPreflight(grants, { preflighted, tried, maxAttempts = 2 } = {}) {
|
|
1240
|
+
const done = preflighted ?? new Set();
|
|
1241
|
+
const attempts = tried ?? new Map();
|
|
1242
|
+
const out = [];
|
|
1243
|
+
for (const g of Array.isArray(grants) ? grants : []) {
|
|
1244
|
+
const id = g?.id;
|
|
1245
|
+
if (typeof id !== "string" || !id) continue;
|
|
1246
|
+
if (g.status && g.status !== "active") continue;
|
|
1247
|
+
if (g.conversation_only === true) continue;
|
|
1248
|
+
if (Array.isArray(g.scope?.verbs) && g.scope.verbs.length === 0) continue;
|
|
1249
|
+
if (done.has(id)) continue;
|
|
1250
|
+
if ((attempts.get(id) ?? 0) >= maxAttempts) continue;
|
|
1251
|
+
out.push(id);
|
|
1252
|
+
}
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Run the pre-flight for one grant and post it as a host-initiated call:
|
|
1258
|
+
* `ctx.create(grantId)` asks the server for a call row (POST /api/bridge/hands with
|
|
1259
|
+
* { grant_id, verb: "preflight", host_initiated: true }) and returns its id;
|
|
1260
|
+
* `ctx.report(callId, result)` posts the output through the same route every other
|
|
1261
|
+
* call uses. Throws when the server refuses, so the caller can count the attempt.
|
|
1262
|
+
*/
|
|
1263
|
+
export async function runPreflight(grantId, ctx) {
|
|
1264
|
+
const output = await collectPreflight(ctx, ctx.preflightParts ?? PREFLIGHT_PARTS);
|
|
1265
|
+
const callId = await ctx.create(grantId);
|
|
1266
|
+
if (typeof callId !== "string" || !callId) throw new Error("the server didn't open a pre-flight call");
|
|
1267
|
+
const posted = await ctx.report(callId, { status: "done", output, error: null });
|
|
1268
|
+
if (posted === false) throw new Error("the server refused the pre-flight result");
|
|
1269
|
+
return { callId, output };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1029
1272
|
/**
|
|
1030
1273
|
* The host loop: claim each pending call (CAS server-side), run it, post the result.
|
|
1031
1274
|
* Serialized per grant — one visiting agent, one pair of hands, one thing at a time,
|
|
@@ -1057,6 +1300,12 @@ export function describeCall(call) {
|
|
|
1057
1300
|
case "run": return `run ${a.template}`;
|
|
1058
1301
|
case "doctor": return "run the setup doctor";
|
|
1059
1302
|
case "env": return "look at what's installed";
|
|
1303
|
+
case "preflight": return "pre-flight: what's installed, the doctor, CLI versions, the config's shape";
|
|
1304
|
+
case "plan": {
|
|
1305
|
+
const steps = Array.isArray(a.steps) ? a.steps : [];
|
|
1306
|
+
const why = typeof a.why === "string" && a.why.trim() ? a.why.trim().slice(0, 120) : "a plan";
|
|
1307
|
+
return `plan: ${why} (${steps.length} step${steps.length === 1 ? "" : "s"}: ${steps.map(stepLabel).join(", ").slice(0, 200)})`;
|
|
1308
|
+
}
|
|
1060
1309
|
// The three verbs a host must approve are the three that used to render as a
|
|
1061
1310
|
// bare verb name (ultrareview #123, bug_007). Say WHAT, not just which.
|
|
1062
1311
|
case "write_file": return `write ${a.path}${typeof a.content === "string" ? ` (${a.content.length} chars)` : ""}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cookbook-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"node": ">=18"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
|
-
"test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs test/kimi.test.mjs"
|
|
43
|
+
"test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs test/kimi.test.mjs test/hands.test.mjs"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"cookbook",
|