@bivy/bivy 0.3.0-staging.40 → 0.3.0-staging.41

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.
@@ -117,6 +117,11 @@ export class ControlPlaneTaskPoller {
117
117
  poke() {
118
118
  void this.tick();
119
119
  }
120
+ /** Number of queue items currently running on this node. Lets an ephemeral
121
+ * machine's self-teardown avoid exiting while it's mid-work. */
122
+ inFlightCount() {
123
+ return this.inFlight.size;
124
+ }
120
125
  /**
121
126
  * Replace the routing labels this live poller serves.
122
127
  *
@@ -16,6 +16,7 @@ export const EPHEMERAL_ALLOWED_HOSTS = new Set([
16
16
  "api.machines.dev",
17
17
  "api.fly.io",
18
18
  "api.sprites.dev",
19
+ "api.e2b.app",
19
20
  "ec2.us-east-1.amazonaws.com",
20
21
  "ec2.us-west-2.amazonaws.com",
21
22
  "ec2.eu-west-1.amazonaws.com",
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /** Parse the daemon's ephemeral-teardown config from the process env. */
4
+ export function readEphemeralTeardownConfig(env = process.env) {
5
+ return {
6
+ enabled: env.BIVY_EPHEMERAL === "1",
7
+ provider: String(env.BIVY_EPHEMERAL_PROVIDER || "").toLowerCase(),
8
+ ttlMin: Number(env.BIVY_EPHEMERAL_TTL_MIN) || 60,
9
+ onFinish: env.BIVY_TEARDOWN_ON_FINISH === "1",
10
+ finishGraceMs: Number(env.BIVY_TEARDOWN_FINISH_GRACE_MS) || 10_000,
11
+ idleGraceMs: Number(env.BIVY_SESSION_IDLE_CLOSE_MS) || 30 * 60 * 1000,
12
+ };
13
+ }
14
+ /**
15
+ * Pure decision: should this ephemeral machine tear itself down now? True only
16
+ * when it has done work at least once and is now fully quiet — nothing running,
17
+ * no device attached, no queue work in flight — for longer than the applicable
18
+ * grace (short after an agent finishes, else the idle window).
19
+ */
20
+ export function shouldSelfTeardown(cfg, state) {
21
+ if (!cfg.enabled)
22
+ return false;
23
+ if (!state.everBusy)
24
+ return false;
25
+ if (state.anyWorking || state.anyRemoteActive || state.inFlightWork > 0)
26
+ return false;
27
+ const grace = cfg.onFinish ? cfg.finishGraceMs : cfg.idleGraceMs;
28
+ return state.idleForMs >= grace;
29
+ }
30
+ let torndown = false;
31
+ /** Reset the once-only latch — tests only. */
32
+ export function __resetTeardownLatch() {
33
+ torndown = false;
34
+ }
35
+ /**
36
+ * Execute the teardown. Signals the control plane, then stops the daemon:
37
+ * - Fly: exiting the init process trips `auto_destroy` — the machine is reaped.
38
+ * - EC2: `shutdown -h now` self-terminates the instance, then we exit.
39
+ * - Hetzner: exiting takes the node offline; the control-plane reconciler issues
40
+ * the provider DELETE (it can't self-reap on OS shutdown). The /node/settled
41
+ * signal makes that prompt; the CP timer is the backstop.
42
+ * Once-only: a racing idle sweep + agent_end can both call this safely.
43
+ */
44
+ export async function performSelfTeardown(deps) {
45
+ if (torndown)
46
+ return;
47
+ torndown = true;
48
+ const log = deps.log ?? ((m) => console.log(`[ephemeral-teardown] ${m}`));
49
+ const exit = deps.exit ?? ((c) => process.exit(c));
50
+ log(`machine idle — self-teardown (provider=${deps.provider})`);
51
+ try {
52
+ await deps.signalSettled?.();
53
+ }
54
+ catch {
55
+ /* best effort — the CP timer reconciler and TTL are the backstops */
56
+ }
57
+ if (deps.provider === "aws") {
58
+ try {
59
+ deps.shutdown?.();
60
+ }
61
+ catch {
62
+ /* fall through to exit; the TTL shutdown still backstops */
63
+ }
64
+ }
65
+ exit(0);
66
+ }
@@ -35,6 +35,7 @@ const PAIR_INFO = Buffer.from(HKDF_INFO.pair);
35
35
  const ROTATE_INFO = Buffer.from(HKDF_INFO.rotate);
36
36
  const MODEL_AUTH_VAULT_INFO = Buffer.from(HKDF_INFO.modelAuthVault);
37
37
  const GITHUB_APP_VAULT_INFO = Buffer.from(HKDF_INFO.githubAppVault);
38
+ const DEVICE_VAULT_INFO = Buffer.from(HKDF_INFO.deviceVault);
38
39
  const EMPTY_SALT = Buffer.alloc(0);
39
40
  /** Generate a fresh X25519 keypair (node identity or device ephemeral). */
40
41
  export function generatePairingKeypair() {
@@ -73,7 +74,8 @@ export function deriveWrapKey(ourPrivateKeyB64, theirPublicKeyB64, purpose) {
73
74
  const info = purpose === "pair" ? PAIR_INFO
74
75
  : purpose === "rotate" ? ROTATE_INFO
75
76
  : purpose === "github-app-vault" ? GITHUB_APP_VAULT_INFO
76
- : MODEL_AUTH_VAULT_INFO;
77
+ : purpose === "device-vault" ? DEVICE_VAULT_INFO
78
+ : MODEL_AUTH_VAULT_INFO;
77
79
  return Buffer.from(hkdfSync("sha256", shared, EMPTY_SALT, info, WRAP_KEY_BYTES));
78
80
  }
79
81
  /** A fresh 32-byte symmetric room key. */
package/dist/server.js CHANGED
@@ -40,6 +40,9 @@ import { collectNodeStats } from "./node-stats.js";
40
40
  import { SessionEventCoalescer } from "./session-event-coalescer.js";
41
41
  import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed } from "./auth.js";
42
42
  import { RelayConnector, loadRelayConfig } from "./relay-client.js";
43
+ import { readEphemeralTeardownConfig, shouldSelfTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
44
+ import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
45
+ import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
43
46
  import { PolicyEngine } from "./policy/policy-engine.js";
44
47
  import { TerminalManager } from "./terminal.js";
45
48
  import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
@@ -6142,10 +6145,153 @@ async function deleteSessionFile(opts) {
6142
6145
  scheduleAdvertise();
6143
6146
  return { sessionId: deletedSessionId, sessionFile: inRoot ? resolved : undefined };
6144
6147
  }
6145
- const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessions(); pruneEmptySessions(); }, idleCloseSweepMs);
6148
+ const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessions(); pruneEmptySessions(); evaluateEphemeralTeardown(); }, idleCloseSweepMs);
6146
6149
  idleCloseTimer.unref?.();
