@bivy/bivy 0.10.1-staging.347 → 0.10.1-staging.348

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/acp-shim.mjs CHANGED
@@ -49,6 +49,20 @@ if (!agentCmd) {
49
49
  process.exit(2);
50
50
  }
51
51
 
52
+ // MCP servers to advertise to the ACP agent on session/new and session/load.
53
+ // Bivy passes its configured servers as a JSON array of ACP mcpServer objects
54
+ // (see acpMcpServersFromConfig in src/runtime/index.ts). Forwarding them lets an
55
+ // ACP agent reach the user's MCP tools — previously hardcoded to [] so ACP
56
+ // agents were cut off from MCP entirely. Defaults to none; a malformed value is
57
+ // ignored rather than crashing the shim.
58
+ let mcpServers = [];
59
+ if (process.env.BIVY_ACP_MCP_SERVERS) {
60
+ try {
61
+ const parsed = JSON.parse(process.env.BIVY_ACP_MCP_SERVERS);
62
+ if (Array.isArray(parsed)) mcpServers = parsed;
63
+ } catch { /* malformed → none */ }
64
+ }
65
+
52
66
  // --- bivy-agent-protocol output (our stdout) --------------------------------
53
67
  function bivy(obj) {
54
68
  process.stdout.write(`${JSON.stringify(obj)}\n`);
@@ -427,7 +441,7 @@ async function onBivyCommand(msg) {
427
441
  case "session.create": {
428
442
  await ensureInitialized();
429
443
  cwd = String(msg.cwd || msg.workspace || cwd);
430
- const res = await agentRequest("session/new", { cwd, mcpServers: [] });
444
+ const res = await agentRequest("session/new", { cwd, mcpServers });
431
445
  sessionId = res?.sessionId ?? res?.session?.id ?? null;
432
446
  publishModels(res);
433
447
  await applyPendingModel();
@@ -445,13 +459,13 @@ async function onBivyCommand(msg) {
445
459
  // hanging the reopen with no watchdog to recover it (resume isn't a
446
460
  // "working" turn). On timeout/failure we fall back to a fresh session so
447
461
  // the chat still opens instead of spinning on "Fetching transcript…".
448
- const res = await agentRequest("session/load", { sessionId: ref, cwd, mcpServers: [] }, { timeoutMs: 30_000 });
462
+ const res = await agentRequest("session/load", { sessionId: ref, cwd, mcpServers }, { timeoutMs: 30_000 });
449
463
  sessionId = res?.sessionId ?? ref;
450
464
  publishModels(res);
451
465
  } catch {
452
466
  // Agent doesn't support session/load (or it timed out) — start fresh so
453
467
  // the chat still opens.
454
- const res = await agentRequest("session/new", { cwd, mcpServers: [] });
468
+ const res = await agentRequest("session/new", { cwd, mcpServers });
455
469
  sessionId = res?.sessionId ?? null;
456
470
  publishModels(res);
457
471
  }
package/bin/bivy.mjs CHANGED
@@ -30,7 +30,7 @@ import path from "node:path";
30
30
  import readline from "node:readline";
31
31
  import { StringDecoder } from "node:string_decoder";
32
32
  import { spawn, spawnSync } from "node:child_process";
33
- import { randomBytes, createCipheriv, createDecipheriv, randomUUID } from "node:crypto";
33
+ import { randomBytes, createCipheriv, createDecipheriv, randomUUID, createHash, verify as cryptoVerify } from "node:crypto";
34
34
  import { fileURLToPath, pathToFileURL } from "node:url";
35
35
  import vm from "node:vm";
36
36
  import { selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
@@ -4310,10 +4310,11 @@ async function runUpdate(args = []) {
4310
4310
  // be running. Decisions + metadata only; never tool payloads.
4311
4311
  function cmdAudit(args = []) {
4312
4312
  if (args.includes("-h") || args.includes("--help")) {
4313
- console.log("Usage: bivy audit [--session <id>] [--kind <kind>] [--limit <n>] [--json]\n\nShow the node's governance audit trail (tool-call decisions, …).");
4313
+ console.log("Usage: bivy audit [--session <id>] [--kind <kind>] [--limit <n>] [--json] [--verify]\n\nShow the node's governance audit trail (tool-call decisions, …).\n --verify recompute the tamper-evidence hash chain and check signatures.");
4314
4314
  return;
4315
4315
  }
4316
4316
  const file = path.join(appDir, "audit", "audit.jsonl");
4317
+ if (args.includes("--verify")) { cmdAuditVerify(file, args.includes("--json")); return; }
4317
4318
  let raw;
4318
4319
  try { raw = fs.readFileSync(file, "utf8"); }
4319
4320
  catch { console.log("No audit events recorded yet."); return; }
@@ -4336,6 +4337,62 @@ function cmdAudit(args = []) {
4336
4337
  }
4337
4338
  }
4338
4339
 
4340
+ // Verify the audit trail's tamper-evidence chain (moat #1, phase 2A). Mirrors
4341
+ // src/audit/integrity.ts (canonicalize / chainHash / verifyAuditChain) so the
4342
+ // check runs with no daemon and no TS toolchain — integrity.ts stays the source
4343
+ // of truth; keep the two in sync.
4344
+ function auditCanonicalize(value) {
4345
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
4346
+ if (Array.isArray(value)) return `[${value.map((v) => auditCanonicalize(v)).join(",")}]`;
4347
+ const keys = Object.keys(value).sort();
4348
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${auditCanonicalize(value[k])}`).join(",")}}`;
4349
+ }
4350
+ function auditChainHash(prev, entry) {
4351
+ return createHash("sha256").update(`${prev}\n${auditCanonicalize(entry)}`).digest("hex");
4352
+ }
4353
+ function cmdAuditVerify(file, asJson) {
4354
+ let raw;
4355
+ try { raw = fs.readFileSync(file, "utf8"); }
4356
+ catch { console.log("No audit events recorded yet."); return; }
4357
+ let pub;
4358
+ try { pub = fs.readFileSync(path.join(path.dirname(file), "audit-key.pub.pem"), "utf8"); } catch { pub = undefined; }
4359
+ const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
4360
+ let prev = "", expectedSeq, started = false;
4361
+ let unprotectedPrefix = 0, verified = 0, signaturesVerified = 0;
4362
+ let brokenAt, reason;
4363
+ for (let i = 0; i < lines.length && brokenAt === undefined; i++) {
4364
+ let parsed;
4365
+ try { parsed = JSON.parse(lines[i]); } catch { brokenAt = i + 1; reason = "unparseable line"; break; }
4366
+ const { prev: linePrev, hash, sig, ...entry } = parsed;
4367
+ if (hash === undefined) {
4368
+ if (started) { brokenAt = i + 1; reason = "unchained line inside chained region"; break; }
4369
+ unprotectedPrefix++; continue;
4370
+ }
4371
+ started = true;
4372
+ if (linePrev !== prev) { brokenAt = i + 1; reason = "prev-hash linkage broken (reordered or deleted entry)"; break; }
4373
+ if (auditChainHash(prev, entry) !== hash) { brokenAt = i + 1; reason = "hash mismatch (entry was edited)"; break; }
4374
+ if (expectedSeq !== undefined && entry.seq !== expectedSeq) { brokenAt = i + 1; reason = "seq gap (entry inserted or removed)"; break; }
4375
+ expectedSeq = (entry.seq ?? 0) + 1;
4376
+ if (sig !== undefined && pub) {
4377
+ const good = cryptoVerify(null, Buffer.from(hash, "hex"), pub, Buffer.from(sig, "base64"));
4378
+ if (!good) { brokenAt = i + 1; reason = "signature invalid (forged entry)"; break; }
4379
+ signaturesVerified++;
4380
+ }
4381
+ prev = hash; verified++;
4382
+ }
4383
+ const ok = brokenAt === undefined;
4384
+ const result = { ok, total: lines.length, unprotectedPrefix, verified, signaturesVerified, ...(ok ? {} : { brokenAt, reason }) };
4385
+ if (asJson) { console.log(JSON.stringify(result, null, 2)); process.exitCode = ok ? 0 : 1; return; }
4386
+ if (ok) {
4387
+ const noun = verified === 1 ? "entry" : "entries";
4388
+ console.log(c.green(`✓ audit trail intact — ${verified} chained ${noun} verified${pub ? `, ${signaturesVerified} signed` : " (unsigned: no public key found)"}.`));
4389
+ if (unprotectedPrefix) console.log(c.dim(` (${unprotectedPrefix} legacy pre-chain line(s) at the start are unprotected.)`));
4390
+ } else {
4391
+ console.log(c.red(`✗ audit trail FAILED verification at line ${brokenAt}: ${reason}`));
4392
+ process.exitCode = 1;
4393
+ }
4394
+ }
4395
+
4339
4396
  // Print the update log (default: the tail; `-f`/`--follow` streams new output).
4340
4397
  // After a `bivy update` detaches and this terminal reconnects, this is how you
4341
4398
  // confirm the background update succeeded.
package/dist/approval.js CHANGED
@@ -22,6 +22,12 @@ export class ApprovalManager {
22
22
  list() {
23
23
  return [...this.history, ...[...this.pending.values()].map((p) => p.request)];
24
24
  }
25
+ /** Unanswered approvals for a session. Carried into a fork bundle so a move
26
+ * DISCLOSES them (they belong to the source runtime's turn and can't replay
27
+ * across a fork) rather than silently dropping the pending decision. */
28
+ pendingFor(sessionId) {
29
+ return [...this.pending.values()].map((p) => p.request).filter((r) => r.sessionId === sessionId);
30
+ }
25
31
  async request(input) {
26
32
  const request = {
27
33
  id: randomUUID(),
@@ -16,10 +16,17 @@
16
16
  //
17
17
  // Pure fs leaf: imports nothing from the kernel; the daemon constructs one
18
18
  // AuditLog and hands `record` to the guardian / egress / approval seams.
19
+ //
20
+ // TAMPER-EVIDENCE (phase 2A): when built with a signer, every appended line is
21
+ // hash-chained and signed (see ./integrity.ts), so truncation, reordering, and
22
+ // edits become detectable via `verifyAuditChain`. The chain is stamped as extra
23
+ // fields (`seq`/`prev`/`hash`/`sig`), which readers below ignore, so it is fully
24
+ // backward-compatible with pre-chain audit files.
19
25
  import fs from "node:fs";
20
26
  import path from "node:path";
27
+ import { chainEntry, readChainState } from "./integrity.js";
21
28
  /** Build the node's audit log under `<dir>/audit.jsonl` (dir created lazily). */
22
- export function createAuditLog(dir) {
29
+ export function createAuditLog(dir, opts = {}) {
23
30
  const file = path.join(dir, "audit.jsonl");
24
31
  let ensured = false;
25
32
  let successfulWrites = 0;
@@ -27,6 +34,9 @@ export function createAuditLog(dir) {
27
34
  let cachedSignature = "";
28
35
  let cachedStorage = "missing";
29
36
  let cachedCorruptLines = 0;
37
+ // Resume the hash chain from the file's tail so a daemon restart continues it
38
+ // rather than forking a fresh chain (which would read as a truncation).
39
+ const chain = readChainState(file);
30
40
  return {
31
41
  file,
32
42
  record(event) {
@@ -35,7 +45,8 @@ export function createAuditLog(dir) {
35
45
  fs.mkdirSync(dir, { recursive: true });
36
46
  ensured = true;
37
47
  }
38
- fs.appendFileSync(file, `${JSON.stringify({ ts: Date.now(), ...event })}\n`);
48
+ const line = chainEntry(chain, { ts: Date.now(), ...event }, opts.signer);
49
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`);
39
50
  successfulWrites += 1;
40
51
  cachedSignature = "";
41
52
  }
@@ -0,0 +1,205 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Audit-trail INTEGRITY (governance moat, phase 2A). Turns the append-only
5
+ // JSONL audit stream from "append-only by convention" into a tamper-EVIDENT
6
+ // record, so a receipt can move from "observation report" toward "attestation".
7
+ //
8
+ // Two layers, each independently useful:
9
+ // 1. Hash chain (always on). Every entry carries `seq` (monotonic) and `prev`
10
+ // (the previous entry's hash), and its own `hash` binds them together:
11
+ // hash = sha256(prev + "\n" + canonical(entry-without-prev/hash/sig)).
12
+ // This makes truncation, reordering, and in-place edits DETECTABLE by
13
+ // recomputation — no key required. On its own it does NOT stop an attacker
14
+ // with filesystem access who simply recomputes the whole chain.
15
+ // 2. Signature (when a signer is configured). Each entry's hash is signed with
16
+ // the node's Ed25519 audit key. Recomputing the chain now requires the
17
+ // private key, so a privileged tamper is EVIDENT to anyone holding the
18
+ // public key. This is the piece that makes the chain unforgeable.
19
+ //
20
+ // This module stays a pure leaf: it imports only node:crypto/fs, exactly like
21
+ // audit/index.ts, so the audit writer can chain without pulling in the kernel.
22
+ import crypto from "node:crypto";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ /** Deterministic JSON: object keys sorted recursively, so the hash of an entry
26
+ * is independent of key insertion order (the verifier re-serializes from a
27
+ * parsed line and must land on the exact same bytes the writer hashed). */
28
+ export function canonicalize(value) {
29
+ if (value === null || typeof value !== "object")
30
+ return JSON.stringify(value) ?? "null";
31
+ if (Array.isArray(value))
32
+ return `[${value.map((v) => canonicalize(v)).join(",")}]`;
33
+ const obj = value;
34
+ const keys = Object.keys(obj).sort();
35
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(",")}}`;
36
+ }
37
+ /** The three chain fields the writer stamps onto every entry; they are excluded
38
+ * from the hashed content (the hash is OVER the event, and BINDS `prev`). */
39
+ export const CHAIN_FIELDS = ["prev", "hash", "sig"];
40
+ /** Compute an entry's chain hash. `entry` is the full event object WITHOUT the
41
+ * chain fields (ts + seq + the governance fields). */
42
+ export function chainHash(prev, entry) {
43
+ return crypto.createHash("sha256").update(`${prev}\n${canonicalize(entry)}`).digest("hex");
44
+ }
45
+ /** Split a persisted line's parsed object into (event, chain). */
46
+ function splitChain(parsed) {
47
+ const entry = {};
48
+ let prev;
49
+ let hash;
50
+ let sig;
51
+ for (const [k, v] of Object.entries(parsed)) {
52
+ if (k === "prev")
53
+ prev = typeof v === "string" ? v : undefined;
54
+ else if (k === "hash")
55
+ hash = typeof v === "string" ? v : undefined;
56
+ else if (k === "sig")
57
+ sig = typeof v === "string" ? v : undefined;
58
+ else
59
+ entry[k] = v;
60
+ }
61
+ return { entry, prev, hash, sig };
62
+ }
63
+ /** Recover the chain head from an existing audit file so a daemon restart
64
+ * CONTINUES the chain instead of forking a fresh one (which would look like a
65
+ * truncation to the verifier). Best-effort: an empty/absent/legacy file yields
66
+ * a genesis state (`seq: 0, prev: ""`). */
67
+ export function readChainState(file) {
68
+ let raw;
69
+ try {
70
+ raw = fs.readFileSync(file, "utf8");
71
+ }
72
+ catch {
73
+ return { seq: 0, prev: "" };
74
+ }
75
+ const lines = raw.split("\n").filter((l) => l.trim());
76
+ if (!lines.length)
77
+ return { seq: 0, prev: "" };
78
+ const last = lines[lines.length - 1];
79
+ try {
80
+ const parsed = JSON.parse(last);
81
+ const seq = typeof parsed.seq === "number" ? parsed.seq : lines.length - 1;
82
+ const hash = typeof parsed.hash === "string" ? parsed.hash : "";
83
+ return { seq: seq + 1, prev: hash };
84
+ }
85
+ catch {
86
+ // Corrupt tail: continue by position, unchained (verifier flags the gap).
87
+ return { seq: lines.length, prev: "" };
88
+ }
89
+ }
90
+ /** Stamp `event` with chain (and optional signature) fields, advancing `state`
91
+ * in place. Returns the object to serialize as one JSONL line. */
92
+ export function chainEntry(state, event, signer) {
93
+ // `keyId` is bound INTO the hashed entry (not appended after) so it is covered
94
+ // by the chain — a key substitution is then a hash mismatch. Only prev/hash/sig
95
+ // stay outside the hash (see CHAIN_FIELDS / the verifier's splitChain).
96
+ const entry = { ...event, seq: state.seq, ...(signer ? { keyId: signer.keyId } : {}) };
97
+ const hash = chainHash(state.prev, entry);
98
+ const line = { ...entry, prev: state.prev, hash };
99
+ if (signer)
100
+ line.sig = signer.sign(hash);
101
+ state.prev = hash;
102
+ state.seq += 1;
103
+ return line;
104
+ }
105
+ /** Recompute the chain over a persisted audit file and report the first break.
106
+ * Detects: edited fields (hash mismatch), reordering / deleted middle lines
107
+ * (prev linkage break), truncation from the end (only if an external anchor is
108
+ * known — see note), and forged entries when signing is in use (bad `sig`). */
109
+ export function verifyAuditChain(file, opts = {}) {
110
+ let raw;
111
+ try {
112
+ raw = fs.readFileSync(file, "utf8");
113
+ }
114
+ catch {
115
+ return { ok: true, total: 0, unprotectedPrefix: 0, verified: 0, signaturesVerified: 0 };
116
+ }
117
+ const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
118
+ let unprotectedPrefix = 0;
119
+ let verified = 0;
120
+ let signaturesVerified = 0;
121
+ let prev = "";
122
+ let expectedSeq;
123
+ let started = false;
124
+ for (let i = 0; i < lines.length; i++) {
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(lines[i]);
128
+ }
129
+ catch {
130
+ return { ok: false, total: lines.length, unprotectedPrefix, verified, signaturesVerified, brokenAt: i + 1, reason: "unparseable line" };
131
+ }
132
+ const { entry, prev: linePrev, hash, sig } = splitChain(parsed);
133
+ if (hash === undefined) {
134
+ if (started) {
135
+ // A chained region can't sprout an unchained hole in the middle.
136
+ return { ok: false, total: lines.length, unprotectedPrefix, verified, signaturesVerified, brokenAt: i + 1, reason: "unchained line inside chained region" };
137
+ }
138
+ unprotectedPrefix += 1;
139
+ continue;
140
+ }
141
+ started = true;
142
+ if (linePrev !== prev) {
143
+ return { ok: false, total: lines.length, unprotectedPrefix, verified, signaturesVerified, brokenAt: i + 1, reason: "prev-hash linkage broken (reordered or deleted entry)" };
144
+ }
145
+ const recomputed = chainHash(prev, entry);
146
+ if (recomputed !== hash) {
147
+ return { ok: false, total: lines.length, unprotectedPrefix, verified, signaturesVerified, brokenAt: i + 1, reason: "hash mismatch (entry was edited)" };
148
+ }
149
+ const seq = typeof entry.seq === "number" ? entry.seq : undefined;
150
+ if (expectedSeq !== undefined && seq !== expectedSeq) {
151
+ return { ok: false, total: lines.length, unprotectedPrefix, verified, signaturesVerified, brokenAt: i + 1, reason: "seq gap (entry inserted or removed)" };
152
+ }
153
+ expectedSeq = (seq ?? 0) + 1;
154
+ if (sig !== undefined) {
155
+ const keyId = typeof entry.keyId === "string" ? entry.keyId : undefined;
156
+ const pub = keyId ? opts.publicKeys?.[keyId] : undefined;
157
+ if (pub) {
158
+ const good = crypto.verify(null, Buffer.from(hash, "hex"), pub, Buffer.from(sig, "base64"));
159
+ if (!good) {
160
+ return { ok: false, total: lines.length, unprotectedPrefix, verified, signaturesVerified, brokenAt: i + 1, reason: "signature invalid (forged entry)" };
161
+ }
162
+ signaturesVerified += 1;
163
+ }
164
+ }
165
+ prev = hash;
166
+ verified += 1;
167
+ }
168
+ return { ok: true, total: lines.length, unprotectedPrefix, verified, signaturesVerified };
169
+ }
170
+ function fingerprint(publicKeyPem) {
171
+ return crypto.createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 16);
172
+ }
173
+ /** Load (or mint, first run) the node's Ed25519 audit key under `dir`. The
174
+ * private key is written 0600; the public key + id are returned for anchoring
175
+ * in receipts and for the verifier's `publicKeys` map. */
176
+ export function loadOrCreateAuditKey(dir) {
177
+ const privPath = path.join(dir, "audit-key.pem");
178
+ const pubPath = path.join(dir, "audit-key.pub.pem");
179
+ let privateKeyPem;
180
+ let publicKeyPem;
181
+ try {
182
+ privateKeyPem = fs.readFileSync(privPath, "utf8");
183
+ publicKeyPem = fs.readFileSync(pubPath, "utf8");
184
+ }
185
+ catch {
186
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
187
+ privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
188
+ publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ fs.writeFileSync(privPath, privateKeyPem, { mode: 0o600 });
191
+ fs.writeFileSync(pubPath, publicKeyPem, { mode: 0o644 });
192
+ }
193
+ const keyId = fingerprint(publicKeyPem);
194
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
195
+ return {
196
+ keyId,
197
+ publicKeyPem,
198
+ signer: {
199
+ keyId,
200
+ sign(hashHex) {
201
+ return crypto.sign(null, Buffer.from(hashHex, "hex"), privateKey).toString("base64");
202
+ },
203
+ },
204
+ };
205
+ }
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Node EVIDENCE attestation (governance moat, phase 2A). A Receipt is projected
5
+ // downstream (web/control-plane) from the payload-free governance evidence the
6
+ // NODE authored (`receiptEvidenceForRun`). This signs that node-authored evidence
7
+ // with the node's audit key — the same key that signs the audit trail — and
8
+ // anchors it to the audit chain head it summarises. Verification proves the node
9
+ // observed exactly these facts and they were not altered in transit, turning the
10
+ // downstream receipt from "observation report" into an attested claim.
11
+ //
12
+ // Deliberately node-only and @bivy/core-free (the daemon never imports the
13
+ // browser-shared core): it signs a canonical serialization of the evidence plus
14
+ // a small header, so any verifier — including a future WebCrypto one in the PWA —
15
+ // can re-derive the exact signed bytes.
16
+ import crypto from "node:crypto";
17
+ import { canonicalize } from "./integrity.js";
18
+ /** The exact bytes that get signed: a canonical serialization of the header
19
+ * fields plus the evidence, so sign + verify never drift. */
20
+ function digestOf(header, evidence) {
21
+ const content = {
22
+ createdAt: header.createdAt,
23
+ ...(header.runId ? { runId: header.runId } : {}),
24
+ ...(header.machineId ? { machineId: header.machineId } : {}),
25
+ ...(header.auditChainHead ? { auditChainHead: header.auditChainHead } : {}),
26
+ evidence,
27
+ };
28
+ return crypto.createHash("sha256").update(canonicalize(content)).digest("hex");
29
+ }
30
+ /** Sign node-authored governance evidence. */
31
+ export function attestEvidence(evidence, signer, header) {
32
+ const hash = digestOf(header, evidence);
33
+ return {
34
+ evidence,
35
+ attestation: {
36
+ alg: "sha256-ed25519",
37
+ keyId: signer.keyId,
38
+ hash,
39
+ sig: signer.sign(hash),
40
+ createdAt: header.createdAt,
41
+ ...(header.runId ? { runId: header.runId } : {}),
42
+ ...(header.machineId ? { machineId: header.machineId } : {}),
43
+ ...(header.auditChainHead ? { auditChainHead: header.auditChainHead } : {}),
44
+ },
45
+ };
46
+ }
47
+ /** Verify attested evidence: re-derive the digest from the evidence + header and
48
+ * check the signature against the named public key. Detects any edit to the
49
+ * evidence or header (digest mismatch) and a forged/unknown-key signature. */
50
+ export function verifyEvidenceAttestation(attested, publicKeys) {
51
+ const { evidence, attestation } = attested;
52
+ if (attestation.alg !== "sha256-ed25519")
53
+ return { ok: false, reason: `unsupported alg ${attestation.alg}` };
54
+ const pub = publicKeys[attestation.keyId];
55
+ if (!pub)
56
+ return { ok: false, reason: `no public key for keyId ${attestation.keyId}` };
57
+ const recomputed = digestOf(attestation, evidence);
58
+ if (recomputed !== attestation.hash)
59
+ return { ok: false, reason: "digest mismatch (evidence or header was edited)" };
60
+ const good = crypto.verify(null, Buffer.from(attestation.hash, "hex"), pub, Buffer.from(attestation.sig, "base64"));
61
+ return good ? { ok: true } : { ok: false, reason: "signature invalid" };
62
+ }
@@ -53,7 +53,7 @@ export function receiptEvidenceForRun(events, storageReadable, context) {
53
53
  profile: context.profile,
54
54
  controller: context.controller,
55
55
  ...(context.agentVersion ? { agentVersion: context.agentVersion } : {}),
56
- modelVersionStatus: "unknown",
56
+ modelVersionStatus: context.modelVersion ? "available" : "unknown",
57
57
  },
58
58
  protection: {
59
59
  effective: {
@@ -215,6 +215,29 @@ export async function resolveAdoptBaseRef(repoDir, branch) {
215
215
  return resolveDefaultBaseRef(repoDir);
216
216
  }
217
217
  }
218
+ /**
219
+ * Whether the source branch is actually present on the remote this node cloned
220
+ * from — i.e. whether a cross-node ADOPT can carry the source's committed work.
221
+ * When false, adopting would silently base off the destination's DEFAULT branch
222
+ * and drop every source commit (see resolveAdoptBaseRef's fallback), so fork
223
+ * stand-up uses this to refuse rather than lose work. Fetches first so a branch
224
+ * pushed after the clone is still seen.
225
+ */
226
+ export async function originBranchPresent(repoDir, branch) {
227
+ // A transient fetch failure shouldn't crash the fork; fall back to whatever
228
+ // remote-tracking refs the clone already has.
229
+ try {
230
+ await fetchOrigin(repoDir);
231
+ }
232
+ catch { /* offline / no remote — check local refs */ }
233
+ try {
234
+ await exec("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd: repoDir });
235
+ return true;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
218
241
  /**
219
242
  * Resolve the best base for a same-node fork without assuming the source branch
220
243
  * ref still exists. Prefer the live source checkout because it preserves its
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Translate Bivy's MCP config (a name->spec MAP, the shape Claude/Gemini/generic
5
+ // hosts use) into the ARRAY of ACP mcpServer objects that ACP's session/new and
6
+ // session/load expect (phase 3A). The ACP shim (bin/acp-shim.mjs) reads the
7
+ // serialized result from BIVY_ACP_MCP_SERVERS and forwards it — previously it
8
+ // hardcoded `[]`, cutting ACP agents off from MCP (including Bivy's own tools
9
+ // server). Pure + unit-tested so the mapping can't silently regress.
10
+ function envToAcp(env) {
11
+ if (!env)
12
+ return undefined;
13
+ const entries = Object.entries(env).map(([name, value]) => ({ name, value: String(value) }));
14
+ return entries.length ? entries : undefined;
15
+ }
16
+ /** Convert one Bivy server spec to ACP shape. Remote (url) servers pass through
17
+ * as an http/sse entry; stdio servers as command/args/env. Returns null for a
18
+ * spec that is neither (nothing runnable to advertise). */
19
+ export function acpMcpServer(name, spec) {
20
+ if (spec.url) {
21
+ return { name, url: spec.url, type: typeof spec.type === "string" ? spec.type : "http" };
22
+ }
23
+ if (spec.command) {
24
+ const env = envToAcp(spec.env);
25
+ return { name, command: spec.command, args: spec.args ?? [], ...(env ? { env } : {}) };
26
+ }
27
+ return null;
28
+ }
29
+ /** Translate a full Bivy MCP config into the ACP server array. */
30
+ export function acpMcpServersFromConfig(config) {
31
+ const servers = config?.mcpServers ?? {};
32
+ const out = [];
33
+ for (const [name, spec] of Object.entries(servers)) {
34
+ const mapped = acpMcpServer(name, spec);
35
+ if (mapped)
36
+ out.push(mapped);
37
+ }
38
+ return out;
39
+ }
40
+ /** Serialize a config to the BIVY_ACP_MCP_SERVERS env value, or undefined when
41
+ * there is nothing to advertise (so the shim keeps its empty default). */
42
+ export function serializeAcpMcpEnv(config) {
43
+ const servers = acpMcpServersFromConfig(config);
44
+ return servers.length ? JSON.stringify(servers) : undefined;
45
+ }
@@ -48,6 +48,7 @@ import { grokCredentialPreflight } from "./grok-preflight.js";
48
48
  import { ensureGrokAuth } from "./grok-auth.js";
49
49
  import { parserFactoryFor } from "./cli-parsers.js";
50
50
  import { sandboxTier, sandboxArgsFor } from "../harness/sandbox.js";
51
+ import { serializeAcpMcpEnv } from "./acp-mcp.js";
51
52
  import { ProtocolRuntime, protocolRuntimeFromEnv, protocolCommandsFromEnv } from "./protocol.js";
52
53
  import { codexSlashCommands, opencodeSlashCommands } from "./slash-commands.js";
53
54
  import { withExactCapabilitySurface } from "./types.js";
@@ -646,6 +647,10 @@ function acpShimPath() {
646
647
  function acpRuntimeOptions(opts) {
647
648
  const slashBehavior = opts.behaviors?.slashCommands;
648
649
  const slashCommands = slashBehavior ? SLASH_COMMAND_BEHAVIORS[slashBehavior]() : undefined;
650
+ // Forward Bivy's configured MCP servers to the ACP agent (3A): the shim reads
651
+ // BIVY_ACP_MCP_SERVERS and advertises them on session/new — previously it sent
652
+ // [], so ACP agents couldn't reach MCP (including Bivy's own tools server).
653
+ const acpMcpEnv = serializeAcpMcpEnv(opts.mcpConfig);
649
654
  return {
650
655
  id: opts.id,
651
656
  displayName: opts.displayName,
@@ -655,6 +660,7 @@ function acpRuntimeOptions(opts) {
655
660
  // the FIRST session (before the shim's hello lands); the hello confirms them.
656
661
  capabilities: { toolInterception: true, resume: true },
657
662
  resumable: true,
663
+ ...(acpMcpEnv ? { env: { BIVY_ACP_MCP_SERVERS: acpMcpEnv } } : {}),
658
664
  // An ACP-promoted opencode still surfaces/expands its on-disk commands (the
659
665
  // ACP handshake doesn't carry them); a bare ACP agent has none.
660
666
  ...(slashCommands ? { slashCommands } : {}),
package/dist/server.js CHANGED
@@ -16,6 +16,8 @@ import { SessionRerouteController } from "./policy/session-reroute.js";
16
16
  import { activeRulesetFor } from "./runtime/ruleset-store.js";
17
17
  import { createRulesetController } from "./controllers/rulesets.js";
18
18
  import { createAuditLog, readAuditEvents } from "./audit/index.js";
19
+ import { loadOrCreateAuditKey, readChainState } from "./audit/integrity.js";
20
+ import { attestEvidence } from "./audit/receipt-attest.js";
19
21
  import { receiptEvidenceForRun } from "./audit/receipt-evidence.js";
20
22
  import { createWorkspaceController } from "./controllers/workspaces.js";
21
23
  import { createModelController } from "./controllers/models.js";
@@ -83,7 +85,7 @@ import { checkDiskAdmission } from "./harness/disk-admission.js";
83
85
  import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
84
86
  import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
85
87
  import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
86
- import { parseRepo, isGitHubSlugPart, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, ghCliInstalled, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, resolveForkBaseRef, fetchOrigin } from "./repo-workspace.js";
88
+ import { parseRepo, isGitHubSlugPart, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, ghCliInstalled, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, resolveForkBaseRef, originBranchPresent, fetchOrigin } from "./repo-workspace.js";
87
89
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
88
90
  import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssueOnce, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
89
91
  import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
@@ -340,7 +342,11 @@ const approvals = new ApprovalManager();
340
342
  // governance events Bivy already intercepts — tool-call decisions today,
341
343
  // network/approval events next — attributed per session + agent, queryable via
342
344
  // `bivy audit`. Distinct from the per-session transcript (session/event-log.ts).
343
- const auditLog = createAuditLog(path.join(appDir, "audit"));
345
+ // Every entry is hash-chained and signed with the node's Ed25519 audit key
346
+ // (loaded/minted under <appDir>/audit) so the trail is tamper-evident and
347
+ // `bivy audit --verify` can prove it — the basis of an attested Receipt (2A).
348
+ const auditKey = loadOrCreateAuditKey(path.join(appDir, "audit"));
349
+ const auditLog = createAuditLog(path.join(appDir, "audit"), { signer: auditKey.signer });
344
350
  // Bivy owns the AskUserQuestion → question-card feature at the guardian layer,
345
351
  // runtime-agnostically (see src/question.ts). The manager holds every pending
346
352
  // clarifying question across sessions; the guardian interceptor below raises
@@ -3072,7 +3078,7 @@ const RELAY_COMMANDS = {
3072
3078
  // When the client has already picked a target agent, pass it so the
3073
3079
  // bundle omits the native payload for a cross-runtime fork (it could
3074
3080
  // never be replayed there — see buildForkBundle). Unset => keep it.
3075
- const bundle = buildForkBundle({ runtime: getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: agentFrom(msg), liveMessages: rec.session.getMessages() });
3081
+ const bundle = buildForkBundle({ runtime: getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: agentFrom(msg), liveMessages: rec.session.getMessages(), state: forkInFlightState(rec) });
3076
3082
  relay?.sendEvent({ type: "session.fork.bundle", requestId, bundle });
3077
3083
  }
3078
3084
  catch (error) {
@@ -3148,7 +3154,7 @@ const RELAY_COMMANDS = {
3148
3154
  catch { /* best effort */ }
3149
3155
  }
3150
3156
  // Same runtime → the bundle carries the native payload → full fidelity.
3151
- const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages() });
3157
+ const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages(), state: forkInFlightState(rec) });
3152
3158
  // Cut a fresh fork branch (the source still holds its own); skip prereq
3153
3159
  // detection (same node + same runtime ⇒ agent and repo are present).
3154
3160
  const outcome = await forkStandUp.standUpFork({
@@ -4369,6 +4375,29 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4369
4375
  : stage === "no_changes" ? "Run completed with no file changes."
4370
4376
  : stage === "checks_failed" ? "Deterministic validation checks failed."
4371
4377
  : "Execution failed. Detailed diagnostics remain on the node.";
4378
+ // On a terminal event, attest the node-authored governance evidence with
4379
+ // the audit key and anchor it to the current audit chain head — so the
4380
+ // downstream Receipt carries a verifiable node signature (2A), not just an
4381
+ // unsigned projection. Emitted alongside the raw evidence (non-breaking).
4382
+ const receiptEvidence = terminal
4383
+ ? receiptEvidenceForRun(auditEvents, fs.existsSync(auditLog.file), {
4384
+ profile: record.ephemeral ? "isolated_customer_cloud" : "trusted_workstation",
4385
+ controller: record.ephemeral ? "bivy_hosted_provisioning" : "customer",
4386
+ sandboxTier: record.sandbox,
4387
+ approvalMode: record.approvalMode,
4388
+ runtimeEnforcement: runtimeInfo?.protectionLevel,
4389
+ toolInterception: runtimeInfo?.capabilities?.toolInterception === true,
4390
+ correlation: overrides.correlation,
4391
+ })
4392
+ : undefined;
4393
+ const receiptAttestation = receiptEvidence
4394
+ ? attestEvidence(receiptEvidence, auditKey.signer, {
4395
+ createdAt: new Date().toISOString(),
4396
+ runId: overrides.correlation?.runId,
4397
+ machineId: overrides.correlation?.machineId,
4398
+ auditChainHead: readChainState(auditLog.file).prev || undefined,
4399
+ }).attestation
4400
+ : undefined;
4372
4401
  void overrides.onEvidence?.({
4373
4402
  output: { sessionId: record.id, branch, prUrl: typeof extra.prUrl === "string" ? extra.prUrl : undefined },
4374
4403
  events: [{
@@ -4379,15 +4408,8 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4379
4408
  url: typeof extra.prUrl === "string" ? extra.prUrl : undefined,
4380
4409
  ...(stage === "checks_failed" || stage === "failed" ? { status: "failed" } : {}),
4381
4410
  }],
4382
- ...(terminal ? { receiptEvidence: receiptEvidenceForRun(auditEvents, fs.existsSync(auditLog.file), {
4383
- profile: record.ephemeral ? "isolated_customer_cloud" : "trusted_workstation",
4384
- controller: record.ephemeral ? "bivy_hosted_provisioning" : "customer",
4385
- sandboxTier: record.sandbox,
4386
- approvalMode: record.approvalMode,
4387
- runtimeEnforcement: runtimeInfo?.protectionLevel,
4388
- toolInterception: runtimeInfo?.capabilities?.toolInterception === true,
4389
- correlation: overrides.correlation,
4390
- }) } : {}),
4411
+ ...(receiptEvidence ? { receiptEvidence } : {}),
4412
+ ...(receiptAttestation ? { receiptAttestation, auditPublicKey: { keyId: auditKey.keyId, publicKeyPem: auditKey.publicKeyPem } } : {}),
4391
4413
  });
4392
4414
  }
4393
4415
  };
@@ -6062,6 +6084,18 @@ function forkRecordFor(rec) {
6062
6084
  sandbox: rec.sandbox,
6063
6085
  };
6064
6086
  }
6087
+ /** Snapshot the source's in-flight turn/approval state for the fork bundle, so a
6088
+ * fork/move DISCLOSES a mid-turn session or a pending approval instead of
6089
+ * silently dropping it (1A). Undefined when there is nothing in flight. */
6090
+ function forkInFlightState(rec) {
6091
+ const pendingApprovals = approvals.pendingFor(rec.id).map((r) => ({ toolName: r.toolName, requestId: r.id }));
6092
+ if (!rec.isWorking && pendingApprovals.length === 0)
6093
+ return undefined;
6094
+ return {
6095
+ ...(rec.isWorking ? { working: true } : {}),
6096
+ ...(pendingApprovals.length ? { pendingApprovals } : {}),
6097
+ };
6098
+ }
6065
6099
  /** Build the `session.fork.done` event both fork paths emit from a stood-up session. */
6066
6100
  function forkDoneEvent(requestId, record, plan, missing) {
6067
6101
  return {
@@ -8571,6 +8605,7 @@ const forkStandUp = createForkStandUp({
8571
8605
  resolveDefaultBaseRef,
8572
8606
  resolveAdoptBaseRef,
8573
8607
  resolveForkBaseRef,
8608
+ originBranchPresent,
8574
8609
  applyDirtyPatch,
8575
8610
  gitRepoRoot,
8576
8611
  materializeFork,
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Fork-fidelity CONFORMANCE matrix (phase 1E). The exact fidelity a fork of one
5
+ // agent into another achieves is determined by two capability flags — this
6
+ // derives, for every (source -> destination) pair, the tier the fork will land
7
+ // at, so the runtime support matrix can be GENERATED and regression-tested
8
+ // instead of hand-maintained (and silently drifting from the code).
9
+ //
10
+ // The tier logic here MUST mirror resolveForkFidelity in ./fork.ts — a test
11
+ // (test/fork-matrix.test.ts) drives representative pairs through the real
12
+ // resolveForkFidelity and asserts they agree, so this can't drift from the
13
+ // production decision path.
14
+ /**
15
+ * Tier a fork of `source` into `dest` achieves:
16
+ * - "full" same runtime, both ends speak native transport (byte-exact);
17
+ * - "replayed" the destination can import portable history (true cross-agent
18
+ * fork onto a copy of the transcript);
19
+ * - "seeded" otherwise (a continuation prompt).
20
+ * Mirrors resolveForkFidelity: a native payload is only captured when the SOURCE
21
+ * has forkTransport and is only importable by the SAME runtime, so "full" ⇔
22
+ * same id AND both forkTransport.
23
+ */
24
+ export function forkTier(source, dest) {
25
+ if (source.id === dest.id && source.forkTransport && dest.forkTransport)
26
+ return "full";
27
+ if (dest.forkHistoryImport)
28
+ return "replayed";
29
+ return "seeded";
30
+ }
31
+ /** Every ordered (source, dest) pair and its tier. */
32
+ export function forkMatrix(agents) {
33
+ const cells = [];
34
+ for (const source of agents) {
35
+ for (const dest of agents) {
36
+ cells.push({ source: source.id, dest: dest.id, tier: forkTier(source, dest) });
37
+ }
38
+ }
39
+ return cells;
40
+ }
41
+ const TIER_MARK = { full: "●", replayed: "◑", seeded: "○" };
42
+ /** Render the matrix as a GitHub-flavored markdown table (rows = source,
43
+ * columns = destination), with a legend. */
44
+ export function renderForkMatrixMarkdown(agents) {
45
+ const name = (a) => a.displayName ?? a.id;
46
+ const header = ["source ↓ / dest →", ...agents.map(name)].join(" | ");
47
+ const sep = ["---", ...agents.map(() => ":-:")].join(" | ");
48
+ const rows = agents.map((source) => {
49
+ const cells = agents.map((dest) => TIER_MARK[forkTier(source, dest)]);
50
+ return [name(source), ...cells].join(" | ");
51
+ });
52
+ return [
53
+ "<!-- GENERATED by scripts/gen-fork-matrix.ts — do not edit by hand. -->",
54
+ "# Fork fidelity matrix",
55
+ "",
56
+ "Legend: ● full (byte-exact, same runtime) · ◑ replayed (portable history) · ○ seeded (continuation prompt).",
57
+ "",
58
+ `| ${header} |`,
59
+ `| ${sep} |`,
60
+ ...rows.map((r) => `| ${r} |`),
61
+ "",
62
+ ].join("\n");
63
+ }
@@ -5,6 +5,9 @@
5
5
  * The engine reports what the destination is missing to continue the forked
6
6
  * session so the UI can guide the user instead of failing opaquely:
7
7
  * - agent — the target runtime must be installed/available (a HARD blocker).
8
+ * - commits — for a cross-node adopt, the source branch must be present on the
9
+ * remote, or its committed work can't travel (a HARD blocker: proceeding
10
+ * would silently base off the destination default and drop every commit).
8
11
  * - model — the model's provider should have auth on this node (soft: the
9
12
  * runtime can fall back, or the model-auth vault can supply it).
10
13
  * - repo — the source repo should be reachable (soft: it may be public, or
@@ -25,6 +28,20 @@ export function evaluateForkPrereqs(input) {
25
28
  blocking: !input.agent.available,
26
29
  ...(input.agent.available ? {} : { fix: "runtime.install" }),
27
30
  });
31
+ if (input.commits) {
32
+ out.push({
33
+ kind: "commits",
34
+ ok: input.commits.present,
35
+ label: input.commits.branch,
36
+ detail: input.commits.present
37
+ ? `Branch "${input.commits.branch}" is on the remote; its commits will travel.`
38
+ : `Branch "${input.commits.branch}" isn't on the remote, so its commits can't reach this node — push it from the source, then retry (proceeding would drop that work).`,
39
+ // HARD blocker: silently basing off the destination default would drop the
40
+ // source's committed work — the exact silent-loss this gate prevents.
41
+ blocking: !input.commits.present,
42
+ ...(input.commits.present ? {} : { fix: "source.push" }),
43
+ });
44
+ }
28
45
  if (input.model) {
29
46
  out.push({
30
47
  kind: "model",
@@ -19,6 +19,26 @@ import { randomBytes } from "node:crypto";
19
19
  import { normalizeSandboxTier } from "../harness/sandbox.js";
20
20
  import { parseRepo } from "../repo-workspace.js";
21
21
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "../session/fork-prereqs.js";
22
+ /**
23
+ * A one-line, user-facing disclosure of the source's in-flight state, or
24
+ * undefined when there is nothing to disclose. Pure so it is unit-testable.
25
+ */
26
+ export function describeInFlightState(state) {
27
+ if (!state)
28
+ return undefined;
29
+ const pending = state.pendingApprovals?.length ?? 0;
30
+ const parts = [];
31
+ if (pending > 0) {
32
+ const names = (state.pendingApprovals ?? []).map((a) => a.toolName).filter(Boolean).slice(0, 3);
33
+ const detail = names.length ? ` (${names.join(", ")}${pending > names.length ? ", …" : ""})` : "";
34
+ parts.push(`${pending} pending tool approval${pending === 1 ? "" : "s"}${detail}`);
35
+ }
36
+ if (state.working)
37
+ parts.push("an unfinished turn");
38
+ if (!parts.length)
39
+ return undefined;
40
+ return `The source session had ${parts.join(" and ")} that couldn't carry into the fork — re-issue the request here if you still need it.`;
41
+ }
22
42
  export function createForkStandUp(deps) {
23
43
  async function standUpFork(opts) {
24
44
  const { bundle, targetRuntimeId } = opts;
@@ -73,6 +93,23 @@ export function createForkStandUp(deps) {
73
93
  repoReachable = Boolean(token);
74
94
  const repoDir = await deps.cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: deps.reposRoot });
75
95
  const srcBranch = bundle.record.branch;
96
+ // Silent-loss gate (1A): a cross-node ADOPT of a branch that never reached
97
+ // the remote would fall back to the destination's default branch and DROP
98
+ // every source commit (resolveAdoptBaseRef's degrade path). Detect it and
99
+ // refuse with an actionable checklist item instead of losing committed work.
100
+ // Skipped for a same-node fork ("fresh"), which bases off the live source
101
+ // tip and can't lose commits this way.
102
+ if (opts.worktree === "adopt" && srcBranch && opts.detectPrereqs) {
103
+ const branchPresent = await deps.originBranchPresent(repoDir, srcBranch);
104
+ if (!branchPresent) {
105
+ const prereqs = evaluateForkPrereqs({ ...prereqInput, commits: { branch: srcBranch, present: false } });
106
+ return {
107
+ ok: false,
108
+ error: `The source branch "${srcBranch}" isn't on the remote yet, so its commits can't travel to this node. Push it from the source, then retry.`,
109
+ missing: missingForkPrereqs(prereqs),
110
+ };
111
+ }
112
+ }
76
113
  // Serialize clone-adjacent worktree ops on this repo so concurrent forks /
77
114
  // pickups don't race on `git worktree add` or clobber each other's trees.
78
115
  const wt = await deps.withRepoLock(repoDir, async () => {
@@ -146,6 +183,12 @@ export function createForkStandUp(deps) {
146
183
  // cleanly, so the fork isn't silently missing work-in-progress.
147
184
  if (dirtyWarning)
148
185
  deps.broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
186
+ // Disclose in-flight state the source had (a mid-turn session, pending tool
187
+ // approvals) — it belongs to the source runtime's live turn and can't replay
188
+ // into the fork, so we tell the user rather than silently dropping it (1A).
189
+ const stateNotice = describeInFlightState(bundle.state);
190
+ if (stateNotice)
191
+ deps.broadcast({ type: "session.notice", sessionId: record.id, message: stateNotice });
149
192
  await deps.applyRequestedModel(record, targetModel);
150
193
  deps.persistSessionMetadata(record);
151
194
  deps.scheduleAdvertise();
@@ -41,7 +41,7 @@ export function buildForkBundle(opts) {
41
41
  // transcript below still supports replay or a seeded continuation.
42
42
  }
43
43
  }
44
- return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}) };
44
+ return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}), ...(opts.state ? { state: opts.state } : {}) };
45
45
  }
46
46
  /**
47
47
  * Decide the best fidelity a fork of `bundle` into `targetRuntime` can achieve:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.347",
3
+ "version": "0.10.1-staging.348",
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.",