@bivy/bivy 0.19.4-staging.5 → 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 CHANGED
@@ -511,7 +511,19 @@ function nativeAgentAuthDetected(choice) {
511
511
  }
512
512
 
513
513
  function url(config) {
514
- return `http://localhost:${config.port}`;
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
- let token;
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
- let token;
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
- let token;
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}`, {
@@ -2686,11 +2695,10 @@ answer later and 'bivy ask status <id>' checks without waiting.
2686
2695
 
2687
2696
  // One JSON request to the local node for the commands below: starts the node if
2688
2697
  // needed and returns { status, body }, or fails in the shared error shape.
2689
- async function nodeRequest(method, pathname, body, { json = false } = {}) {
2698
+ async function nodeRequest(method, pathname, body, { json = false, sessionId } = {}) {
2690
2699
  const config = loadConfig();
2691
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 });
2692
- let token;
2693
- try { token = await localDeviceToken(config); } catch { token = undefined; }
2701
+ const token = await sessionAuth(config, sessionId);
2694
2702
  const res = await fetch(`${url(config)}${pathname}`, {
2695
2703
  method,
2696
2704
  headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
@@ -2719,7 +2727,7 @@ async function cmdFork(args = []) {
2719
2727
  const modelArg = parsed.one("--model");
2720
2728
  const slash = modelArg?.indexOf("/") ?? -1;
2721
2729
  const model = modelArg ? (slash > 0 ? { provider: modelArg.slice(0, slash), id: modelArg.slice(slash + 1) } : { provider: "", id: modelArg }) : undefined;
2722
- const { status, body } = await nodeRequest("POST", "/api/session/fork", { sessionId, ...(model ? { model } : {}) }, { json });
2730
+ const { status, body } = await nodeRequest("POST", "/api/session/fork", { sessionId, ...(model ? { model } : {}) }, { json, sessionId });
2723
2731
  if (status === 404) return fail({ code: "node_outdated", message: "This node is older than 'bivy fork'.", next: "bivy update", exit: EXIT.unavailable });
2724
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 });
2725
2733
  if (json) { console.log(JSON.stringify({ sessionId: body.sessionId, fidelity: body.fidelity, ...(body.missing?.length ? { missing: body.missing } : {}) })); return; }
@@ -2854,8 +2862,7 @@ async function cmdDelegate(args = []) {
2854
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; }
2855
2863
  const config = loadConfig();
2856
2864
  if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
2857
- let token;
2858
- try { token = await localDeviceToken(config); } catch { token = undefined; }
2865
+ const token = await sessionAuth(config, sessionId);
2859
2866
  const base = `${url(config)}/api/session/${encodeURIComponent(sessionId)}/delegated-runs`;
2860
2867
  const call = async (path, body) => {
2861
2868
  const res = await fetch(`${base}${path}`, {
@@ -3136,9 +3143,9 @@ async function appMenu(rest, { json = false } = {}) {
3136
3143
  async function appRequest(action, body, { print = true } = {}) {
3137
3144
  const config = loadConfig();
3138
3145
  if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
3139
- const token = await localDeviceToken(config);
3146
+ const token = await sessionAuth(config, body.sessionId);
3140
3147
  const response = await fetch(`${url(config)}/api/apps/${action}`, {
3141
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body),
3148
+ method: "POST", headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "content-type": "application/json" }, body: JSON.stringify(body),
3142
3149
  });
3143
3150
  const result = await response.json();
3144
3151
  if (!response.ok || result.error) throw new Error(result.error || `App command failed (${response.status}).`);
@@ -5821,6 +5828,22 @@ function readSelfVersion() {
5821
5828
  }
5822
5829
  }
5823
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
+
5824
5847
  function printHelp() {
5825
5848
  const details = { run: `Agents: ${[...AGENT_INTEGRATIONS.keys()].join(" | ")}, or -- <command>` };
5826
5849
  console.log(`\n${renderHelp({ paint: c.cyan, bold: c.bold, details })}\n`);
@@ -5898,13 +5921,27 @@ function cmdGuide(args = []) {
5898
5921
  console.log(guide.markdown.trimEnd());
5899
5922
  }
5900
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
+
5901
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);
5902
5938
  await hydrateCanonicalConfig();
5903
5939
  const argv = process.argv.slice(2);
5904
5940
  const [command, ...args] = argv;
5905
5941
  if (command === undefined) { printHelp(); return; }
5906
5942
  const entry = resolveCommand(command);
5907
5943
  if (!entry) {
5944
+ recordAgentMiss(command);
5908
5945
  const close = suggestCommands(command);
5909
5946
  cliError({
5910
5947
  code: "unknown_command",
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
- const env = { BIVY_SESSION_ID: opts.sessionId };
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
- function defaultRunBivy(sessionId) {
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 } : {}) });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.19.4-staging.5",
3
+ "version": "0.19.4-staging.6",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",