@bivy/bivy 0.19.4-staging.4 → 0.19.4-staging.6
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/bin/bivy.mjs +195 -12
- package/bin/cli-commands.mjs +4 -0
- package/bin/guides/automate.md +2 -1
- package/bin/guides/more-hands.md +6 -0
- package/bin/guides/show-the-user.md +6 -1
- package/dist/auth.js +20 -1
- package/dist/certification/agent-ux.js +48 -0
- package/dist/harness/mcp-config.js +4 -1
- package/dist/harness/mcp-serve-cli.js +6 -5
- package/dist/protocol/client-command-routes.js +1 -0
- package/dist/protocol/client-command-schemas.js +1 -0
- package/dist/runtime/session-env.js +9 -1
- package/dist/server.js +19 -1
- package/dist/session/session-tokens.js +74 -0
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -511,7 +511,19 @@ function nativeAgentAuthDetected(choice) {
|
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
function url(config) {
|
|
514
|
-
|
|
514
|
+
// BIVY_NODE_URL: set by `bivy mcp-serve` for the CLI it runs, so tools reach
|
|
515
|
+
// the node that started the agent whatever data dir this process resolves.
|
|
516
|
+
const override = process.env.BIVY_NODE_URL?.trim().replace(/\/+$/, "");
|
|
517
|
+
return override || `http://localhost:${config.port}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The credential for a request about `sessionId`: the agent's own session token
|
|
521
|
+
// ($BIVY_SESSION_TOKEN) when it is that session, else a device token when this
|
|
522
|
+
// user can mint one, else none (loopback on a single-user host needs none).
|
|
523
|
+
async function sessionAuth(config, sessionId) {
|
|
524
|
+
const own = resolveAttachSessionId({ env: process.env });
|
|
525
|
+
if (process.env.BIVY_SESSION_TOKEN && sessionId && sessionId === own) return process.env.BIVY_SESSION_TOKEN;
|
|
526
|
+
try { return await localDeviceToken(config); } catch { return undefined; }
|
|
515
527
|
}
|
|
516
528
|
|
|
517
529
|
// The host the node will bind (mirrors src/server.ts). We probe on this same
|
|
@@ -2424,8 +2436,7 @@ async function cmdAttach(args = []) {
|
|
|
2424
2436
|
// (loopback bypasses auth), but include one when available so multi-user
|
|
2425
2437
|
// hosts work too.
|
|
2426
2438
|
async function sessionPost(config, sessionId, action, body) {
|
|
2427
|
-
|
|
2428
|
-
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2439
|
+
const token = await sessionAuth(config, sessionId);
|
|
2429
2440
|
return fetch(`${url(config)}/api/session/${encodeURIComponent(sessionId)}/${action}`, {
|
|
2430
2441
|
method: "POST",
|
|
2431
2442
|
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
@@ -2437,7 +2448,7 @@ async function sessionPost(config, sessionId, action, body) {
|
|
|
2437
2448
|
function sessionHttpError(what, status, body) {
|
|
2438
2449
|
const message = `${what} failed (${status}): ${body?.error || "unknown error"}`;
|
|
2439
2450
|
if (status === 404) return { code: "session_not_found", message, hint: "The session must be open on this node.", next: "bivy context", exit: EXIT.notFound };
|
|
2440
|
-
if (status === 401 || status === 403) return { code: "denied", message, exit: EXIT.denied };
|
|
2451
|
+
if (status === 401 || status === 403) return { code: "denied", message, hint: "A session's token only reaches that session; run it from the session's own shell, or from a terminal signed in to this node.", exit: EXIT.denied };
|
|
2441
2452
|
if (status === 400) return { code: "invalid", message, exit: EXIT.usage };
|
|
2442
2453
|
return { code: "failed", message, exit: EXIT.failed };
|
|
2443
2454
|
}
|
|
@@ -2472,8 +2483,7 @@ async function cmdContext(args = []) {
|
|
|
2472
2483
|
let node = { session: null };
|
|
2473
2484
|
if (sessionId) {
|
|
2474
2485
|
const config = loadConfig();
|
|
2475
|
-
|
|
2476
|
-
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2486
|
+
const token = await sessionAuth(config, sessionId);
|
|
2477
2487
|
try {
|
|
2478
2488
|
const res = await fetch(`${url(config)}/api/session/${encodeURIComponent(sessionId)}/context`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
2479
2489
|
const body = await res.json().catch(() => ({}));
|
|
@@ -2631,8 +2641,7 @@ answer later and 'bivy ask status <id>' checks without waiting.
|
|
|
2631
2641
|
if (!Number.isFinite(timeout) || timeout < 1) return fail({ code: "usage", message: "--timeout takes seconds or a duration like 90s, 10m or 2h.", exit: EXIT.usage });
|
|
2632
2642
|
const config = loadConfig();
|
|
2633
2643
|
if (!(await ensureNodeRunning(config))) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node at ${url(config)}.`, next: "bivy status", exit: EXIT.unavailable });
|
|
2634
|
-
|
|
2635
|
-
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2644
|
+
const token = await sessionAuth(config, sessionId);
|
|
2636
2645
|
const base = `${url(config)}/api/session/${encodeURIComponent(sessionId)}/ask`;
|
|
2637
2646
|
const call = async (suffix, body) => {
|
|
2638
2647
|
const res = await fetch(`${base}${suffix}`, {
|
|
@@ -2684,6 +2693,139 @@ answer later and 'bivy ask status <id>' checks without waiting.
|
|
|
2684
2693
|
finish(await waitFor(started.id, timeout + 5));
|
|
2685
2694
|
}
|
|
2686
2695
|
|
|
2696
|
+
// One JSON request to the local node for the commands below: starts the node if
|
|
2697
|
+
// needed and returns { status, body }, or fails in the shared error shape.
|
|
2698
|
+
async function nodeRequest(method, pathname, body, { json = false, sessionId } = {}) {
|
|
2699
|
+
const config = loadConfig();
|
|
2700
|
+
if (!(await ensureNodeRunning(config))) return cliError({ code: "node_unreachable", message: `Could not reach the Bivy node at ${url(config)}.`, next: "bivy status", exit: EXIT.unavailable }, { json, paint: c.red });
|
|
2701
|
+
const token = await sessionAuth(config, sessionId);
|
|
2702
|
+
const res = await fetch(`${url(config)}${pathname}`, {
|
|
2703
|
+
method,
|
|
2704
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
2705
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
2706
|
+
}).catch((error) => error);
|
|
2707
|
+
if (res instanceof Error) return cliError({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable }, { json, paint: c.red });
|
|
2708
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// `bivy fork [session-id]` — the app's Fork on this machine: a new session with
|
|
2712
|
+
// the same conversation, agent and uncommitted work, on its own branch. Give it
|
|
2713
|
+
// its next task with `bivy send <new-id> "…"`.
|
|
2714
|
+
async function cmdFork(args = []) {
|
|
2715
|
+
const usage = "Usage: bivy fork [session-id] [--model <provider/id>] [--json]";
|
|
2716
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2717
|
+
console.log(`${usage}\n\nCopy a session (default: this one, $BIVY_SESSION_ID) into a new session on this machine: the same conversation and agent, with uncommitted work carried into a fresh worktree on its own branch. --model switches the fork's model. Prints the new session's id; send it work with 'bivy send <id> "…"'. --json prints {"sessionId","fidelity"}.`);
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
const json = wantsJson(args);
|
|
2721
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2722
|
+
const parsed = parseSessionArgs(args, ["--model", "--session"]);
|
|
2723
|
+
if (parsed.error) return fail({ code: "usage", message: parsed.error, exit: EXIT.usage });
|
|
2724
|
+
const sessionId = parsed.positional[0] ?? resolveAttachSessionId({ sessionFlag: parsed.one("--session"), env: process.env });
|
|
2725
|
+
if (!sessionId) return fail({ code: "no_session", message: "No session to fork.", hint: "Pass a session id, or run inside an agent session ($BIVY_SESSION_ID).", next: "bivy sessions --json", exit: EXIT.usage });
|
|
2726
|
+
// "provider/id" or a bare model id, as the fork command takes it.
|
|
2727
|
+
const modelArg = parsed.one("--model");
|
|
2728
|
+
const slash = modelArg?.indexOf("/") ?? -1;
|
|
2729
|
+
const model = modelArg ? (slash > 0 ? { provider: modelArg.slice(0, slash), id: modelArg.slice(slash + 1) } : { provider: "", id: modelArg }) : undefined;
|
|
2730
|
+
const { status, body } = await nodeRequest("POST", "/api/session/fork", { sessionId, ...(model ? { model } : {}) }, { json, sessionId });
|
|
2731
|
+
if (status === 404) return fail({ code: "node_outdated", message: "This node is older than 'bivy fork'.", next: "bivy update", exit: EXIT.unavailable });
|
|
2732
|
+
if (status >= 400 || body.error) return fail({ code: /not found/i.test(body.error ?? "") ? "session_not_found" : "failed", message: `Fork failed: ${body.error || `HTTP ${status}`}`, exit: /not found/i.test(body.error ?? "") ? EXIT.notFound : EXIT.failed });
|
|
2733
|
+
if (json) { console.log(JSON.stringify({ sessionId: body.sessionId, fidelity: body.fidelity, ...(body.missing?.length ? { missing: body.missing } : {}) })); return; }
|
|
2734
|
+
console.log(c.green(`Forked into ${body.sessionId}.`) + ` Give it work with: bivy send ${body.sessionId} "…"`);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// `bivy approvals` — tool calls waiting for a person, and answering them. For a
|
|
2738
|
+
// supervising agent or script; the app shows the same requests as cards.
|
|
2739
|
+
async function cmdApprovals(args = []) {
|
|
2740
|
+
const usage = "Usage: bivy approvals [list] [--json]\n bivy approvals approve <id> [--json]\n bivy approvals reject <id> [--json]";
|
|
2741
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2742
|
+
console.log(`${usage}\n\nList the tool calls on this node waiting for approval, or answer one. The app shows the same requests as cards; answering here resolves them there too.`);
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
const json = wantsJson(args);
|
|
2746
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2747
|
+
const [action = "list", id] = args.filter((a) => !a.startsWith("-"));
|
|
2748
|
+
if (action === "list") {
|
|
2749
|
+
const { body } = await nodeRequest("GET", "/api/approvals", undefined, { json });
|
|
2750
|
+
const pending = (Array.isArray(body) ? body : []).filter((a) => a.status === "pending");
|
|
2751
|
+
if (json) { console.log(JSON.stringify(pending, null, 2)); return; }
|
|
2752
|
+
if (!pending.length) { console.log("No approvals waiting."); return; }
|
|
2753
|
+
for (const a of pending) console.log(`${c.cyan(a.id)} ${a.toolName}${a.risk ? c.dim(` (${a.risk})`) : ""} session ${a.sessionId}\n ${a.reason}`);
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
if ((action === "approve" || action === "reject") && id) {
|
|
2757
|
+
const { status } = await nodeRequest("POST", `/api/approvals/${encodeURIComponent(id)}/${action}`, {}, { json });
|
|
2758
|
+
if (status === 404) return fail({ code: "not_found", message: `No pending approval ${id}.`, next: "bivy approvals --json", exit: EXIT.notFound });
|
|
2759
|
+
if (json) { console.log(JSON.stringify({ ok: true, id, approved: action === "approve" })); return; }
|
|
2760
|
+
console.log(c.green(action === "approve" ? `Approved ${id}.` : `Rejected ${id}.`));
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// `bivy issues` — the node's GitHub issue pickup: open issues labelled for an
|
|
2767
|
+
// agent, and starting a session on one (it claims the issue with a label).
|
|
2768
|
+
async function cmdIssues(args = []) {
|
|
2769
|
+
const usage = "Usage: bivy issues [list] [--json]\n bivy issues pickup <number> [--json]";
|
|
2770
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2771
|
+
console.log(`${usage}\n\nOpen GitHub issues labelled for an agent in this node's repository, and picking one up: pickup claims the issue with its claim label and starts a session on it. Needs GitHub issue pickup configured (see docs/github-work-queue.md).`);
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
const json = wantsJson(args);
|
|
2775
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2776
|
+
const [action = "list", number] = args.filter((a) => !a.startsWith("-"));
|
|
2777
|
+
if (action === "list") {
|
|
2778
|
+
const { status, body } = await nodeRequest("GET", "/api/github/issues", undefined, { json });
|
|
2779
|
+
if (status >= 400) return fail({ code: "not_configured", message: body.error || `HTTP ${status}`, exit: EXIT.failed });
|
|
2780
|
+
if (json) { console.log(JSON.stringify(body, null, 2)); return; }
|
|
2781
|
+
if (!body.issues?.length) { console.log(`No open issues labelled ${body.label} in ${body.repo}.`); return; }
|
|
2782
|
+
for (const issue of body.issues) console.log(`${c.cyan(`#${issue.number}`)} ${issue.title}`);
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
if (action === "pickup" && number) {
|
|
2786
|
+
const { status, body } = await nodeRequest("POST", `/api/github/issues/${encodeURIComponent(number)}/pickup`, {}, { json });
|
|
2787
|
+
if (status >= 400) return fail({ code: status === 404 ? "not_found" : status === 409 ? "conflict" : "failed", message: body.error || `HTTP ${status}`, exit: status === 404 ? EXIT.notFound : status === 409 ? EXIT.conflict : status === 400 ? EXIT.usage : EXIT.failed });
|
|
2788
|
+
if (json) { console.log(JSON.stringify(body)); return; }
|
|
2789
|
+
console.log(c.green(`Picked up #${body.issue?.number} in ${body.repo}; a session is starting on it.`));
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// `bivy instructions` — the account-wide instructions every agent session gets
|
|
2796
|
+
// (Settings → Agent instructions). `set` refuses to overwrite a newer edit
|
|
2797
|
+
// made elsewhere since the text was read.
|
|
2798
|
+
async function cmdInstructions(args = []) {
|
|
2799
|
+
const usage = "Usage: bivy instructions [show] [--json]\n bivy instructions set <file|-> [--json]\n bivy instructions path";
|
|
2800
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2801
|
+
console.log(`${usage}\n\nThe account-wide instructions every agent session receives (the same text as Settings → Agent instructions). 'set' replaces them from a file, or from stdin with '-'; it applies to sessions started afterwards. 'path' prints the file on this machine.`);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
const json = wantsJson(args);
|
|
2805
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2806
|
+
const [action = "show", source] = args.filter((a) => a === "-" || !a.startsWith("-"));
|
|
2807
|
+
if (action === "path") { console.log(path.join(appDir, "AGENTS.md")); return; }
|
|
2808
|
+
const { status, body } = await nodeRequest("GET", "/api/node/settings", undefined, { json });
|
|
2809
|
+
const current = body.agentInstructions;
|
|
2810
|
+
if (status >= 400 || !current) return fail({ code: "node_outdated", message: "This node does not report agent instructions.", next: "bivy update", exit: EXIT.unavailable });
|
|
2811
|
+
if (action === "show") {
|
|
2812
|
+
if (json) { console.log(JSON.stringify({ text: current.text, updatedAt: current.updatedAt, bivyNote: current.bivyNote })); return; }
|
|
2813
|
+
console.log(current.text || c.dim("(none yet)"));
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
if (action === "set" && source) {
|
|
2817
|
+
let text;
|
|
2818
|
+
try { text = source === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(path.resolve(source), "utf8"); }
|
|
2819
|
+
catch (error) { return fail({ code: "file_not_found", message: `Could not read ${source}: ${error?.message || error}`, exit: EXIT.notFound }); }
|
|
2820
|
+
const saved = await nodeRequest("POST", "/api/node/settings", { agentInstructions: text, agentInstructionsBaseUpdatedAt: current.updatedAt }, { json });
|
|
2821
|
+
if (saved.status >= 400) return fail({ code: /another device/.test(saved.body.error ?? "") ? "conflict" : "invalid", message: saved.body.error || `HTTP ${saved.status}`, exit: /another device/.test(saved.body.error ?? "") ? EXIT.conflict : EXIT.usage });
|
|
2822
|
+
if (json) { console.log(JSON.stringify({ ok: true, bytes: Buffer.byteLength(text) })); return; }
|
|
2823
|
+
console.log(c.green("Saved. Sessions started from now on get these instructions."));
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2687
2829
|
// Cross-agent / cross-machine delegation (docs/agent-delegation.md): hand a
|
|
2688
2830
|
// self-contained task from this session to another agent, optionally on another
|
|
2689
2831
|
// account machine, and get its answer back. Agent-agnostic — any agent with a
|
|
@@ -2720,8 +2862,7 @@ async function cmdDelegate(args = []) {
|
|
|
2720
2862
|
if (!sessionId) { console.error(c.red("No session id. Run inside an agent session ($BIVY_SESSION_ID) or pass --session <id>.")); process.exit(1); return; }
|
|
2721
2863
|
const config = loadConfig();
|
|
2722
2864
|
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
2723
|
-
|
|
2724
|
-
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2865
|
+
const token = await sessionAuth(config, sessionId);
|
|
2725
2866
|
const base = `${url(config)}/api/session/${encodeURIComponent(sessionId)}/delegated-runs`;
|
|
2726
2867
|
const call = async (path, body) => {
|
|
2727
2868
|
const res = await fetch(`${base}${path}`, {
|
|
@@ -3002,9 +3143,9 @@ async function appMenu(rest, { json = false } = {}) {
|
|
|
3002
3143
|
async function appRequest(action, body, { print = true } = {}) {
|
|
3003
3144
|
const config = loadConfig();
|
|
3004
3145
|
if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
|
|
3005
|
-
const token = await
|
|
3146
|
+
const token = await sessionAuth(config, body.sessionId);
|
|
3006
3147
|
const response = await fetch(`${url(config)}/api/apps/${action}`, {
|
|
3007
|
-
method: "POST", headers: { authorization: `Bearer ${token}
|
|
3148
|
+
method: "POST", headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "content-type": "application/json" }, body: JSON.stringify(body),
|
|
3008
3149
|
});
|
|
3009
3150
|
const result = await response.json();
|
|
3010
3151
|
if (!response.ok || result.error) throw new Error(result.error || `App command failed (${response.status}).`);
|
|
@@ -5687,6 +5828,22 @@ function readSelfVersion() {
|
|
|
5687
5828
|
}
|
|
5688
5829
|
}
|
|
5689
5830
|
|
|
5831
|
+
// A command an agent tried that doesn't exist is the best hint for what it
|
|
5832
|
+
// expected Bivy to have. Inside a session, keep the word (never its arguments)
|
|
5833
|
+
// in <data-dir>/agent-cli-misses.jsonl for `pnpm run eval:agent-ux` and
|
|
5834
|
+
// whoever names the next command. Bounded: one rotation at 256 KiB.
|
|
5835
|
+
function recordAgentMiss(word) {
|
|
5836
|
+
const session = resolveAttachSessionId({ env: process.env });
|
|
5837
|
+
if (!session || typeof word !== "string" || word.length > 64) return;
|
|
5838
|
+
try {
|
|
5839
|
+
const file = path.join(appDir, "agent-cli-misses.jsonl");
|
|
5840
|
+
if (fs.existsSync(file) && fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`);
|
|
5841
|
+
fs.appendFileSync(file, `${JSON.stringify({ ts: Date.now(), session, word })}\n`, { mode: 0o600 });
|
|
5842
|
+
} catch {
|
|
5843
|
+
// Best-effort: a miss log must never change what the command does.
|
|
5844
|
+
}
|
|
5845
|
+
}
|
|
5846
|
+
|
|
5690
5847
|
function printHelp() {
|
|
5691
5848
|
const details = { run: `Agents: ${[...AGENT_INTEGRATIONS.keys()].join(" | ")}, or -- <command>` };
|
|
5692
5849
|
console.log(`\n${renderHelp({ paint: c.cyan, bold: c.bold, details })}\n`);
|
|
@@ -5764,13 +5921,27 @@ function cmdGuide(args = []) {
|
|
|
5764
5921
|
console.log(guide.markdown.trimEnd());
|
|
5765
5922
|
}
|
|
5766
5923
|
|
|
5924
|
+
// Inside an agent session the daemon names its own CLI ($BIVY_NODE_CLI). When
|
|
5925
|
+
// `bivy` was found on PATH (every installer links it by that name) and is
|
|
5926
|
+
// another file, run the node's, so the agent gets the commands its node
|
|
5927
|
+
// documents. Running a file on purpose (`node …/bin/bivy.mjs`) is left alone.
|
|
5928
|
+
function nodeCliToRun() {
|
|
5929
|
+
const nodeCli = process.env.BIVY_NODE_CLI;
|
|
5930
|
+
if (path.basename(process.argv[1] ?? "") !== "bivy") return undefined;
|
|
5931
|
+
if (!nodeCli || !path.isAbsolute(nodeCli) || !fs.existsSync(nodeCli)) return undefined;
|
|
5932
|
+
try { return fs.realpathSync(nodeCli) === fs.realpathSync(selfScript) ? undefined : nodeCli; } catch { return undefined; }
|
|
5933
|
+
}
|
|
5934
|
+
|
|
5767
5935
|
async function main() {
|
|
5936
|
+
const nodeCli = nodeCliToRun();
|
|
5937
|
+
if (nodeCli) process.exit(spawnSync(process.execPath, [nodeCli, ...process.argv.slice(2)], { stdio: "inherit", env: process.env }).status ?? 1);
|
|
5768
5938
|
await hydrateCanonicalConfig();
|
|
5769
5939
|
const argv = process.argv.slice(2);
|
|
5770
5940
|
const [command, ...args] = argv;
|
|
5771
5941
|
if (command === undefined) { printHelp(); return; }
|
|
5772
5942
|
const entry = resolveCommand(command);
|
|
5773
5943
|
if (!entry) {
|
|
5944
|
+
recordAgentMiss(command);
|
|
5774
5945
|
const close = suggestCommands(command);
|
|
5775
5946
|
cliError({
|
|
5776
5947
|
code: "unknown_command",
|
|
@@ -5861,6 +6032,18 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
5861
6032
|
case "notify":
|
|
5862
6033
|
await cmdNotify(args);
|
|
5863
6034
|
break;
|
|
6035
|
+
case "fork":
|
|
6036
|
+
await cmdFork(args);
|
|
6037
|
+
break;
|
|
6038
|
+
case "approvals":
|
|
6039
|
+
await cmdApprovals(args);
|
|
6040
|
+
break;
|
|
6041
|
+
case "issues":
|
|
6042
|
+
await cmdIssues(args);
|
|
6043
|
+
break;
|
|
6044
|
+
case "instructions":
|
|
6045
|
+
await cmdInstructions(args);
|
|
6046
|
+
break;
|
|
5864
6047
|
case "ask":
|
|
5865
6048
|
await cmdAsk(args);
|
|
5866
6049
|
break;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -56,6 +56,7 @@ export const COMMANDS = [
|
|
|
56
56
|
{ name: "app_screenshot", description: "Screenshot this session's web views (or one app) and return the PNG paths, to check your work before saying a UI change is done.", input: { app: { type: "string", description: "App ID or name (default: all web views)." }, widths: { type: "array", items: { type: "number" }, description: "Viewport widths, e.g. [390, 1280]." }, path: { type: "string", description: "Page path, e.g. /settings." } }, argv: ["app", "shot", { arg: "app" }, { flag: "--widths", from: "widths", join: "," }, { flag: "--path", from: "path" }] },
|
|
57
57
|
{ name: "app_present", description: "Tell the user a visible change is ready: a card in the chat with the app at phone width (before and after), and the preview opens.", input: { view: { type: "string", description: "App or view name or ID (default: the one opened last)." }, note: { type: "string", description: "What changed." }, path: { type: "string", description: "Page path to show." } }, argv: ["app", "present", { arg: "view" }, { flag: "--note", from: "note" }, { flag: "--path", from: "path" }] },
|
|
58
58
|
] },
|
|
59
|
+
{ name: "fork", group: "session", scope: "session", json: true, usage: "fork [session-id] [--model <model>]", summary: "Copy this session (conversation and uncommitted work) into a new one on its own branch" },
|
|
59
60
|
{ name: "delegate", group: "session", scope: "session", json: true, usage: 'delegate "<task>" [--agent <id>] [--machine <name>] [--wait]', summary: "Hand a task to another agent or machine and get its answer back", subcommands: ["machines", "status", "wait"] },
|
|
60
61
|
|
|
61
62
|
// Agents and sessions
|
|
@@ -68,6 +69,8 @@ export const COMMANDS = [
|
|
|
68
69
|
{ name: "takeover", group: "sessions", scope: "node", usage: "takeover <id>", summary: "Stop a run-terminal's native TUI and continue it as a governed chat" },
|
|
69
70
|
{ name: "promote", group: "sessions", scope: "node", usage: "promote <session-id>", summary: "Continue a warm-replicated session on this node" },
|
|
70
71
|
{ name: "prune", aliases: ["clean"], group: "sessions", scope: "node", json: true, usage: "prune [--keep N] [--older-than 7d] [--dry-run]", summary: "Delete old sessions, workspaces and worktrees" },
|
|
72
|
+
{ name: "approvals", group: "sessions", scope: "node", json: true, usage: "approvals [list|approve <id>|reject <id>]", summary: "List pending tool approvals on this node, or answer one", subcommands: ["list", "approve", "reject"] },
|
|
73
|
+
{ name: "issues", group: "sessions", scope: "node", json: true, usage: "issues [list|pickup <number>]", summary: "GitHub issues waiting for an agent; pick one up to start a session on it", subcommands: ["list", "pickup"] },
|
|
71
74
|
{ name: "agents", group: "sessions", scope: "node", json: true, usage: "agents [--json]", summary: "List supported agents and which are installed" },
|
|
72
75
|
{ name: "agent", group: "sessions", scope: "node", json: true, usage: "agent <add|list|remove>", summary: "Connect, list or remove a user-owned agent", subcommands: ["add", "list", "remove"] },
|
|
73
76
|
{ name: "agents:install", aliases: ["runtimes:install"], group: "sessions", scope: "node", usage: "agents:install [--bridges]", summary: "Install known upstream agents" },
|
|
@@ -111,6 +114,7 @@ export const COMMANDS = [
|
|
|
111
114
|
{ name: "github:app-sync", group: "accounts", scope: "account", usage: "github:app-sync [on|off]", summary: "Sync GitHub App keys to this account's other nodes" },
|
|
112
115
|
|
|
113
116
|
// Configuration and extensions
|
|
117
|
+
{ name: "instructions", group: "config", scope: "account", json: true, usage: "instructions [show|set <file|->|path]", summary: "The account-wide instructions every agent session receives", subcommands: ["show", "set", "path"] },
|
|
114
118
|
{ name: "config", group: "config", scope: "node", json: true, usage: "config <show|get|set|unset|explain|validate|init|path>", summary: "Typed node configuration", subcommands: ["init", "validate", "show", "get", "set", "unset", "explain", "path"] },
|
|
115
119
|
{ name: "plugin", aliases: ["plugins"], group: "config", scope: "node", json: true, usage: "plugin <init|validate|doctor|test|install|list|remove>", summary: "Build and install plugins", subcommands: ["init", "validate", "doctor", "test", "install", "list", "remove"] },
|
|
116
120
|
{ name: "voice", aliases: ["stt"], group: "config", scope: "node", usage: "voice <provider|key|remove|status>", summary: "Configure speech-to-text", subcommands: ["provider", "key", "remove", "status"] },
|
package/bin/guides/automate.md
CHANGED
|
@@ -13,6 +13,7 @@ infrastructure code:
|
|
|
13
13
|
bivy automation list --json
|
|
14
14
|
bivy automation trigger <id-or-key> # run one now
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
To pause one, set `enabled: false` on it and apply. Always `validate` and
|
|
17
|
+
`plan` before `apply`, and show the user the plan when it
|
|
17
18
|
changes something they didn't ask for. `apply --prune` also removes automations
|
|
18
19
|
the file no longer has. `bivy automation --help` has every flag.
|
package/bin/guides/more-hands.md
CHANGED
|
@@ -15,4 +15,10 @@ Write the task so it stands on its own: the other agent does not see this chat.
|
|
|
15
15
|
get a run id: `bivy delegate status <id>`, `bivy delegate wait <id>`.
|
|
16
16
|
Code comes back by git: fetch the branch or review the PR.
|
|
17
17
|
|
|
18
|
+
To try another approach from the same point, fork this session and give the
|
|
19
|
+
copy its task (it gets its own branch):
|
|
20
|
+
|
|
21
|
+
id=$(bivy fork --json | jq -r .sessionId)
|
|
22
|
+
bivy send "$id" "Try the same fix with a streaming parser instead."
|
|
23
|
+
|
|
18
24
|
For unattended work with checks and a receipt, see `bivy guide long-work`.
|
|
@@ -19,9 +19,14 @@ not render; a remote `https://` image does.
|
|
|
19
19
|
|
|
20
20
|
Publish it once; the user opens it from the chat:
|
|
21
21
|
|
|
22
|
-
bivy app run --
|
|
22
|
+
bivy app run -- cargo run # a desktop app, no manifest
|
|
23
23
|
bivy app publish app.json # web server, static build, terminal
|
|
24
24
|
|
|
25
|
+
A `terminal` view is a command the user can watch and type into from the app,
|
|
26
|
+
such as a REPL or a log tail:
|
|
27
|
+
|
|
28
|
+
{"kind":"terminal","name":"Console","command":"bin/rails","args":["console"]}
|
|
29
|
+
|
|
25
30
|
A manifest with a server Bivy starts on first open:
|
|
26
31
|
|
|
27
32
|
{"version":1,"name":"Site","views":[{"kind":"web","name":"Web",
|
package/dist/auth.js
CHANGED
|
@@ -251,12 +251,31 @@ export function requestOriginAllowed(req) {
|
|
|
251
251
|
return true;
|
|
252
252
|
}
|
|
253
253
|
/** Express middleware enforcing auth on /api routes. */
|
|
254
|
-
export function authMiddleware(identity) {
|
|
254
|
+
export function authMiddleware(identity, sessionTokens) {
|
|
255
255
|
return (req, res, next) => {
|
|
256
256
|
if (!requestOriginAllowed(req)) {
|
|
257
257
|
res.status(403).json({ error: "Forbidden origin" });
|
|
258
258
|
return;
|
|
259
259
|
}
|
|
260
|
+
// An agent session's token: only that session's own routes, even over
|
|
261
|
+
// loopback, so a token-carrying request is never more than its session.
|
|
262
|
+
const token = tokenFromRequest(req);
|
|
263
|
+
if (sessionTokens?.claims(token)) {
|
|
264
|
+
const sessionId = sessionTokens.verify(token);
|
|
265
|
+
if (!sessionId) {
|
|
266
|
+
res.status(401).json({ error: "Unknown or expired session token" });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const call = { method: req.method, path: req.baseUrl + req.path, body: req.body };
|
|
270
|
+
if (!sessionTokens.allows(sessionId, call)) {
|
|
271
|
+
res.status(403).json({ error: `A session token can only reach its own session's routes, not ${call.method} ${call.path}.` });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
sessionTokens.used?.(sessionId, call);
|
|
275
|
+
req.auth = { deviceId: null, loopback: isLoopbackAddress(req.socket?.remoteAddress), sessionId };
|
|
276
|
+
next();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
260
279
|
const ctx = resolveAuth(identity, req);
|
|
261
280
|
if (!isAuthorized(ctx)) {
|
|
262
281
|
res.status(401).json({ error: "Unauthorized" });
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* Scoring for the agent-UX eval (certification/agent-ux.json, run by
|
|
5
|
+
* scripts/agent-ux-eval.ts): given what a real agent did in a session, did it
|
|
6
|
+
* use Bivy the way the task needed? Pure, so the rules are testable without an
|
|
7
|
+
* agent or a model.
|
|
8
|
+
*/
|
|
9
|
+
function globToRegExp(glob) {
|
|
10
|
+
return new RegExp(`^${glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
|
|
11
|
+
}
|
|
12
|
+
/** Every expectation of a task, checked against an observation. `vars` fills {name} placeholders. */
|
|
13
|
+
export function scoreAgentUx(expect, seen, vars = {}) {
|
|
14
|
+
const fill = (text) => text.replace(/\{(\w+)\}/g, (whole, name) => vars[name] ?? whole);
|
|
15
|
+
return expect.map((rule) => {
|
|
16
|
+
if ("block" in rule) {
|
|
17
|
+
const min = rule.min ?? 1;
|
|
18
|
+
return { expectation: `${min}+ ${rule.block}`, passed: (seen.blocks[rule.block] ?? 0) >= min };
|
|
19
|
+
}
|
|
20
|
+
if ("asked" in rule)
|
|
21
|
+
return { expectation: "asked a question", passed: seen.asked };
|
|
22
|
+
if ("reply" in rule) {
|
|
23
|
+
const text = fill(rule.reply);
|
|
24
|
+
return { expectation: `reply mentions "${text}"`, passed: seen.reply.toLowerCase().includes(text.toLowerCase()) };
|
|
25
|
+
}
|
|
26
|
+
const pattern = globToRegExp(rule.file);
|
|
27
|
+
return { expectation: `file ${rule.file}`, passed: seen.files.some((file) => pattern.test(file)) };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Count `bivy_*` chat card blocks anywhere in a history payload's messages. */
|
|
31
|
+
export function countCardBlocks(messages) {
|
|
32
|
+
const counts = {};
|
|
33
|
+
const walk = (value) => {
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
for (const item of value)
|
|
36
|
+
walk(item);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!value || typeof value !== "object")
|
|
40
|
+
return;
|
|
41
|
+
const type = value.type;
|
|
42
|
+
if (typeof type === "string" && type.startsWith("bivy_"))
|
|
43
|
+
counts[type] = (counts[type] ?? 0) + 1;
|
|
44
|
+
walk(value.content);
|
|
45
|
+
};
|
|
46
|
+
walk(messages);
|
|
47
|
+
return counts;
|
|
48
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// the fs inject/restore step (mcp-inject.ts) reads/writes; the transform itself
|
|
17
17
|
// is shared.
|
|
18
18
|
import nodePath from "node:path";
|
|
19
|
+
import { bivySessionEnv } from "../runtime/session-env.js";
|
|
19
20
|
/** Marker arg the proxy CLI recognizes, so a config can be detected as already routed. */
|
|
20
21
|
export const PROXY_MARKER = "--bivy-mcp";
|
|
21
22
|
/** True when a server spec already launches through the Bivy proxy. */
|
|
@@ -94,7 +95,9 @@ export function parseProxiedArgs(args) {
|
|
|
94
95
|
// back to the right session.
|
|
95
96
|
/** The server spec that launches `bivy mcp-serve` for a session. */
|
|
96
97
|
export function bivyToolsServerSpec(opts) {
|
|
97
|
-
|
|
98
|
+
// Some agents start MCP servers with a trimmed environment, so the session's
|
|
99
|
+
// token travels in the spec rather than being inherited.
|
|
100
|
+
const env = { ...bivySessionEnv(opts.sessionId) };
|
|
98
101
|
if (opts.endpoint)
|
|
99
102
|
env.BIVY_MCP_ENDPOINT = opts.endpoint;
|
|
100
103
|
// The user's account-wide instructions, advertised as the server's MCP
|
|
@@ -94,11 +94,12 @@ export async function runAttachToChat(endpoint, sessionId, args, fetchImpl, toke
|
|
|
94
94
|
const name = body?.name || filePath;
|
|
95
95
|
return { isError: false, text: `Attached ${name} to the chat as ${body?.kind === "image" ? "an inline image" : "a downloadable file"}. The user can see it now.` };
|
|
96
96
|
}
|
|
97
|
-
/** Run this install's `bivy` (bin/bivy.mjs, two levels up from src/ or dist/)
|
|
98
|
-
|
|
97
|
+
/** Run this install's `bivy` (bin/bivy.mjs, two levels up from src/ or dist/),
|
|
98
|
+
* against the node that injected this server. */
|
|
99
|
+
function defaultRunBivy(sessionId, endpoint) {
|
|
99
100
|
const cli = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../bin/bivy.mjs");
|
|
100
101
|
return (args) => new Promise((resolve) => {
|
|
101
|
-
const child = spawn(process.execPath, [cli, ...args], { env: { ...process.env, BIVY_SESSION_ID: sessionId, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
102
|
+
const child = spawn(process.execPath, [cli, ...args], { env: { ...process.env, BIVY_SESSION_ID: sessionId, BIVY_NODE_URL: endpoint, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
102
103
|
let stdout = "";
|
|
103
104
|
let stderr = "";
|
|
104
105
|
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
@@ -133,10 +134,10 @@ function readInstructions(file) {
|
|
|
133
134
|
export function createBivyMcpServer(deps = {}) {
|
|
134
135
|
const endpoint = deps.endpoint ?? process.env.BIVY_MCP_ENDPOINT ?? DEFAULT_ENDPOINT;
|
|
135
136
|
const sessionId = deps.sessionId ?? process.env.BIVY_SESSION_ID ?? process.env.BIVY_MCP_SESSION ?? "";
|
|
136
|
-
const token = deps.token ?? process.env.BIVY_MCP_TOKEN ?? undefined;
|
|
137
|
+
const token = deps.token ?? process.env.BIVY_MCP_TOKEN ?? process.env.BIVY_SESSION_TOKEN ?? undefined;
|
|
137
138
|
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
138
139
|
const instructions = readInstructions(deps.instructionsFile ?? process.env.BIVY_AGENT_INSTRUCTIONS_FILE);
|
|
139
|
-
const runBivy = deps.runBivy ?? defaultRunBivy(sessionId);
|
|
140
|
+
const runBivy = deps.runBivy ?? defaultRunBivy(sessionId, endpoint);
|
|
140
141
|
let tools;
|
|
141
142
|
const generated = () => (tools ??= cliTools(runBivy));
|
|
142
143
|
const server = new Server({ name: "bivy", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} }, ...(instructions ? { instructions } : {}) });
|
|
@@ -8,6 +8,7 @@ export const CLIENT_COMMAND_ROUTES = [
|
|
|
8
8
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
9
9
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
10
10
|
{ method: "post", path: "/api/session/pause", kind: "session.pause" },
|
|
11
|
+
{ method: "post", path: "/api/session/fork", kind: "session.fork.local" },
|
|
11
12
|
...["get", "draft"].map((action) => ({ method: "post", path: `/api/session/presence/${action}`, kind: `session.presence.${action}` })),
|
|
12
13
|
{ method: "post", path: "/api/session/resume", kind: "session.resume" },
|
|
13
14
|
{ method: "post", path: "/api/session/question/answer", kind: "session.question.answer" },
|
|
@@ -11,6 +11,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
11
11
|
ping: Type.Object(request),
|
|
12
12
|
"apps.list": Type.Object({ ...request, ...optionalSession }),
|
|
13
13
|
"artifacts.list": Type.Object(request),
|
|
14
|
+
"session.fork.local": Type.Object({ ...request, ...session, model: Type.Optional(Type.Object({ provider: Type.Optional(Type.String({ maxLength: 200 })), id: Type.String({ maxLength: 200 }) })) }),
|
|
14
15
|
"apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
|
|
15
16
|
"apps.offers": Type.Object({ ...request, ...session }),
|
|
16
17
|
"apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
|
|
@@ -15,8 +15,16 @@
|
|
|
15
15
|
// has no hook to inject this into its bash tool's env the same way — see the
|
|
16
16
|
// comment on PiSession.interactiveTuiCommand and bin/attach-session-id.mjs for
|
|
17
17
|
// how that gap is closed instead.
|
|
18
|
+
//
|
|
19
|
+
// With a signer registered (the daemon does at startup), the env also carries
|
|
20
|
+
// BIVY_SESSION_TOKEN: a credential for that session's own routes only (see
|
|
21
|
+
// src/session/session-tokens.ts), which the `bivy` CLI and MCP tools prefer.
|
|
22
|
+
let signSessionToken;
|
|
23
|
+
export function setSessionTokenSigner(sign) {
|
|
24
|
+
signSessionToken = sign;
|
|
25
|
+
}
|
|
18
26
|
export function bivySessionEnv(sessionId) {
|
|
19
|
-
return { BIVY_SESSION_ID: sessionId };
|
|
27
|
+
return { BIVY_SESSION_ID: sessionId, ...(signSessionToken ? { BIVY_SESSION_TOKEN: signSessionToken(sessionId) } : {}) };
|
|
20
28
|
}
|
|
21
29
|
/**
|
|
22
30
|
* Apply a profile's instructions env template (AgentProfile.instructions) for one
|
package/dist/server.js
CHANGED
|
@@ -128,6 +128,8 @@ import { createArtifactCommands } from "./controllers/artifact-commands.js";
|
|
|
128
128
|
import { PresenceBook, deviceFrom } from "./session/presence.js";
|
|
129
129
|
import { buildAgentContext } from "./session/agent-context.js";
|
|
130
130
|
import { AgentQuestions, askQuestionsFrom } from "./session/agent-questions.js";
|
|
131
|
+
import { createSessionTokenCodec, isSessionToken, sessionTokenAllows } from "./session/session-tokens.js";
|
|
132
|
+
import { setSessionTokenSigner } from "./runtime/session-env.js";
|
|
131
133
|
import { MAX_NOTICE_TEXT, isAgentNotice, noticePush } from "./session/notices.js";
|
|
132
134
|
import { createForkCommands } from "./controllers/fork-commands.js";
|
|
133
135
|
import { createGithubCommands } from "./controllers/github-commands.js";
|
|
@@ -169,6 +171,12 @@ import { TERMINAL_DELEGATION, isDelegatedFrom } from "./session/delegations.js";
|
|
|
169
171
|
import { ControlPlaneTaskPoller, resolveControlPlaneTaskConfig, } from "./control-plane-tasks.js";
|
|
170
172
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
171
173
|
const repoRoot = path.resolve(__dirname, "..");
|
|
174
|
+
// Agents inherit this: the `bivy` on their PATH (another install, or an older
|
|
175
|
+
// version) hands their commands to this node's own CLI, so the commands the
|
|
176
|
+
// agent note names always exist. Agent shells that rebuild PATH from the
|
|
177
|
+
// user's rc files keep ordinary variables like this one.
|
|
178
|
+
if (!process.env.BIVY_NODE_CLI && fs.existsSync(path.join(repoRoot, "bin", "bivy.mjs")))
|
|
179
|
+
process.env.BIVY_NODE_CLI = path.join(repoRoot, "bin", "bivy.mjs");
|
|
172
180
|
// Asset root holds read-only bundled files (public/, helper scripts); data dir
|
|
173
181
|
// holds writable per-install state (.bivy). Both default to the repo when
|
|
174
182
|
// running from source. Packaged/release builds may override them (BIVY_ASSET_ROOT,
|
|
@@ -395,6 +403,9 @@ const sessionAllowRules = new SessionAllowRules();
|
|
|
395
403
|
// `bivy audit --verify` can prove it — the basis of an attested Receipt (2A).
|
|
396
404
|
const auditKey = loadOrCreateAuditKey(path.join(appDir, "audit"));
|
|
397
405
|
const auditLog = createAuditLog(path.join(appDir, "audit"), { signer: auditKey.signer });
|
|
406
|
+
// Agent sessions get a token for their own routes in their env (BIVY_SESSION_TOKEN).
|
|
407
|
+
const sessionTokens = createSessionTokenCodec();
|
|
408
|
+
setSessionTokenSigner(sessionTokens.sign);
|
|
398
409
|
// Bivy owns the AskUserQuestion → question-card feature at the guardian layer,
|
|
399
410
|
// runtime-agnostically (see src/question.ts). The manager holds every pending
|
|
400
411
|
// clarifying question across sessions; the guardian interceptor below raises
|
|
@@ -9397,7 +9408,14 @@ app.post("/api/auth/bootstrap", sensitiveRateLimiter, (req, res) => {
|
|
|
9397
9408
|
});
|
|
9398
9409
|
// All other /api routes require auth (loopback may bypass, per config and host
|
|
9399
9410
|
// — see loopbackAllowed()/isMultiUserHost() in src/auth.ts).
|
|
9400
|
-
app.use("/api", authMiddleware(identity
|
|
9411
|
+
app.use("/api", authMiddleware(identity, {
|
|
9412
|
+
verify: sessionTokens.verify,
|
|
9413
|
+
claims: isSessionToken,
|
|
9414
|
+
allows: sessionTokenAllows,
|
|
9415
|
+
// What an agent did through Bivy, per session (`bivy audit --session <id>`).
|
|
9416
|
+
used: (sessionId, call) => { if (call.method !== "GET")
|
|
9417
|
+
auditLog.record({ kind: "agent.call", session: sessionId, tool: `${call.method} ${call.path}` }); },
|
|
9418
|
+
}));
|
|
9401
9419
|
// Reload relay.json without forcing the user to restart the whole node. This is
|
|
9402
9420
|
// used by `bivy relay:setup` after it enrolls the node.
|
|
9403
9421
|
app.post("/api/relay/reload", (_req, res) => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
/**
|
|
5
|
+
* Session tokens: a credential each agent session gets in its environment
|
|
6
|
+
* ($BIVY_SESSION_TOKEN, via bivySessionEnv) that reaches only that session's
|
|
7
|
+
* own routes. They let `bivy attach`, `notify`, `ask`, `app` and the MCP tools
|
|
8
|
+
* work where the loopback bypass doesn't apply (multi-user hosts,
|
|
9
|
+
* BIVY_REQUIRE_LOCAL_AUTH) and where the agent can't read the node's data dir to
|
|
10
|
+
* mint a device token (sandboxed agents), without handing it the node.
|
|
11
|
+
*
|
|
12
|
+
* A token is `bst_<session id, base64url>.<HMAC-SHA256>` under a key that lives
|
|
13
|
+
* only in the daemon's memory, so it needs no storage and stops working when
|
|
14
|
+
* the daemon restarts (along with the agent processes that held it).
|
|
15
|
+
*/
|
|
16
|
+
const PREFIX = "bst_";
|
|
17
|
+
export function isSessionToken(token) {
|
|
18
|
+
return typeof token === "string" && token.startsWith(PREFIX);
|
|
19
|
+
}
|
|
20
|
+
export function createSessionTokenCodec(key = randomBytes(32)) {
|
|
21
|
+
const mac = (id) => createHmac("sha256", key).update(`bivy-session-token:${id}`).digest("base64url");
|
|
22
|
+
return {
|
|
23
|
+
sign(sessionId) {
|
|
24
|
+
return `${PREFIX}${Buffer.from(sessionId).toString("base64url")}.${mac(sessionId)}`;
|
|
25
|
+
},
|
|
26
|
+
verify(token) {
|
|
27
|
+
if (!isSessionToken(token))
|
|
28
|
+
return undefined;
|
|
29
|
+
const [encoded, signature] = token.slice(PREFIX.length).split(".");
|
|
30
|
+
if (!encoded || !signature)
|
|
31
|
+
return undefined;
|
|
32
|
+
const sessionId = Buffer.from(encoded, "base64url").toString();
|
|
33
|
+
const expected = Buffer.from(mac(sessionId));
|
|
34
|
+
const given = Buffer.from(signature);
|
|
35
|
+
return sessionId && given.length === expected.length && timingSafeEqual(given, expected) ? sessionId : undefined;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* What a session token may call: its own session's agent-facing routes, and
|
|
41
|
+
* the read-only machine list `bivy delegate machines` needs. `session` says
|
|
42
|
+
* where the request names its session (a path segment or body.sessionId); it
|
|
43
|
+
* must be the token's own. Adding a route is adding a row.
|
|
44
|
+
*/
|
|
45
|
+
export const SESSION_TOKEN_ROUTES = [
|
|
46
|
+
{ method: "GET", path: /^\/api\/session\/([^/]+)\/context$/, session: "path" },
|
|
47
|
+
{ method: "POST", path: /^\/api\/session\/([^/]+)\/(attach|suggest|notify)$/, session: "path" },
|
|
48
|
+
{ method: "POST", path: /^\/api\/session\/([^/]+)\/ask$/, session: "path" },
|
|
49
|
+
{ method: "GET", path: /^\/api\/session\/([^/]+)\/ask\/[^/]+$/, session: "path" },
|
|
50
|
+
{ method: "POST", path: /^\/api\/session\/([^/]+)\/ask\/[^/]+\/wait$/, session: "path" },
|
|
51
|
+
{ method: "POST", path: /^\/api\/session\/([^/]+)\/delegated-runs$/, session: "path" },
|
|
52
|
+
{ method: "GET", path: /^\/api\/session\/([^/]+)\/delegated-runs\/[^/]+$/, session: "path" },
|
|
53
|
+
{ method: "POST", path: /^\/api\/session\/([^/]+)\/delegated-runs\/[^/]+\/wait$/, session: "path" },
|
|
54
|
+
{ method: "POST", path: /^\/api\/apps\/[a-zA-Z]+$/, session: "body" },
|
|
55
|
+
{ method: "POST", path: /^\/api\/session\/fork$/, session: "body" },
|
|
56
|
+
{ method: "GET", path: /^\/api\/machines$/, session: "none" },
|
|
57
|
+
];
|
|
58
|
+
/** Whether a request made with `sessionId`'s token is one it may make. */
|
|
59
|
+
export function sessionTokenAllows(sessionId, req) {
|
|
60
|
+
for (const route of SESSION_TOKEN_ROUTES) {
|
|
61
|
+
if (route.method !== req.method)
|
|
62
|
+
continue;
|
|
63
|
+
const match = route.path.exec(req.path);
|
|
64
|
+
if (!match)
|
|
65
|
+
continue;
|
|
66
|
+
if (route.session === "none")
|
|
67
|
+
return true;
|
|
68
|
+
const named = route.session === "path"
|
|
69
|
+
? decodeURIComponent(match[1] ?? "")
|
|
70
|
+
: req.body?.sessionId;
|
|
71
|
+
return named === sessionId;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
package/package.json
CHANGED