6147
6150
  const worktreeCleanupTimer = setInterval(() => void sweepDiskGuardrails(), worktreeCleanupSweepMs);
6148
6151
  worktreeCleanupTimer.unref?.();
6152
+ // --- server-side ephemeral teardown ----------------------------------------
6153
+ // On a disposable machine (bootstrap set BIVY_EPHEMERAL=1) the daemon ends the
6154
+ // machine ITSELF once it goes idle, so teardown no longer needs the launching
6155
+ // device online. See src/ephemeral-teardown.ts + docs/ephemeral-sessions.md.
6156
+ const ephemeralTeardownCfg = readEphemeralTeardownConfig();
6157
+ let ephemeralEverBusy = false;
6158
+ let ephemeralLastBusyAt = Date.now();
6159
+ /** Best-effort "I've settled — reap me" signal to the control plane. Non-secret
6160
+ * (node id via the enrollment bearer); lets a hosted machine whose provider
6161
+ * can't self-reap on exit (Hetzner) be destroyed server-side. */
6162
+ async function signalSettledToControlPlane() {
6163
+ if (!sessionAdvertiseTarget)
6164
+ return;
6165
+ await fetch(`${sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "")}/node/settled`, {
6166
+ method: "POST",
6167
+ headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}`, "content-type": "application/json" },
6168
+ body: "{}",
6169
+ }).catch(() => { });
6170
+ }
6171
+ /** Rebuild-resume (Gap B): on a freshly re-provisioned machine booted with
6172
+ * `BIVY_RESTORE=<sessionId>`, fetch the session's control-plane snapshot,
6173
+ * decrypt it with this machine's room key (reused from the torn-down session so
6174
+ * the seal matches), and apply it — restoring the transcript (EventLog) and the
6175
+ * git checkpoint into a repo the session can open. The runtime process starts
6176
+ * fresh/seeded from the restored transcript ("reconstructed", not byte-identical
6177
+ * — see docs/ephemeral-sessions.md). Best-effort: a missing/undecryptable
6178
+ * snapshot leaves a clean fresh machine. Reuses the standby-replica machinery. */
6179
+ async function restoreSessionFromSnapshot(sessionId) {
6180
+ if (!sessionAdvertiseTarget)
6181
+ return;
6182
+ const cpBaseUrl = sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "");
6183
+ try {
6184
+ const res = await fetch(`${cpBaseUrl}/node/session-snapshot/${encodeURIComponent(sessionId)}`, {
6185
+ headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}` },
6186
+ });
6187
+ if (!res.ok) {
6188
+ console.error(`[restore] no snapshot for ${sessionId} (${res.status})`);
6189
+ return;
6190
+ }
6191
+ const data = (await res.json());
6192
+ if (!data.ciphertext)
6193
+ return;
6194
+ const applied = await applySessionSnapshot(data.ciphertext, pairingStore.roomKey(), {
6195
+ persistRecords: (id, records) => eventLog.rewrite(id, records),
6196
+ applyBundle: async (id, buf) => applyCheckpointBundle(await ensureReplicaRepo(id), id, buf),
6197
+ materialize: async (id) => materializeCheckpoint(await ensureReplicaRepo(id), id),
6198
+ });
6199
+ // Register the rebuilt session so it lists and opens (mirrors the standby's
6200
+ // upsertReplicaMeta); the transcript replays from the restored EventLog.
6201
+ try {
6202
+ metadata.upsertSession({ id: sessionId, source: "restored", status: "saved" });
6203
+ }
6204
+ catch {
6205
+ /* best-effort listing */
6206
+ }
6207
+ console.log(`[restore] session ${sessionId}: ${applied.recordCount} records, checkpoint ${applied.checkpointCommit ?? "none"}`);
6208
+ }
6209
+ catch (e) {
6210
+ console.error(`[restore] session ${sessionId} failed: ${e?.message || e}`);
6211
+ }
6212
+ }
6213
+ /** Flush a durable, E2E-encrypted snapshot of each open session to the control
6214
+ * plane before this disposable machine is torn down, so a destroy-lane session
6215
+ * can be rebuilt on a fresh machine later (Gap B). Sealed under the node room
6216
+ * key — the same key that seals the session title — so a restore machine that
6217
+ * reuses this session's room key can decrypt it; the control plane sees only
6218
+ * ciphertext. Best-effort per session; never blocks teardown for long. */
6219
+ async function flushSessionSnapshots() {
6220
+ if (!sessionAdvertiseTarget)
6221
+ return;
6222
+ const roomKey = pairingStore.roomKey();
6223
+ const cpBaseUrl = sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "");
6224
+ for (const record of new Set(openSessions.values())) {
6225
+ try {
6226
+ const sealed = await buildSessionSnapshot(record.id, roomKey, {
6227
+ readRecords: (id) => eventLog.entries(id),
6228
+ epochOf: () => 0,
6229
+ checkpointHead: async (id) => {
6230
+ try {
6231
+ return (await harness.checkpoints(id))[0]?.id;
6232
+ }
6233
+ catch {
6234
+ return undefined;
6235
+ }
6236
+ },
6237
+ bundleCheckpoint: async (id, since) => createCheckpointBundle(harnessDirFor(record), id, since),
6238
+ runtimeSessionRef: (id) => openSessions.get(id)?.sessionFile,
6239
+ worktreeSync: () => true,
6240
+ });
6241
+ if (!sealed)
6242
+ continue;
6243
+ await fetch(`${cpBaseUrl}/node/session-snapshot/${encodeURIComponent(record.id)}`, {
6244
+ method: "PUT",
6245
+ headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}`, "content-type": "application/json" },
6246
+ body: JSON.stringify({ ciphertext: sealed }),
6247
+ }).catch(() => { });
6248
+ }
6249
+ catch {
6250
+ /* best effort per session — TTL/branch push remain the backstops */
6251
+ }
6252
+ }
6253
+ }
6254
+ let ephemeralTearingDown = false;
6255
+ /** Evaluate the quiet condition and self-terminate if the machine is done. Reads
6256
+ * live session/queue state each call, so it's safe to invoke from the idle
6257
+ * sweep and from agent_end. No-op on a persistent node (env absent). */
6258
+ function evaluateEphemeralTeardown() {
6259
+ if (!ephemeralTeardownCfg.enabled || ephemeralTearingDown)
6260
+ return;
6261
+ const records = new Set(openSessions.values());
6262
+ const anyWorking = [...records].some((r) => r.isWorking);
6263
+ const anyRemoteActive = [...records].some((r) => r.remoteActive);
6264
+ const inFlightWork = controlPlanePoller?.inFlightCount() ?? 0;
6265
+ if (anyWorking || anyRemoteActive || inFlightWork > 0) {
6266
+ ephemeralEverBusy = true;
6267
+ ephemeralLastBusyAt = Date.now();
6268
+ return;
6269
+ }
6270
+ const idleForMs = Date.now() - ephemeralLastBusyAt;
6271
+ if (!shouldSelfTeardown(ephemeralTeardownCfg, { everBusy: ephemeralEverBusy, anyWorking, anyRemoteActive, inFlightWork, idleForMs }))
6272
+ return;
6273
+ ephemeralTearingDown = true;
6274
+ void (async () => {
6275
+ // Persist a rebuild snapshot BEFORE the machine goes away (Gap B), then reap.
6276
+ await flushSessionSnapshots();
6277
+ await performSelfTeardown({
6278
+ provider: ephemeralTeardownCfg.provider,
6279
+ signalSettled: signalSettledToControlPlane,
6280
+ shutdown: () => { try {
6281
+ spawnSync("shutdown", ["-h", "now"], { stdio: "ignore" });
6282
+ }
6283
+ catch { /* TTL backstops */ } },
6284
+ });
6285
+ })();
6286
+ }
6287
+ if (ephemeralTeardownCfg.enabled) {
6288
+ // Sample often enough to honour the finish grace (~10s) without waiting for the
6289
+ // 1–5min idle sweep. Ephemeral-only, so no cost on a persistent node.
6290
+ const ephemeralEvalMs = Math.max(2_000, Math.min(ephemeralTeardownCfg.finishGraceMs, 15_000));
6291
+ const ephemeralTeardownTimer = setInterval(() => evaluateEphemeralTeardown(), ephemeralEvalMs);
6292
+ ephemeralTeardownTimer.unref?.();
6293
+ console.log(`[ephemeral-teardown] armed: provider=${ephemeralTeardownCfg.provider} onFinish=${ephemeralTeardownCfg.onFinish} ttl=${ephemeralTeardownCfg.ttlMin}m`);
6294
+ }
6149
6295
  setTimeout(() => void sweepDiskGuardrails(), 30_000).unref?.();
6150
6296
  // One sweep shortly after boot clears ghosts left by a previous run before any
6151
6297
  // client paints its sidebar; the idle timer keeps it clean thereafter.
@@ -6382,6 +6528,9 @@ function attachSessionListeners(record) {
6382
6528
  // itself (gh/API/web) so the badge lights up.
6383
6529
  void maybePushWorktreeBranch(record)
6384
6530
  .then(() => maybeDetectPullRequest(record));
6531
+ // On a disposable machine, a finished turn with nobody watching is the cue
6532
+ // to consider self-teardown promptly (the idle sweep is the backstop).
6533
+ evaluateEphemeralTeardown();
6385
6534
  }
6386
6535
  const sessionEventPayload = { type: "session.event", sessionId: record.id, event };
6387
6536
  if (event.type === "message_update") {
@@ -9432,6 +9581,10 @@ const server = app.listen(port, host, async () => {
9432
9581
  console.log(`Agent data dir: ${piDir}`);
9433
9582
  console.log(`Workspace: ${defaultWorkspace}`);
9434
9583
  startRelayIfConfigured();
9584
+ // Rebuild-resume (Gap B): if this machine was re-provisioned to restore a
9585
+ // torn-down session, pull + apply its snapshot before serving. Non-blocking.
9586
+ if (process.env.BIVY_RESTORE)
9587
+ void restoreSessionFromSnapshot(String(process.env.BIVY_RESTORE));
9435
9588
  startModelAuthWatcher();
9436
9589
  startGithubAppSyncWatcher();
9437
9590
  await startGitHubTasksIfConfigured();
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Node-independent, encrypted SESSION SNAPSHOT (Gap B in docs/ephemeral-sessions.md).
5
+ //
6
+ // Warm replication (replicator.ts) ships a session's state node→node so a live
7
+ // standby can take over — but that copy evaporates if every node goes away, which
8
+ // is exactly the destroy-lane ephemeral case (Fly Machines/Hetzner/EC2 that are
9
+ // torn down when the agent finishes). This reuses the SAME replication frame
10
+ // (full transcript + git checkpoint bundle + runtimeSessionRef) but seals it under
11
+ // the session room key and stores it as an opaque control-plane BLOB, so a
12
+ // torn-down session can be rebuilt onto a fresh machine later. The control plane
13
+ // only ever sees ciphertext — same posture as the E2E session title.
14
+ //
15
+ // This module is the pure build/apply core (injected deps, no daemon/relay), so
16
+ // it's unit-testable like replicator.ts; the daemon wires the real EventLog /
17
+ // checkpoint deps, the room key from relay.json, and the control-plane transport.
18
+ import { OwnerReplicator, StandbyApplier } from "./replicator.js";
19
+ import { seal, open } from "../e2e.js";
20
+ /**
21
+ * Build a full, sealed snapshot of a session: a complete replication frame (ALL
22
+ * transcript records + a full git checkpoint bundle + the runtime resume token),
23
+ * serialized and encrypted under the session room key. Returns null when there's
24
+ * nothing to snapshot yet (no records and no checkpoint).
25
+ *
26
+ * A fresh `OwnerReplicator` has no cursor, so `buildTurnFrame` produces a FULL
27
+ * frame + full bundle — exactly what a from-scratch rebuild on a new machine
28
+ * needs (it holds no base to delta against).
29
+ */
30
+ export async function buildSessionSnapshot(sessionId, roomKey, deps) {
31
+ const owner = new OwnerReplicator({ ...deps, worktreeSync: () => true });
32
+ const frame = await owner.buildTurnFrame(sessionId);
33
+ // Nothing worth persisting yet: no transcript and no checkpoint (a fresh owner
34
+ // has no cursor, so buildReplFrame emits a zero-record frame rather than null).
35
+ if (!frame || (frame.records.length === 0 && !frame.checkpointCommit))
36
+ return null;
37
+ return seal(roomKey, JSON.stringify(frame));
38
+ }
39
+ /**
40
+ * Decrypt and apply a sealed snapshot onto a fresh machine: rewrite the session's
41
+ * EventLog transcript, land the git checkpoint into the (replica) repo and
42
+ * materialize the working tree, and return the runtimeSessionRef so the caller
43
+ * can re-derive a resumable runtime session (writeHistory / seeded fallback).
44
+ * Throws on a bad key / corrupt blob, or if the frame can't apply cleanly.
45
+ */
46
+ export async function applySessionSnapshot(sealed, roomKey, deps) {
47
+ const frame = JSON.parse(open(roomKey, sealed));
48
+ const applier = new StandbyApplier(deps);
49
+ const ack = await applier.receive(frame);
50
+ // We always ship a FULL frame + full bundle, so a fresh applier applies it
51
+ // outright ("applied"); "resync" is a benign already-current signal. A
52
+ // "needFull"/"stale" here means a corrupt/foreign blob.
53
+ if (ack.status !== "applied" && ack.status !== "resync") {
54
+ throw new Error(`snapshot apply failed: ${ack.status}`);
55
+ }
56
+ return {
57
+ runtimeSessionRef: frame.runtimeSessionRef,
58
+ recordCount: frame.records.length,
59
+ checkpointCommit: frame.checkpointCommit,
60
+ };
61
+ }
@@ -29,6 +29,10 @@ export const HKDF_INFO = {
29
29
  modelAuthVault: "bivy-model-auth-vault-v1",
30
30
  /** GitHub App private-key vault delivery (node-only, opt-in — issue #88). */
31
31
  githubAppVault: "bivy-github-app-vault-v1",
32
+ /** Device→device ephemeral-provider-token vault delivery (opt-in). Unlike the
33
+ * node vaults above, the recipients are the account's paired DEVICES, so a
34
+ * second device can wake/reach a machine the first launched. */
35
+ deviceVault: "bivy-device-vault-v1",
32
36
  };
33
37
  /** Version byte stamped into every sealed frame's authenticated plaintext. */
34
38
  export const FRAME_VERSION = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.3.0-staging.40",
3
+ "version": "0.3.0-staging.41",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",