@bivy/bivy 0.3.0 → 0.4.0-staging.51
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 +60 -1
- package/dist/control-plane-tasks.js +5 -0
- package/dist/device-registry.js +34 -2
- package/dist/ephemeral-exec.js +1 -0
- package/dist/ephemeral-teardown.js +66 -0
- package/dist/pairing-crypto.js +3 -1
- package/dist/relay-client.js +2 -1
- package/dist/repo-workspace.js +52 -7
- package/dist/runtime/claude-code.js +5 -0
- package/dist/runtime/oauth/oauth-login-sweep.js +19 -0
- package/dist/server.js +423 -9
- package/dist/session/attach-to-chat.js +134 -0
- package/dist/session/event-log.js +59 -2
- package/dist/session/snapshot.js +61 -0
- package/dist/wire-format.js +4 -0
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -1528,7 +1528,7 @@ function cmdCompletions(args = []) {
|
|
|
1528
1528
|
const shell = (args[0] || "").toLowerCase();
|
|
1529
1529
|
const commands = [
|
|
1530
1530
|
"run", "sessions", "ls", "resume", "promote", "rename", "nodes", "agents", "agents:install", "shim", "takeover", "token", "exec",
|
|
1531
|
-
"send", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
|
|
1531
|
+
"send", "attach", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
|
|
1532
1532
|
"update", "update:log", "open", "service", "secrets", "voice", "link", "relay:setup",
|
|
1533
1533
|
"github:connect", "github:app-create", "github:app-connect", "github:app-sync", "prune", "uninstall", "help", "version",
|
|
1534
1534
|
];
|
|
@@ -1974,6 +1974,62 @@ async function cmdSend(args = []) {
|
|
|
1974
1974
|
process.exit(code);
|
|
1975
1975
|
}
|
|
1976
1976
|
|
|
1977
|
+
// `bivy attach <file> [--caption "…"] [--session <id>]` — surface a file the
|
|
1978
|
+
// agent produced into the chat as an image/file attachment (the reverse of the
|
|
1979
|
+
// composer paperclip). The universal path: any agent that can run a shell command
|
|
1980
|
+
// can call this. The session id defaults to $BIVY_SESSION_ID, which the daemon
|
|
1981
|
+
// injects into the agent's subprocess env. The file is resolved to an absolute
|
|
1982
|
+
// path here (the CLI's cwd is the agent's workdir) and confined to the session
|
|
1983
|
+
// workspace server-side.
|
|
1984
|
+
async function cmdAttach(args = []) {
|
|
1985
|
+
const flag = (name) => {
|
|
1986
|
+
const i = args.indexOf(name);
|
|
1987
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
1988
|
+
};
|
|
1989
|
+
const sessionId = flag("--session") || process.env.BIVY_SESSION_ID;
|
|
1990
|
+
const caption = flag("--caption");
|
|
1991
|
+
const name = flag("--name");
|
|
1992
|
+
const mimeType = flag("--mime") || flag("--mimeType");
|
|
1993
|
+
const flagsWithValue = new Set(["--session", "--caption", "--name", "--mime", "--mimeType"]);
|
|
1994
|
+
// First positional that isn't a flag or a flag's value.
|
|
1995
|
+
let file;
|
|
1996
|
+
for (let i = 0; i < args.length; i++) {
|
|
1997
|
+
const a = args[i];
|
|
1998
|
+
if (a.startsWith("-")) { if (flagsWithValue.has(a)) i++; continue; }
|
|
1999
|
+
if (i > 0 && flagsWithValue.has(args[i - 1])) continue;
|
|
2000
|
+
file = a;
|
|
2001
|
+
break;
|
|
2002
|
+
}
|
|
2003
|
+
if (!file) { console.error(c.red('Usage: bivy attach <file> [--caption "…"] [--session <id>]')); process.exit(1); return; }
|
|
2004
|
+
if (!sessionId) { console.error(c.red("No session id. Set --session <id> or run inside an agent session ($BIVY_SESSION_ID).")); process.exit(1); return; }
|
|
2005
|
+
const absPath = path.resolve(process.cwd(), file);
|
|
2006
|
+
if (!fs.existsSync(absPath)) { console.error(c.red(`File not found: ${file}`)); process.exit(1); return; }
|
|
2007
|
+
|
|
2008
|
+
const config = loadConfig();
|
|
2009
|
+
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
2010
|
+
// A token isn't required on a single-user host (loopback bypasses auth), but
|
|
2011
|
+
// include it when available so multi-user hosts work too.
|
|
2012
|
+
let token;
|
|
2013
|
+
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2014
|
+
const headers = { "content-type": "application/json" };
|
|
2015
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
2016
|
+
let res;
|
|
2017
|
+
try {
|
|
2018
|
+
res = await fetch(`${url(config)}/api/session/${encodeURIComponent(sessionId)}/attach`, {
|
|
2019
|
+
method: "POST",
|
|
2020
|
+
headers,
|
|
2021
|
+
body: JSON.stringify({ path: absPath, caption, name, mimeType }),
|
|
2022
|
+
});
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
console.error(c.red(`Could not reach the Bivy node: ${error?.message || String(error)}`));
|
|
2025
|
+
process.exit(1);
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const body = await res.json().catch(() => ({}));
|
|
2029
|
+
if (!res.ok) { console.error(c.red(`Attach failed (${res.status}): ${body?.error || "unknown error"}`)); process.exit(1); return; }
|
|
2030
|
+
console.log(c.green(`Attached ${body.name} (${body.kind}, ${body.size} bytes) to the chat.`));
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1977
2033
|
// Map a saved session's runtime id to the `bivy run` agent whose native CLI can
|
|
1978
2034
|
// resume it in a terminal. Only agents with a real native resume qualify; other
|
|
1979
2035
|
// runtimes (generic-cli, SDK-only) have no terminal resume and open in the web app.
|
|
@@ -4128,6 +4184,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
4128
4184
|
case "send":
|
|
4129
4185
|
await cmdSend(args);
|
|
4130
4186
|
break;
|
|
4187
|
+
case "attach":
|
|
4188
|
+
await cmdAttach(args);
|
|
4189
|
+
break;
|
|
4131
4190
|
case "completions":
|
|
4132
4191
|
case "completion":
|
|
4133
4192
|
cmdCompletions(args);
|
|
@@ -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
|
*
|
package/dist/device-registry.js
CHANGED
|
@@ -6,6 +6,26 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { generatePairingKeypair, generateRoomKey, generatePairSecret, deriveWrapKey, verifyPairingProof, wrapRoomKey, } from "./pairing-crypto.js";
|
|
7
7
|
import { seal, open } from "./e2e.js";
|
|
8
8
|
const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
|
|
9
|
+
const ROOM_KEY_BYTES = 32;
|
|
10
|
+
/**
|
|
11
|
+
* Validate + canonicalize a pre-shared room-key seed. Returns the canonical
|
|
12
|
+
* base64 of a well-formed 32-byte key, or undefined for anything malformed
|
|
13
|
+
* (missing, wrong length, non-base64) so the caller falls back to a fresh key
|
|
14
|
+
* rather than adopting a bad seed that would make every device unable to decrypt.
|
|
15
|
+
*/
|
|
16
|
+
function normalizeSeedRoomKey(seedRoomKeyB64) {
|
|
17
|
+
if (!seedRoomKeyB64)
|
|
18
|
+
return undefined;
|
|
19
|
+
try {
|
|
20
|
+
const buf = Buffer.from(seedRoomKeyB64, "base64");
|
|
21
|
+
if (buf.length !== ROOM_KEY_BYTES)
|
|
22
|
+
return undefined;
|
|
23
|
+
return buf.toString("base64");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
9
29
|
export class PairingStore {
|
|
10
30
|
filePath;
|
|
11
31
|
data;
|
|
@@ -18,8 +38,17 @@ export class PairingStore {
|
|
|
18
38
|
* Load (or create) the pairing state. A fresh state gets a randomly generated
|
|
19
39
|
* room key; devices receive it (and every later rotation) over the X25519
|
|
20
40
|
* pairing handshake, so there is no static seed to carry forward.
|
|
41
|
+
*
|
|
42
|
+
* `seedRoomKeyB64` (base64 32-byte key) is the ONE exception: an ephemeral
|
|
43
|
+
* REBUILD reuses a torn-down session's node id and must decrypt a snapshot
|
|
44
|
+
* sealed under that session's original room key. The launching device/CP bakes
|
|
45
|
+
* that key into relay.json (`e2eKey`) and the daemon passes it here, so a
|
|
46
|
+
* brand-new pairing state adopts it instead of minting a fresh one. It is used
|
|
47
|
+
* ONLY when there is no existing pairing.json — an already-paired node never
|
|
48
|
+
* has its room key overwritten. An absent/malformed seed falls back to a fresh
|
|
49
|
+
* random key (the ordinary first-run path).
|
|
21
50
|
*/
|
|
22
|
-
static load(appDir) {
|
|
51
|
+
static load(appDir, seedRoomKeyB64) {
|
|
23
52
|
const filePath = path.join(appDir, "pairing.json");
|
|
24
53
|
let raw;
|
|
25
54
|
try {
|
|
@@ -55,9 +84,12 @@ export class PairingStore {
|
|
|
55
84
|
devices: Array.isArray(parsed.devices) ? parsed.devices : [],
|
|
56
85
|
});
|
|
57
86
|
}
|
|
87
|
+
// No pairing.json yet — first run. Adopt a valid pre-shared seed (ephemeral
|
|
88
|
+
// rebuild) if one was supplied; otherwise mint a fresh random room key.
|
|
89
|
+
const seededRoomKey = normalizeSeedRoomKey(seedRoomKeyB64);
|
|
58
90
|
const data = {
|
|
59
91
|
nodeKeypair: generatePairingKeypair(),
|
|
60
|
-
roomKeyB64: generateRoomKey().toString("base64"),
|
|
92
|
+
roomKeyB64: seededRoomKey ?? generateRoomKey().toString("base64"),
|
|
61
93
|
devices: [],
|
|
62
94
|
};
|
|
63
95
|
const store = new PairingStore(filePath, data);
|
package/dist/ephemeral-exec.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/pairing-crypto.js
CHANGED
|
@@ -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
|
-
:
|
|
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/relay-client.js
CHANGED
|
@@ -26,7 +26,8 @@ export function loadRelayConfig(appDir) {
|
|
|
26
26
|
return null;
|
|
27
27
|
const controlPlaneUrl = process.env.BIVY_CONTROL_PLANE_URL ?? raw.controlPlaneUrl;
|
|
28
28
|
const clientBaseUrl = process.env.BIVY_CLIENT_BASE_URL ?? raw.clientBaseUrl ?? controlPlaneUrl;
|
|
29
|
-
|
|
29
|
+
const e2eKey = process.env.BIVY_ROOM_KEY ?? raw.e2eKey;
|
|
30
|
+
return { url, enrollmentToken, controlPlaneUrl, clientBaseUrl, e2eKey };
|
|
30
31
|
}
|
|
31
32
|
// Application-level keepalive. On flaky/mobile links the node→relay TCP socket
|
|
32
33
|
// can half-open silently: the node still thinks it is connected and buffers
|
package/dist/repo-workspace.js
CHANGED
|
@@ -29,15 +29,60 @@ export function parseGitHubRemote(input) {
|
|
|
29
29
|
return parseRepo(`${ssh[1]}/${ssh[2]}`);
|
|
30
30
|
return undefined;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
/**
|
|
34
|
+
* A git failure that DEFINITIVELY means "this workspace is not a GitHub-connected
|
|
35
|
+
* checkout" — a genuine `undefined` answer — as opposed to a transient failure we
|
|
36
|
+
* must not mistake for one. `git remote get-url origin` reports "not a git
|
|
37
|
+
* repository" (no repo) or "No such remote" (a repo with no origin); both are
|
|
38
|
+
* real, stable answers. Anything else (notably `index.lock`/`config.lock`
|
|
39
|
+
* contention when many sessions touch the same shared clone at once) is transient.
|
|
40
|
+
*/
|
|
41
|
+
function isDefinitiveNonGitHubError(error) {
|
|
42
|
+
const e = error;
|
|
43
|
+
const text = `${e?.stderr ?? ""} ${e?.message ?? String(error)}`;
|
|
44
|
+
return /not a git repository|No such remote|does not appear to be a git repository/i.test(text);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Infer owner/repo from a workspace's origin remote, if it is a GitHub checkout.
|
|
48
|
+
*
|
|
49
|
+
* Retries transient git failures before giving up. Misclassifying a momentarily
|
|
50
|
+
* busy GitHub checkout as "not a repo" is what let a session skip worktree
|
|
51
|
+
* isolation and run directly in the shared clone root, where its `git
|
|
52
|
+
* checkout`/`git stash` collided with a concurrent session (the "sessions
|
|
53
|
+
* mixing" bug). So: a DEFINITIVE non-GitHub result (not a repo / no origin)
|
|
54
|
+
* resolves to `undefined` as before, but a transient error is retried and then
|
|
55
|
+
* THROWN — the caller must fail loudly rather than silently degrade to running
|
|
56
|
+
* the agent in the shared root.
|
|
57
|
+
*/
|
|
33
58
|
export async function inferGitHubRepoFromWorkspace(workspace) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
let lastError;
|
|
60
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await exec("git", ["-C", workspace, "remote", "get-url", "origin"], { cwd: workspace });
|
|
63
|
+
return parseGitHubRemote(stdout);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (isDefinitiveNonGitHubError(error))
|
|
67
|
+
return undefined;
|
|
68
|
+
lastError = error;
|
|
69
|
+
if (attempt < 2)
|
|
70
|
+
await delay(50 * (attempt + 1));
|
|
71
|
+
}
|
|
40
72
|
}
|
|
73
|
+
throw new Error(`Could not determine the GitHub repo for ${workspace} (the checkout may be busy): ` +
|
|
74
|
+
`${lastError?.message ?? String(lastError)}`);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* True when `dir` is a Bivy-managed shared clone root — a direct child of the
|
|
78
|
+
* repos root, i.e. `<reposRoot>/owner__repo` (see `cloneOrUpdateRepo`). Every
|
|
79
|
+
* session for a repo shares that one checkout, so an agent must NEVER run
|
|
80
|
+
* directly in it; it runs in a per-session worktree instead. Worktree paths live
|
|
81
|
+
* DEEPER (`<clone>/.bivy/worktrees/<slug>`) and are intentionally not matched, so
|
|
82
|
+
* this cleanly distinguishes "the shared root" from "an isolated worktree".
|
|
83
|
+
*/
|
|
84
|
+
export function isSharedCloneRoot(dir, reposRoot) {
|
|
85
|
+
return path.resolve(path.dirname(path.resolve(dir))) === path.resolve(reposRoot);
|
|
41
86
|
}
|
|
42
87
|
/** A GitHub token from env or the local `gh` login, or undefined (public only). */
|
|
43
88
|
export async function resolveGitHubToken(env = process.env) {
|
|
@@ -671,6 +671,11 @@ class ClaudeSession {
|
|
|
671
671
|
const env = { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env };
|
|
672
672
|
const credEnv = await this.resolveCredentialEnv();
|
|
673
673
|
Object.assign(env, credEnv);
|
|
674
|
+
// Let the agent's own shell surface a file into the chat via `bivy attach`
|
|
675
|
+
// (POST /api/session/:id/attach). The session id is otherwise invisible to
|
|
676
|
+
// the subprocess. Other runtimes should set this the same way to enable the
|
|
677
|
+
// universal attach path for their agents.
|
|
678
|
+
env.BIVY_SESSION_ID = this.id;
|
|
674
679
|
this.spawnedToken = authTokenFromEnv(credEnv);
|
|
675
680
|
const options = {
|
|
676
681
|
cwd: this.cwd,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Pure decision for sweeping stale browser-initiated OAuth logins (see the
|
|
5
|
+
// `oauthLogins` registry in src/server.ts). A login parks the node until the
|
|
6
|
+
// remote device pastes its code; an abandoned one must be reaped (aborting it
|
|
7
|
+
// closes the local callback http.Server) and a finished one dropped after a
|
|
8
|
+
// short grace so clients can still read its final status. Kept pure + isolated
|
|
9
|
+
// so the edge logic is unit-tested without importing the daemon.
|
|
10
|
+
export function isTerminalOAuthStatus(status) {
|
|
11
|
+
return status === "done" || status === "error";
|
|
12
|
+
}
|
|
13
|
+
export function decideOAuthLoginSweep(status, ageMs, opts) {
|
|
14
|
+
const terminal = isTerminalOAuthStatus(status);
|
|
15
|
+
const expired = terminal ? ageMs > opts.graceMs : ageMs > opts.ttlMs;
|
|
16
|
+
if (!expired)
|
|
17
|
+
return { drop: false, abort: false };
|
|
18
|
+
return { drop: true, abort: !terminal };
|
|
19
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -27,6 +27,7 @@ import { provisionAgentRun } from "./runtime/credential-provisioning.js";
|
|
|
27
27
|
import { ingestAgentCredentials } from "./runtime/credential-ingest.js";
|
|
28
28
|
import { suggestNameFromSelectedModel } from "./runtime/model-namer.js";
|
|
29
29
|
import { isNativeOAuthProvider, loginModelOAuth } from "./runtime/oauth/model-oauth.js";
|
|
30
|
+
import { decideOAuthLoginSweep } from "./runtime/oauth/oauth-login-sweep.js";
|
|
30
31
|
import { listCodexSessions, loadCodexTranscript, discoverCodexSessionForCwd } from "./runtime/codex-sessions.js";
|
|
31
32
|
import { dedupeSessionSummaries } from "./session-identity.js";
|
|
32
33
|
import { discoverPiSessionForCwd } from "./runtime/pi-session-discovery.js";
|
|
@@ -40,10 +41,13 @@ import { collectNodeStats } from "./node-stats.js";
|
|
|
40
41
|
import { SessionEventCoalescer } from "./session-event-coalescer.js";
|
|
41
42
|
import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed } from "./auth.js";
|
|
42
43
|
import { RelayConnector, loadRelayConfig } from "./relay-client.js";
|
|
44
|
+
import { readEphemeralTeardownConfig, shouldSelfTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
|
|
45
|
+
import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
|
|
46
|
+
import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
|
|
43
47
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
44
48
|
import { TerminalManager } from "./terminal.js";
|
|
45
49
|
import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
|
|
46
|
-
import { createWorktree, removeWorktree, branchSlug } from "./worktree.js";
|
|
50
|
+
import { createWorktree, removeWorktree, branchSlug, gitRepoRoot } from "./worktree.js";
|
|
47
51
|
import { HarnessManager } from "./harness/manager.js";
|
|
48
52
|
import { startEgressProxyIfEnabled } from "./harness/egress.js";
|
|
49
53
|
import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
|
|
@@ -51,7 +55,7 @@ import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
|
|
|
51
55
|
import { checkDiskAdmission } from "./harness/disk-admission.js";
|
|
52
56
|
import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
|
|
53
57
|
import { injectMcpProxyForSession } from "./harness/mcp-inject.js";
|
|
54
|
-
import { parseRepo, inferGitHubRepoFromWorkspace, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
58
|
+
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
55
59
|
import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
|
|
56
60
|
import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
|
|
57
61
|
import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
|
|
@@ -67,6 +71,7 @@ import { normalizeMessages } from "./session/transcript-normal.js";
|
|
|
67
71
|
import { buildNativeImportSeedPrompt } from "./session/native-import.js";
|
|
68
72
|
import { EventLog } from "./session/event-log.js";
|
|
69
73
|
import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
|
|
74
|
+
import { planAttachment, isAttachPlanError } from "./session/attach-to-chat.js";
|
|
70
75
|
import { ReplicationService } from "./session/replication-service.js";
|
|
71
76
|
import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
|
|
72
77
|
import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
|
|
@@ -737,8 +742,11 @@ function getRuntime(requested, sandbox) {
|
|
|
737
742
|
}
|
|
738
743
|
// Holds the node's X25519 identity key, the rotating room key, and the linked
|
|
739
744
|
// device registry. The room key is generated fresh on first load and delivered
|
|
740
|
-
// to devices via the X25519 pairing handshake — there is no static seed
|
|
741
|
-
|
|
745
|
+
// to devices via the X25519 pairing handshake — there is no static seed, EXCEPT
|
|
746
|
+
// on an ephemeral rebuild: relay.json carries the reused session's room key
|
|
747
|
+
// (`e2eKey`) so a brand-new pairing state adopts it and can decrypt the restored
|
|
748
|
+
// snapshot. Only seeds a first-run node; an existing pairing.json always wins.
|
|
749
|
+
const pairingStore = PairingStore.load(appDir, loadRelayConfig(appDir)?.e2eKey);
|
|
742
750
|
function syncPairingMetadata() {
|
|
743
751
|
for (const device of pairingStore.listDevices()) {
|
|
744
752
|
metadata.upsertDevice({ id: device.id, label: device.label, publicKeyB64: device.publicKeyB64, firstSeenAt: device.createdAt, lastSeenAt: device.lastSeenAt ?? undefined });
|
|
@@ -749,6 +757,40 @@ let relay;
|
|
|
749
757
|
const clients = new Set();
|
|
750
758
|
const commandProcesses = new Map();
|
|
751
759
|
const oauthLogins = new Map();
|
|
760
|
+
// A browser-initiated subscription login parks the node on `manualCodePromise`
|
|
761
|
+
// until the remote device pastes the code (`provider.oauth.code`). If the user
|
|
762
|
+
// abandons it, the entry — AND its local callback http.Server — would otherwise
|
|
763
|
+
// linger until the process exits. That matters especially on a short-lived
|
|
764
|
+
// ephemeral node. Sweep periodically: abort (which closes the callback server,
|
|
765
|
+
// see startCallbackServer) + drop any in-flight login past its TTL, and drop a
|
|
766
|
+
// finished one after a short grace so clients can still read the final status.
|
|
767
|
+
const OAUTH_LOGIN_TTL_MS = 10 * 60_000;
|
|
768
|
+
const OAUTH_LOGIN_DONE_GRACE_MS = 2 * 60_000;
|
|
769
|
+
function sweepOauthLogins(now = Date.now()) {
|
|
770
|
+
for (const [id, login] of oauthLogins.entries()) {
|
|
771
|
+
const { drop, abort } = decideOAuthLoginSweep(login.status, now - login.createdAt, {
|
|
772
|
+
ttlMs: OAUTH_LOGIN_TTL_MS,
|
|
773
|
+
graceMs: OAUTH_LOGIN_DONE_GRACE_MS,
|
|
774
|
+
});
|
|
775
|
+
if (!drop)
|
|
776
|
+
continue;
|
|
777
|
+
if (abort) {
|
|
778
|
+
login.cancelled = true;
|
|
779
|
+
try {
|
|
780
|
+
login.abort.abort();
|
|
781
|
+
}
|
|
782
|
+
catch { /* already settled */ }
|
|
783
|
+
}
|
|
784
|
+
oauthLogins.delete(id);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
let oauthLoginSweepTimer;
|
|
788
|
+
function startOAuthLoginSweeper() {
|
|
789
|
+
if (oauthLoginSweepTimer)
|
|
790
|
+
return;
|
|
791
|
+
oauthLoginSweepTimer = setInterval(() => sweepOauthLogins(), 60_000);
|
|
792
|
+
oauthLoginSweepTimer.unref?.();
|
|
793
|
+
}
|
|
752
794
|
const openSessions = new Map();
|
|
753
795
|
// Stage 2 (docs/agent-node-decoupling.md): sessionId -> agent-service address for
|
|
754
796
|
// live REMOTE sessions the agent service keeps running across an eviction/
|
|
@@ -991,6 +1033,40 @@ function materializeAttachments(record, files) {
|
|
|
991
1033
|
}
|
|
992
1034
|
return { note: notes.join("\n"), refs };
|
|
993
1035
|
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Surface an AGENT-produced file into the chat as an attachment (image or file)
|
|
1038
|
+
* — the reverse of the composer paperclip. Confines to the session workspace,
|
|
1039
|
+
* stores the bytes in the content-addressed AttachmentStore, persists a durable
|
|
1040
|
+
* outbound reference anchored at the current transcript position (so a reload or
|
|
1041
|
+
* another device shows it), and emits the live `attachment` event so attached
|
|
1042
|
+
* devices render the chip/thumbnail immediately. Shared by the HTTP endpoint and
|
|
1043
|
+
* the `bivy attach` CLI. Returns the stored ref, or a human-readable error.
|
|
1044
|
+
*/
|
|
1045
|
+
function attachToChat(record, opts) {
|
|
1046
|
+
const plan = planAttachment({
|
|
1047
|
+
workspaceDir: harnessDirFor(record),
|
|
1048
|
+
filePath: opts.filePath,
|
|
1049
|
+
mimeType: opts.mimeType,
|
|
1050
|
+
name: opts.name,
|
|
1051
|
+
});
|
|
1052
|
+
if (isAttachPlanError(plan))
|
|
1053
|
+
return { error: plan.error };
|
|
1054
|
+
let ref;
|
|
1055
|
+
try {
|
|
1056
|
+
ref = attachmentStore.put(plan.bytes, { name: plan.name, mimeType: plan.mimeType, kind: plan.kind });
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
return { error: `Could not store the attachment: ${error instanceof Error ? error.message : String(error)}` };
|
|
1060
|
+
}
|
|
1061
|
+
const entryId = `att-${randomBytes(8).toString("hex")}`;
|
|
1062
|
+
const caption = opts.caption ? String(opts.caption).slice(0, 2000) : undefined;
|
|
1063
|
+
// Anchor at the current base length so history replay interleaves the
|
|
1064
|
+
// attachment where it was emitted (see event-log outbound projection).
|
|
1065
|
+
const afterMessageCount = record.session.getMessages().length;
|
|
1066
|
+
eventLog.appendOutboundAttachment(record.id, { afterMessageCount, id: entryId, ref, caption });
|
|
1067
|
+
broadcast({ type: "session.event", sessionId: record.id, event: { type: "attachment", id: entryId, ref, caption } });
|
|
1068
|
+
return { ref };
|
|
1069
|
+
}
|
|
994
1070
|
function approvalModeFrom(value) {
|
|
995
1071
|
return value === "never" || value === "risky" || value === "always" || value === "autonomous" ? value : undefined;
|
|
996
1072
|
}
|
|
@@ -3417,7 +3493,14 @@ function startRelayIfConfigured() {
|
|
|
3417
3493
|
terminals.dropClient(RELAY_CLIENT_ID);
|
|
3418
3494
|
relay = new RelayConnector(config, (msg) => void handleRelayMessage(msg), {
|
|
3419
3495
|
pairing: pairingStore,
|
|
3420
|
-
onWorkAvailable: () =>
|
|
3496
|
+
onWorkAvailable: () => {
|
|
3497
|
+
controlPlanePoller?.poke();
|
|
3498
|
+
// A relay wake also means "something changed for this account" — kick a
|
|
3499
|
+
// (debounced) model-auth sync so a peer node answers any pending vault-key
|
|
3500
|
+
// request from a freshly-launched ephemeral runner without waiting for its
|
|
3501
|
+
// 30s poll. Cheap and idempotent; coalesced to at most one sync per burst.
|
|
3502
|
+
triggerModelAuthSyncSoon();
|
|
3503
|
+
},
|
|
3421
3504
|
});
|
|
3422
3505
|
relay.start();
|
|
3423
3506
|
if (config.controlPlaneUrl && config.enrollmentToken) {
|
|
@@ -3517,6 +3600,62 @@ async function modelAuthFetch(pathname, init = {}) {
|
|
|
3517
3600
|
headers.set("content-type", "application/json");
|
|
3518
3601
|
return fetch(`${sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "")}${pathname}`, { ...init, headers });
|
|
3519
3602
|
}
|
|
3603
|
+
// Debounced model-auth sync trigger. A relay wake (`work.available`) fires this
|
|
3604
|
+
// so peers answer a new node's vault-key request promptly (event-driven) instead
|
|
3605
|
+
// of on the steady 30s poll. Coalesces a burst of wakes into one sync.
|
|
3606
|
+
let modelAuthSyncSoonTimer;
|
|
3607
|
+
function triggerModelAuthSyncSoon() {
|
|
3608
|
+
if (modelAuthSyncSoonTimer)
|
|
3609
|
+
return;
|
|
3610
|
+
modelAuthSyncSoonTimer = setTimeout(() => {
|
|
3611
|
+
modelAuthSyncSoonTimer = undefined;
|
|
3612
|
+
void syncModelAuthFromControlPlane();
|
|
3613
|
+
}, 250);
|
|
3614
|
+
modelAuthSyncSoonTimer.unref?.();
|
|
3615
|
+
}
|
|
3616
|
+
// Cold-start fast-retry. A freshly-launched node (typically a short-lived
|
|
3617
|
+
// ephemeral runner) that holds vault ciphertext but no wrapped key yet must wait
|
|
3618
|
+
// for a peer node to answer its key request. The 30s steady poll is too slow for
|
|
3619
|
+
// a machine that may only live a minute, so once we've requested the key we
|
|
3620
|
+
// re-sync on a brief bounded cadence until the wrapped key arrives (a peer
|
|
3621
|
+
// answered) or we give up and let the steady poll continue. Peer-only by design:
|
|
3622
|
+
// the key is always answered by another node over the E2E wrap — nothing ever
|
|
3623
|
+
// transits the device or control plane in the clear.
|
|
3624
|
+
const MODEL_AUTH_COLDSTART_INTERVAL_MS = 2_000;
|
|
3625
|
+
const MODEL_AUTH_COLDSTART_MAX_ATTEMPTS = 30; // ~60s bounded
|
|
3626
|
+
let modelAuthColdStartActive = false;
|
|
3627
|
+
let modelAuthColdStartAttempts = 0;
|
|
3628
|
+
let modelAuthColdStartTimer;
|
|
3629
|
+
function stopModelAuthColdStart() {
|
|
3630
|
+
modelAuthColdStartActive = false;
|
|
3631
|
+
modelAuthColdStartAttempts = 0;
|
|
3632
|
+
if (modelAuthColdStartTimer) {
|
|
3633
|
+
clearTimeout(modelAuthColdStartTimer);
|
|
3634
|
+
modelAuthColdStartTimer = undefined;
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
function ensureModelAuthColdStart() {
|
|
3638
|
+
if (modelAuthColdStartActive)
|
|
3639
|
+
return; // already retrying
|
|
3640
|
+
modelAuthColdStartActive = true;
|
|
3641
|
+
modelAuthColdStartAttempts = 0;
|
|
3642
|
+
const tick = () => {
|
|
3643
|
+
modelAuthColdStartTimer = undefined;
|
|
3644
|
+
if (!modelAuthColdStartActive)
|
|
3645
|
+
return;
|
|
3646
|
+
// A concurrent sync may have already landed the key — stop as soon as we have it.
|
|
3647
|
+
if (readLocalModelAuthVaultKey() || modelAuthColdStartAttempts >= MODEL_AUTH_COLDSTART_MAX_ATTEMPTS) {
|
|
3648
|
+
stopModelAuthColdStart();
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
modelAuthColdStartAttempts++;
|
|
3652
|
+
void syncModelAuthFromControlPlane();
|
|
3653
|
+
modelAuthColdStartTimer = setTimeout(tick, MODEL_AUTH_COLDSTART_INTERVAL_MS);
|
|
3654
|
+
modelAuthColdStartTimer.unref?.();
|
|
3655
|
+
};
|
|
3656
|
+
modelAuthColdStartTimer = setTimeout(tick, MODEL_AUTH_COLDSTART_INTERVAL_MS);
|
|
3657
|
+
modelAuthColdStartTimer.unref?.();
|
|
3658
|
+
}
|
|
3520
3659
|
async function syncModelAuthFromControlPlane() {
|
|
3521
3660
|
if (!sessionAdvertiseTarget)
|
|
3522
3661
|
return;
|
|
@@ -3531,6 +3670,14 @@ async function syncModelAuthFromControlPlane() {
|
|
|
3531
3670
|
vaultKeyB64 = pairingStore.unwrapFromNodePublicKey(data.wrappedKey.wrappedByPublicKey, data.wrappedKey.wrappedKey);
|
|
3532
3671
|
writeLocalModelAuthVaultKey(vaultKeyB64);
|
|
3533
3672
|
}
|
|
3673
|
+
// Node-less inheritance: a lone HOSTED ephemeral (no peer to wrap the key)
|
|
3674
|
+
// adopts the vault key the control plane escrowed for this hosted account, so
|
|
3675
|
+
// it can decrypt the synced vault (incl. subscription OAuth) on cold start. The
|
|
3676
|
+
// control plane serves `hostedKey` only for hosted-provisioning accounts.
|
|
3677
|
+
if (!vaultKeyB64 && data.hostedKey && Buffer.from(data.hostedKey, "base64").length === 32) {
|
|
3678
|
+
vaultKeyB64 = data.hostedKey;
|
|
3679
|
+
writeLocalModelAuthVaultKey(vaultKeyB64);
|
|
3680
|
+
}
|
|
3534
3681
|
if (data.vault?.ciphertext && vaultKeyB64) {
|
|
3535
3682
|
const { providers, localModels } = decryptModelAuthEnvelope(data.vault.ciphertext, vaultKeyB64);
|
|
3536
3683
|
await importProviderAuth(credsDir, providers);
|
|
@@ -3540,10 +3687,17 @@ async function syncModelAuthFromControlPlane() {
|
|
|
3540
3687
|
await writePiModelsProjection();
|
|
3541
3688
|
await broadcastLocalModels();
|
|
3542
3689
|
lastPushedModelAuthCiphertext = data.vault.ciphertext;
|
|
3690
|
+
// Got the key and imported the vault (incl. any subscription-OAuth logins) —
|
|
3691
|
+
// the cold-start race is over.
|
|
3692
|
+
stopModelAuthColdStart();
|
|
3543
3693
|
broadcast({ type: "providers.list", providers: await listProvidersUnified() });
|
|
3544
3694
|
}
|
|
3545
3695
|
else if (data.vault?.ciphertext && !vaultKeyB64) {
|
|
3546
3696
|
await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
|
|
3697
|
+
// No peer has wrapped our key yet. Fast-retry (bounded) so a short-lived
|
|
3698
|
+
// ephemeral runner picks up the key within seconds of a peer answering,
|
|
3699
|
+
// rather than waiting for its next 30s poll.
|
|
3700
|
+
ensureModelAuthColdStart();
|
|
3547
3701
|
}
|
|
3548
3702
|
else if (Object.keys(await exportProviderAuth(credsDir)).length > 0 ||
|
|
3549
3703
|
Object.keys(exportLocalModels(localModelsDir)).length > 0) {
|
|
@@ -3590,6 +3744,13 @@ async function pushModelAuthToControlPlane() {
|
|
|
3590
3744
|
method: "PUT",
|
|
3591
3745
|
body: JSON.stringify({ targetNodeId: identity.nodeId, wrappedByPublicKey: pairingStore.nodePublicKeyB64(), wrappedKey: pairingStore.wrapForNodePublicKey(pairingStore.nodePublicKeyB64(), vaultKeyB64) }),
|
|
3592
3746
|
});
|
|
3747
|
+
// Node-less inheritance: a HOSTED node also escrows the vault key to the control
|
|
3748
|
+
// plane (sealed at rest, hosted-only) so the account's NEXT hosted ephemeral —
|
|
3749
|
+
// possibly the only node — can decrypt this vault without a peer to wrap the key.
|
|
3750
|
+
// Gated to hosted nodes; the CP double-checks the account is hosted. Best effort.
|
|
3751
|
+
if (process.env.BIVY_GITHUB_HOSTED_TASKS) {
|
|
3752
|
+
await modelAuthFetch("/node/model-auth-key/hosted-escrow", { method: "PUT", body: JSON.stringify({ vaultKeyB64 }) }).catch(() => { });
|
|
3753
|
+
}
|
|
3593
3754
|
lastPushedModelAuthCiphertext = ciphertext;
|
|
3594
3755
|
}
|
|
3595
3756
|
catch (error) {
|
|
@@ -4724,6 +4885,19 @@ async function runWorkItem(item, report) {
|
|
|
4724
4885
|
if (item.url)
|
|
4725
4886
|
issue.url = item.url;
|
|
4726
4887
|
}
|
|
4888
|
+
// Case B: the control plane asked us to CONTINUE an existing session for this
|
|
4889
|
+
// issue (an inbound comment/issue on a thread that already has one). If that
|
|
4890
|
+
// session isn't live on this node — e.g. its ephemeral machine was torn down —
|
|
4891
|
+
// best-effort restore its snapshot first so its transcript + branch state are
|
|
4892
|
+
// rebuilt and the work continues the thread instead of starting cold. On
|
|
4893
|
+
// failure we fall through to the normal idempotent, remote-branch-adopting
|
|
4894
|
+
// pickup, so this can only help, never break.
|
|
4895
|
+
const issueSource = `issue:${parsed.owner}/${parsed.repo}#${item.issueNumber}`;
|
|
4896
|
+
if (item.targetKind === "existing_session" && item.targetSessionId && !findIssueSession(issueSource)) {
|
|
4897
|
+
await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => {
|
|
4898
|
+
console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message);
|
|
4899
|
+
});
|
|
4900
|
+
}
|
|
4727
4901
|
await runIssueTask(cfg, issue, { runtimeId: item.runtimeId, model: item.model, onEvidence: report });
|
|
4728
4902
|
return;
|
|
4729
4903
|
}
|
|
@@ -5036,6 +5210,9 @@ async function startOAuthLogin(provider) {
|
|
|
5036
5210
|
// OpenAI's browser flow redirects to http://localhost:1455. Listen on IPv6
|
|
5037
5211
|
// wildcard so browsers resolving localhost to ::1 can reach the callback.
|
|
5038
5212
|
process.env.PI_OAUTH_CALLBACK_HOST ||= "::";
|
|
5213
|
+
// Opportunistically drop stale/abandoned logins whenever a new one starts, so a
|
|
5214
|
+
// long-lived node doesn't accumulate them between sweeps (and tests can drive it).
|
|
5215
|
+
sweepOauthLogins();
|
|
5039
5216
|
const id = randomUUID();
|
|
5040
5217
|
const abort = new AbortController();
|
|
5041
5218
|
const state = { id, provider, status: "starting", abort, createdAt: Date.now(), progress: [] };
|
|
@@ -5383,6 +5560,22 @@ async function standUpFork(opts) {
|
|
|
5383
5560
|
cwd = wt.path;
|
|
5384
5561
|
worktree = wt;
|
|
5385
5562
|
}
|
|
5563
|
+
else {
|
|
5564
|
+
// Non-repo-backed source. The fork would otherwise reuse the PARENT's cwd,
|
|
5565
|
+
// putting two sessions in one working tree — so when that cwd is itself a git
|
|
5566
|
+
// checkout (a local repo without a GitHub origin), cut the fork its own
|
|
5567
|
+
// worktree on a fresh branch. Best-effort: a non-git workspace has no tree to
|
|
5568
|
+
// isolate, so the fork keeps the fallback cwd (no git collisions possible).
|
|
5569
|
+
const forkRepoRoot = await gitRepoRoot(cwd);
|
|
5570
|
+
if (forkRepoRoot) {
|
|
5571
|
+
const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
|
|
5572
|
+
const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
|
|
5573
|
+
applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
5574
|
+
workspace = forkRepoRoot;
|
|
5575
|
+
cwd = wt.path;
|
|
5576
|
+
worktree = wt;
|
|
5577
|
+
}
|
|
5578
|
+
}
|
|
5386
5579
|
// Materialise the transcript, then stand the session up — resume the imported
|
|
5387
5580
|
// transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
|
|
5388
5581
|
const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
|
|
@@ -6142,10 +6335,153 @@ async function deleteSessionFile(opts) {
|
|
|
6142
6335
|
scheduleAdvertise();
|
|
6143
6336
|
return { sessionId: deletedSessionId, sessionFile: inRoot ? resolved : undefined };
|
|
6144
6337
|
}
|
|
6145
|
-
const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessions(); pruneEmptySessions(); }, idleCloseSweepMs);
|
|
6338
|
+
const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessions(); pruneEmptySessions(); evaluateEphemeralTeardown(); }, idleCloseSweepMs);
|
|
6146
6339
|
idleCloseTimer.unref?.();
|
|
6147
6340
|
const worktreeCleanupTimer = setInterval(() => void sweepDiskGuardrails(), worktreeCleanupSweepMs);
|
|
6148
6341
|
worktreeCleanupTimer.unref?.();
|
|
6342
|
+
// --- server-side ephemeral teardown ----------------------------------------
|
|
6343
|
+
// On a disposable machine (bootstrap set BIVY_EPHEMERAL=1) the daemon ends the
|
|
6344
|
+
// machine ITSELF once it goes idle, so teardown no longer needs the launching
|
|
6345
|
+
// device online. See src/ephemeral-teardown.ts + docs/ephemeral-sessions.md.
|
|
6346
|
+
const ephemeralTeardownCfg = readEphemeralTeardownConfig();
|
|
6347
|
+
let ephemeralEverBusy = false;
|
|
6348
|
+
let ephemeralLastBusyAt = Date.now();
|
|
6349
|
+
/** Best-effort "I've settled — reap me" signal to the control plane. Non-secret
|
|
6350
|
+
* (node id via the enrollment bearer); lets a hosted machine whose provider
|
|
6351
|
+
* can't self-reap on exit (Hetzner) be destroyed server-side. */
|
|
6352
|
+
async function signalSettledToControlPlane() {
|
|
6353
|
+
if (!sessionAdvertiseTarget)
|
|
6354
|
+
return;
|
|
6355
|
+
await fetch(`${sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "")}/node/settled`, {
|
|
6356
|
+
method: "POST",
|
|
6357
|
+
headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}`, "content-type": "application/json" },
|
|
6358
|
+
body: "{}",
|
|
6359
|
+
}).catch(() => { });
|
|
6360
|
+
}
|
|
6361
|
+
/** Rebuild-resume (Gap B): on a freshly re-provisioned machine booted with
|
|
6362
|
+
* `BIVY_RESTORE=<sessionId>`, fetch the session's control-plane snapshot,
|
|
6363
|
+
* decrypt it with this machine's room key (reused from the torn-down session so
|
|
6364
|
+
* the seal matches), and apply it — restoring the transcript (EventLog) and the
|
|
6365
|
+
* git checkpoint into a repo the session can open. The runtime process starts
|
|
6366
|
+
* fresh/seeded from the restored transcript ("reconstructed", not byte-identical
|
|
6367
|
+
* — see docs/ephemeral-sessions.md). Best-effort: a missing/undecryptable
|
|
6368
|
+
* snapshot leaves a clean fresh machine. Reuses the standby-replica machinery. */
|
|
6369
|
+
async function restoreSessionFromSnapshot(sessionId) {
|
|
6370
|
+
if (!sessionAdvertiseTarget)
|
|
6371
|
+
return;
|
|
6372
|
+
const cpBaseUrl = sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "");
|
|
6373
|
+
try {
|
|
6374
|
+
const res = await fetch(`${cpBaseUrl}/node/session-snapshot/${encodeURIComponent(sessionId)}`, {
|
|
6375
|
+
headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}` },
|
|
6376
|
+
});
|
|
6377
|
+
if (!res.ok) {
|
|
6378
|
+
console.error(`[restore] no snapshot for ${sessionId} (${res.status})`);
|
|
6379
|
+
return;
|
|
6380
|
+
}
|
|
6381
|
+
const data = (await res.json());
|
|
6382
|
+
if (!data.ciphertext)
|
|
6383
|
+
return;
|
|
6384
|
+
const applied = await applySessionSnapshot(data.ciphertext, pairingStore.roomKey(), {
|
|
6385
|
+
persistRecords: (id, records) => eventLog.rewrite(id, records),
|
|
6386
|
+
applyBundle: async (id, buf) => applyCheckpointBundle(await ensureReplicaRepo(id), id, buf),
|
|
6387
|
+
materialize: async (id) => materializeCheckpoint(await ensureReplicaRepo(id), id),
|
|
6388
|
+
});
|
|
6389
|
+
// Register the rebuilt session so it lists and opens (mirrors the standby's
|
|
6390
|
+
// upsertReplicaMeta); the transcript replays from the restored EventLog.
|
|
6391
|
+
try {
|
|
6392
|
+
metadata.upsertSession({ id: sessionId, source: "restored", status: "saved" });
|
|
6393
|
+
}
|
|
6394
|
+
catch {
|
|
6395
|
+
/* best-effort listing */
|
|
6396
|
+
}
|
|
6397
|
+
console.log(`[restore] session ${sessionId}: ${applied.recordCount} records, checkpoint ${applied.checkpointCommit ?? "none"}`);
|
|
6398
|
+
}
|
|
6399
|
+
catch (e) {
|
|
6400
|
+
console.error(`[restore] session ${sessionId} failed: ${e?.message || e}`);
|
|
6401
|
+
}
|
|
6402
|
+
}
|
|
6403
|
+
/** Flush a durable, E2E-encrypted snapshot of each open session to the control
|
|
6404
|
+
* plane before this disposable machine is torn down, so a destroy-lane session
|
|
6405
|
+
* can be rebuilt on a fresh machine later (Gap B). Sealed under the node room
|
|
6406
|
+
* key — the same key that seals the session title — so a restore machine that
|
|
6407
|
+
* reuses this session's room key can decrypt it; the control plane sees only
|
|
6408
|
+
* ciphertext. Best-effort per session; never blocks teardown for long. */
|
|
6409
|
+
async function flushSessionSnapshots() {
|
|
6410
|
+
if (!sessionAdvertiseTarget)
|
|
6411
|
+
return;
|
|
6412
|
+
const roomKey = pairingStore.roomKey();
|
|
6413
|
+
const cpBaseUrl = sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "");
|
|
6414
|
+
for (const record of new Set(openSessions.values())) {
|
|
6415
|
+
try {
|
|
6416
|
+
const sealed = await buildSessionSnapshot(record.id, roomKey, {
|
|
6417
|
+
readRecords: (id) => eventLog.entries(id),
|
|
6418
|
+
epochOf: () => 0,
|
|
6419
|
+
checkpointHead: async (id) => {
|
|
6420
|
+
try {
|
|
6421
|
+
return (await harness.checkpoints(id))[0]?.id;
|
|
6422
|
+
}
|
|
6423
|
+
catch {
|
|
6424
|
+
return undefined;
|
|
6425
|
+
}
|
|
6426
|
+
},
|
|
6427
|
+
bundleCheckpoint: async (id, since) => createCheckpointBundle(harnessDirFor(record), id, since),
|
|
6428
|
+
runtimeSessionRef: (id) => openSessions.get(id)?.sessionFile,
|
|
6429
|
+
worktreeSync: () => true,
|
|
6430
|
+
});
|
|
6431
|
+
if (!sealed)
|
|
6432
|
+
continue;
|
|
6433
|
+
await fetch(`${cpBaseUrl}/node/session-snapshot/${encodeURIComponent(record.id)}`, {
|
|
6434
|
+
method: "PUT",
|
|
6435
|
+
headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}`, "content-type": "application/json" },
|
|
6436
|
+
body: JSON.stringify({ ciphertext: sealed }),
|
|
6437
|
+
}).catch(() => { });
|
|
6438
|
+
}
|
|
6439
|
+
catch {
|
|
6440
|
+
/* best effort per session — TTL/branch push remain the backstops */
|
|
6441
|
+
}
|
|
6442
|
+
}
|
|
6443
|
+
}
|
|
6444
|
+
let ephemeralTearingDown = false;
|
|
6445
|
+
/** Evaluate the quiet condition and self-terminate if the machine is done. Reads
|
|
6446
|
+
* live session/queue state each call, so it's safe to invoke from the idle
|
|
6447
|
+
* sweep and from agent_end. No-op on a persistent node (env absent). */
|
|
6448
|
+
function evaluateEphemeralTeardown() {
|
|
6449
|
+
if (!ephemeralTeardownCfg.enabled || ephemeralTearingDown)
|
|
6450
|
+
return;
|
|
6451
|
+
const records = new Set(openSessions.values());
|
|
6452
|
+
const anyWorking = [...records].some((r) => r.isWorking);
|
|
6453
|
+
const anyRemoteActive = [...records].some((r) => r.remoteActive);
|
|
6454
|
+
const inFlightWork = controlPlanePoller?.inFlightCount() ?? 0;
|
|
6455
|
+
if (anyWorking || anyRemoteActive || inFlightWork > 0) {
|
|
6456
|
+
ephemeralEverBusy = true;
|
|
6457
|
+
ephemeralLastBusyAt = Date.now();
|
|
6458
|
+
return;
|
|
6459
|
+
}
|
|
6460
|
+
const idleForMs = Date.now() - ephemeralLastBusyAt;
|
|
6461
|
+
if (!shouldSelfTeardown(ephemeralTeardownCfg, { everBusy: ephemeralEverBusy, anyWorking, anyRemoteActive, inFlightWork, idleForMs }))
|
|
6462
|
+
return;
|
|
6463
|
+
ephemeralTearingDown = true;
|
|
6464
|
+
void (async () => {
|
|
6465
|
+
// Persist a rebuild snapshot BEFORE the machine goes away (Gap B), then reap.
|
|
6466
|
+
await flushSessionSnapshots();
|
|
6467
|
+
await performSelfTeardown({
|
|
6468
|
+
provider: ephemeralTeardownCfg.provider,
|
|
6469
|
+
signalSettled: signalSettledToControlPlane,
|
|
6470
|
+
shutdown: () => { try {
|
|
6471
|
+
spawnSync("shutdown", ["-h", "now"], { stdio: "ignore" });
|
|
6472
|
+
}
|
|
6473
|
+
catch { /* TTL backstops */ } },
|
|
6474
|
+
});
|
|
6475
|
+
})();
|
|
6476
|
+
}
|
|
6477
|
+
if (ephemeralTeardownCfg.enabled) {
|
|
6478
|
+
// Sample often enough to honour the finish grace (~10s) without waiting for the
|
|
6479
|
+
// 1–5min idle sweep. Ephemeral-only, so no cost on a persistent node.
|
|
6480
|
+
const ephemeralEvalMs = Math.max(2_000, Math.min(ephemeralTeardownCfg.finishGraceMs, 15_000));
|
|
6481
|
+
const ephemeralTeardownTimer = setInterval(() => evaluateEphemeralTeardown(), ephemeralEvalMs);
|
|
6482
|
+
ephemeralTeardownTimer.unref?.();
|
|
6483
|
+
console.log(`[ephemeral-teardown] armed: provider=${ephemeralTeardownCfg.provider} onFinish=${ephemeralTeardownCfg.onFinish} ttl=${ephemeralTeardownCfg.ttlMin}m`);
|
|
6484
|
+
}
|
|
6149
6485
|
setTimeout(() => void sweepDiskGuardrails(), 30_000).unref?.();
|
|
6150
6486
|
// One sweep shortly after boot clears ghosts left by a previous run before any
|
|
6151
6487
|
// client paints its sidebar; the idle timer keeps it clean thereafter.
|
|
@@ -6382,6 +6718,9 @@ function attachSessionListeners(record) {
|
|
|
6382
6718
|
// itself (gh/API/web) so the badge lights up.
|
|
6383
6719
|
void maybePushWorktreeBranch(record)
|
|
6384
6720
|
.then(() => maybeDetectPullRequest(record));
|
|
6721
|
+
// On a disposable machine, a finished turn with nobody watching is the cue
|
|
6722
|
+
// to consider self-teardown promptly (the idle sweep is the backstop).
|
|
6723
|
+
evaluateEphemeralTeardown();
|
|
6385
6724
|
}
|
|
6386
6725
|
const sessionEventPayload = { type: "session.event", sessionId: record.id, event };
|
|
6387
6726
|
if (event.type === "message_update") {
|
|
@@ -6876,9 +7215,52 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
6876
7215
|
if (!admission.allowed)
|
|
6877
7216
|
throw new Error(`Not enough disk to start a new worktree session: ${admission.reason}`);
|
|
6878
7217
|
const wtOpts = typeof opts.worktree === "object" ? opts.worktree : {};
|
|
6879
|
-
|
|
7218
|
+
// A random suffix, never a timestamp: two sessions started in the same
|
|
7219
|
+
// millisecond would otherwise resolve to the same slug → same worktree path
|
|
7220
|
+
// and branch, and `createWorktree` would ADOPT the first's worktree, dropping
|
|
7221
|
+
// the second session into a directory another session already owns.
|
|
7222
|
+
worktree = await createWorktree({ repoDir: workspace, id: wtOpts.branch ?? `session-${randomBytes(6).toString("hex")}`, branch: wtOpts.branch, base: wtOpts.base });
|
|
6880
7223
|
runtimeWorkspace = worktree.path;
|
|
6881
7224
|
}
|
|
7225
|
+
// Resume with a reaped worktree. When a repo-backed session is resumed but its
|
|
7226
|
+
// worktree directory was removed while it was closed (disk cleanup, a manual
|
|
7227
|
+
// rm, `git worktree remove`), restoredWorktree is undefined and we'd otherwise
|
|
7228
|
+
// fall back to the shared clone root — which the invariant below then rejects.
|
|
7229
|
+
// Re-provision a fresh worktree on the SAME branch instead (branches survive
|
|
7230
|
+
// `git worktree remove`, so the agent's committed history is intact), restoring
|
|
7231
|
+
// isolation so the resumed session is usable again. Best-effort: if the clone
|
|
7232
|
+
// or branch is gone, we leave it to the invariant to fail safe rather than
|
|
7233
|
+
// corrupt a neighbour. The clone root is reconstructed from `source`
|
|
7234
|
+
// (`repo:owner/repo`) because stored `workspace` is the old worktree path.
|
|
7235
|
+
if (requestedSessionFile && !worktree && storedMeta?.worktree && storedMeta?.branch) {
|
|
7236
|
+
const parsedSource = parseRepoSource(storedMeta.source);
|
|
7237
|
+
if (parsedSource) {
|
|
7238
|
+
const repoDir = path.join(reposRoot, `${parsedSource.owner}__${parsedSource.repo}`);
|
|
7239
|
+
try {
|
|
7240
|
+
// Clear any stale registration left by a dir that was rm'd out from under
|
|
7241
|
+
// git, so re-adding the branch's worktree doesn't hit "already checked out".
|
|
7242
|
+
runGit(["worktree", "prune"], repoDir);
|
|
7243
|
+
const reprovisioned = await createWorktree({ repoDir, id: storedMeta.branch, branch: storedMeta.branch });
|
|
7244
|
+
worktree = reprovisioned;
|
|
7245
|
+
runtimeWorkspace = reprovisioned.path;
|
|
7246
|
+
}
|
|
7247
|
+
catch (error) {
|
|
7248
|
+
console.warn(`Could not re-provision worktree for resumed session on ${storedMeta.branch}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7249
|
+
}
|
|
7250
|
+
}
|
|
7251
|
+
}
|
|
7252
|
+
// Isolation invariant. A session must NEVER run directly in a Bivy-managed
|
|
7253
|
+
// shared clone root (`<reposRoot>/owner__repo`): every session for that repo
|
|
7254
|
+
// shares that one checkout, so an agent running there collides with concurrent
|
|
7255
|
+
// sessions on `git checkout`/`git stash` — exactly the "sessions mixing" bug.
|
|
7256
|
+
// A GitHub-backed session is supposed to get its own worktree; reaching here
|
|
7257
|
+
// without one means an earlier step degraded (e.g. a transient repo-inference
|
|
7258
|
+
// failure, or a resume whose worktree was reaped). Fail loudly instead of
|
|
7259
|
+
// silently sharing the tree and corrupting a neighbouring session's work.
|
|
7260
|
+
if (!worktree && isSharedCloneRoot(runtimeWorkspace, reposRoot)) {
|
|
7261
|
+
throw new Error(`Refusing to start a session in the shared clone root ${runtimeWorkspace} without an isolated worktree — ` +
|
|
7262
|
+
`this would collide with concurrent sessions on the same repo. Retry; if it persists the checkout may be busy.`);
|
|
7263
|
+
}
|
|
6882
7264
|
const runtimeSessionOptions = { workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
|
|
6883
7265
|
// Stage 2/3: prefer re-attaching to a still-live remote session — routed to its
|
|
6884
7266
|
// OWN agent service — over re-opening a fresh copy from disk. Falls back to
|
|
@@ -6906,8 +7288,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
6906
7288
|
if (requestedSessionFile && storedMeta?.name && !session.getName())
|
|
6907
7289
|
session.setName(storedMeta.name);
|
|
6908
7290
|
const sessionWorkspace = session.cwd || runtimeWorkspace;
|
|
6909
|
-
|
|
6910
|
-
|
|
7291
|
+
// Best-effort here (unlike createWorkspaceSession, which must fail loudly):
|
|
7292
|
+
// this only decides whether to ADOPT an already-checked-out branch as the
|
|
7293
|
+
// session's worktree label, so a transient inference failure should quietly
|
|
7294
|
+
// skip adoption rather than break resuming the session.
|
|
7295
|
+
const inferredRepo = opts.source || storedMeta?.source ? undefined : await inferGitHubRepoFromWorkspace(sessionWorkspace).catch(() => undefined);
|
|
7296
|
+
if (!worktree && requestedSessionFile && inferredRepo && !isSharedCloneRoot(sessionWorkspace, reposRoot)) {
|
|
6911
7297
|
const branch = runGit(["branch", "--show-current"], sessionWorkspace) || runGit(["rev-parse", "--short", "HEAD"], sessionWorkspace) || undefined;
|
|
6912
7298
|
if (branch) {
|
|
6913
7299
|
const mainWorktree = runGit(["worktree", "list", "--porcelain"], sessionWorkspace)?.split("\n").find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
|
|
@@ -9172,6 +9558,29 @@ app.get("/api/attachment/:hash", (req, res) => {
|
|
|
9172
9558
|
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
|
9173
9559
|
res.end(bytes);
|
|
9174
9560
|
});
|
|
9561
|
+
// Let an AGENT push a file into the chat as an attachment (image/file) — the
|
|
9562
|
+
// reverse of the composer upload. Called by the agent's own shell (`bivy attach`)
|
|
9563
|
+
// or any local tool; on a single-user host the loopback bypass means no token is
|
|
9564
|
+
// needed. Behind /api's authMiddleware. `path` is resolved inside — and confined
|
|
9565
|
+
// to — the session's workspace (see planAttachment's security note).
|
|
9566
|
+
app.post("/api/session/:id/attach", (req, res) => {
|
|
9567
|
+
const record = openSessions.get(String(req.params.id));
|
|
9568
|
+
if (!record)
|
|
9569
|
+
return res.status(404).json({ error: "Session not found" });
|
|
9570
|
+
const filePath = String(req.body?.path ?? req.body?.filePath ?? "").trim();
|
|
9571
|
+
if (!filePath)
|
|
9572
|
+
return res.status(400).json({ error: "Missing file path" });
|
|
9573
|
+
const result = attachToChat(record, {
|
|
9574
|
+
filePath,
|
|
9575
|
+
caption: typeof req.body?.caption === "string" ? req.body.caption : undefined,
|
|
9576
|
+
mimeType: typeof req.body?.mimeType === "string" ? req.body.mimeType : undefined,
|
|
9577
|
+
name: typeof req.body?.name === "string" ? req.body.name : undefined,
|
|
9578
|
+
});
|
|
9579
|
+
if ("error" in result)
|
|
9580
|
+
return res.status(400).json({ error: result.error });
|
|
9581
|
+
const { hash, name, mimeType, size, kind } = result.ref;
|
|
9582
|
+
res.json({ ok: true, hash, name, mimeType, size, kind });
|
|
9583
|
+
});
|
|
9175
9584
|
app.post("/api/session/prompt", async (req, res, next) => {
|
|
9176
9585
|
try {
|
|
9177
9586
|
const text = String(req.body?.text ?? "").trim();
|
|
@@ -9432,7 +9841,12 @@ const server = app.listen(port, host, async () => {
|
|
|
9432
9841
|
console.log(`Agent data dir: ${piDir}`);
|
|
9433
9842
|
console.log(`Workspace: ${defaultWorkspace}`);
|
|
9434
9843
|
startRelayIfConfigured();
|
|
9844
|
+
// Rebuild-resume (Gap B): if this machine was re-provisioned to restore a
|
|
9845
|
+
// torn-down session, pull + apply its snapshot before serving. Non-blocking.
|
|
9846
|
+
if (process.env.BIVY_RESTORE)
|
|
9847
|
+
void restoreSessionFromSnapshot(String(process.env.BIVY_RESTORE));
|
|
9435
9848
|
startModelAuthWatcher();
|
|
9849
|
+
startOAuthLoginSweeper();
|
|
9436
9850
|
startGithubAppSyncWatcher();
|
|
9437
9851
|
await startGitHubTasksIfConfigured();
|
|
9438
9852
|
startControlPlaneTasksIfConfigured();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Plan an AGENT-sent chat attachment: the reverse of the composer paperclip.
|
|
5
|
+
// An agent points at a file it produced in the workspace (a rendered chart, a
|
|
6
|
+
// screenshot, a report), and Bivy surfaces it into the chat as an image/file
|
|
7
|
+
// chip. This module is the PURE, testable half — path confinement, size cap, and
|
|
8
|
+
// mime/kind classification — returning bytes + metadata (or a human-readable
|
|
9
|
+
// error). The server half stores the bytes in the content-addressed
|
|
10
|
+
// AttachmentStore, emits the live `attachment` event, and persists the outbound
|
|
11
|
+
// reference for durable history.
|
|
12
|
+
//
|
|
13
|
+
// Security posture: the resolved file MUST live inside the session's working
|
|
14
|
+
// directory. An agent is a semi-trusted process; without confinement, a prompt
|
|
15
|
+
// injection could turn "attach a file to the chat" into "exfiltrate /etc/passwd
|
|
16
|
+
// (or ~/.ssh/id_rsa) to the user's phone". Symlinks are resolved before the
|
|
17
|
+
// check so a symlink inside the workspace can't point out of it.
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
/** Ceiling for a single agent attachment. Kept comfortably under the relay's
|
|
21
|
+
* 32 MiB reassembly limit (see packages/core/src/wire-format.ts) so a large
|
|
22
|
+
* attachment still travels to a phone over the encrypted relay in chunks. */
|
|
23
|
+
export const MAX_AGENT_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
24
|
+
export function isAttachPlanError(value) {
|
|
25
|
+
return typeof value.error === "string";
|
|
26
|
+
}
|
|
27
|
+
const EXT_MIME = {
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".svg": "image/svg+xml",
|
|
34
|
+
".bmp": "image/bmp",
|
|
35
|
+
".ico": "image/x-icon",
|
|
36
|
+
".avif": "image/avif",
|
|
37
|
+
".pdf": "application/pdf",
|
|
38
|
+
".txt": "text/plain",
|
|
39
|
+
".md": "text/markdown",
|
|
40
|
+
".csv": "text/csv",
|
|
41
|
+
".json": "application/json",
|
|
42
|
+
".html": "text/html",
|
|
43
|
+
".zip": "application/zip",
|
|
44
|
+
};
|
|
45
|
+
/** Sniff a mime type from the leading magic bytes for the common image/PDF
|
|
46
|
+
* formats, so a mislabeled or extension-less file still classifies correctly.
|
|
47
|
+
* Returns "" when nothing matches (caller falls back to extension/default). */
|
|
48
|
+
export function sniffMime(bytes) {
|
|
49
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
50
|
+
return "image/png";
|
|
51
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
52
|
+
return "image/jpeg";
|
|
53
|
+
if (bytes.length >= 6 && (bytes.subarray(0, 6).toString("latin1") === "GIF87a" || bytes.subarray(0, 6).toString("latin1") === "GIF89a"))
|
|
54
|
+
return "image/gif";
|
|
55
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP")
|
|
56
|
+
return "image/webp";
|
|
57
|
+
if (bytes.length >= 5 && bytes.subarray(0, 5).toString("latin1") === "%PDF-")
|
|
58
|
+
return "application/pdf";
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
/** Strip directory components and control/path characters from a filename so it
|
|
62
|
+
* is safe to show and to store as an attachment display name. */
|
|
63
|
+
export function sanitizeAttachmentName(name) {
|
|
64
|
+
const base = path.basename(String(name || "").trim());
|
|
65
|
+
const cleaned = base
|
|
66
|
+
.replace(/[/\\]+/g, "_")
|
|
67
|
+
// eslint-disable-next-line no-control-regex
|
|
68
|
+
.replace(/[\x00-\x1f]+/g, "")
|
|
69
|
+
.trim();
|
|
70
|
+
return cleaned.slice(0, 200) || "attachment";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve, confine, size-check, read, and classify a file the agent asked to
|
|
74
|
+
* attach. `filePath` may be absolute or relative to `workspaceDir`; either way
|
|
75
|
+
* the resolved real path must sit inside `workspaceDir`.
|
|
76
|
+
*/
|
|
77
|
+
export function planAttachment(opts) {
|
|
78
|
+
const raw = String(opts.filePath || "").trim();
|
|
79
|
+
if (!raw)
|
|
80
|
+
return { error: "No file path given." };
|
|
81
|
+
const workspaceDir = path.resolve(opts.workspaceDir);
|
|
82
|
+
const resolved = path.resolve(workspaceDir, raw);
|
|
83
|
+
// Confinement, symlink-safe: resolve the real path of the file before comparing
|
|
84
|
+
// against the real workspace root. realpathSync also fails cleanly for a missing
|
|
85
|
+
// file.
|
|
86
|
+
let realFile;
|
|
87
|
+
let realRoot;
|
|
88
|
+
try {
|
|
89
|
+
realRoot = fs.realpathSync(workspaceDir);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { error: "Workspace directory is unavailable." };
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
realFile = fs.realpathSync(resolved);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { error: `File not found: ${raw}` };
|
|
99
|
+
}
|
|
100
|
+
const rel = path.relative(realRoot, realFile);
|
|
101
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
102
|
+
return { error: "Refusing to attach a file outside the session workspace." };
|
|
103
|
+
}
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = fs.statSync(realFile);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { error: `File not found: ${raw}` };
|
|
110
|
+
}
|
|
111
|
+
if (stat.isDirectory())
|
|
112
|
+
return { error: `Not a file: ${raw}` };
|
|
113
|
+
const maxBytes = opts.maxBytes ?? MAX_AGENT_ATTACHMENT_BYTES;
|
|
114
|
+
if (stat.size > maxBytes) {
|
|
115
|
+
return { error: `File is too large to attach (${stat.size} bytes; limit ${maxBytes}).` };
|
|
116
|
+
}
|
|
117
|
+
if (stat.size === 0)
|
|
118
|
+
return { error: "Refusing to attach an empty file." };
|
|
119
|
+
let bytes;
|
|
120
|
+
try {
|
|
121
|
+
bytes = fs.readFileSync(realFile);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { error: `Could not read file: ${raw}` };
|
|
125
|
+
}
|
|
126
|
+
const ext = path.extname(realFile).toLowerCase();
|
|
127
|
+
const mimeType = (opts.mimeType && String(opts.mimeType).trim()) ||
|
|
128
|
+
sniffMime(bytes) ||
|
|
129
|
+
EXT_MIME[ext] ||
|
|
130
|
+
"application/octet-stream";
|
|
131
|
+
const kind = mimeType.startsWith("image/") ? "image" : "file";
|
|
132
|
+
const name = sanitizeAttachmentName(opts.name || path.basename(realFile));
|
|
133
|
+
return { bytes, name, mimeType, kind };
|
|
134
|
+
}
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
// interleaved in any order on disk; each replay reads only its own kind.
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
|
|
31
|
+
/** Content-block type carried by a folded outbound attachment. MUST match
|
|
32
|
+
* `AGENT_ATTACHMENT_BLOCK` in packages/core/src/store-render.ts — the client's
|
|
33
|
+
* renderHistory keys on this exact string to render the chip. */
|
|
34
|
+
const AGENT_ATTACHMENT_BLOCK = "bivy_attachment";
|
|
31
35
|
function isOverlay(value) {
|
|
32
36
|
if (!value || typeof value !== "object")
|
|
33
37
|
return false;
|
|
@@ -46,8 +50,19 @@ function isAttachment(value) {
|
|
|
46
50
|
const record = value;
|
|
47
51
|
return record.bivyKind === "attachment" && typeof record.text === "string" && Array.isArray(record.refs);
|
|
48
52
|
}
|
|
53
|
+
function isOutboundAttachment(value) {
|
|
54
|
+
if (!value || typeof value !== "object")
|
|
55
|
+
return false;
|
|
56
|
+
const record = value;
|
|
57
|
+
return (record.bivyKind === "outbound-attachment" &&
|
|
58
|
+
typeof record.afterMessageCount === "number" &&
|
|
59
|
+
typeof record.id === "string" &&
|
|
60
|
+
!!record.ref &&
|
|
61
|
+
typeof record.ref === "object" &&
|
|
62
|
+
typeof record.ref.hash === "string");
|
|
63
|
+
}
|
|
49
64
|
function isRecord(value) {
|
|
50
|
-
return isOverlay(value) || isBase(value) || isAttachment(value);
|
|
65
|
+
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value);
|
|
51
66
|
}
|
|
52
67
|
/**
|
|
53
68
|
* Fold attachment records into a text→refs list: last write wins per text (a
|
|
@@ -132,7 +147,32 @@ export function replayExtras(entries) {
|
|
|
132
147
|
else if (entry.bivyKind === "tool")
|
|
133
148
|
tool.push(entry);
|
|
134
149
|
}
|
|
135
|
-
return [...foldIntermediate(intermediate), ...foldTool(tool)];
|
|
150
|
+
return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries)];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Fold the outbound (agent-sent) attachment records into time-anchored synthetic
|
|
154
|
+
* assistant messages `mergeTranscript` interleaves into the transcript. Last write
|
|
155
|
+
* wins per id (a re-emitted id updates in place, matching the log's coalescing),
|
|
156
|
+
* preserving first-seen order. Each becomes one `bivy_attachment` block the client
|
|
157
|
+
* renders as a chip/thumbnail.
|
|
158
|
+
*/
|
|
159
|
+
export function replayOutboundAttachments(entries) {
|
|
160
|
+
const byId = new Map();
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (entry.bivyKind !== "outbound-attachment")
|
|
163
|
+
continue;
|
|
164
|
+
// set() on an existing key updates the value in place (Map keeps first-seen
|
|
165
|
+
// insertion order), so last write wins while position is stable. Final
|
|
166
|
+
// placement is by time in mergeTranscript regardless.
|
|
167
|
+
byId.set(entry.id, entry);
|
|
168
|
+
}
|
|
169
|
+
return [...byId.values()].map((entry) => ({
|
|
170
|
+
role: "assistant",
|
|
171
|
+
content: [{ type: AGENT_ATTACHMENT_BLOCK, ref: entry.ref, caption: entry.caption }],
|
|
172
|
+
afterMessageCount: entry.afterMessageCount,
|
|
173
|
+
createdAt: entry.createdAt,
|
|
174
|
+
id: entry.id,
|
|
175
|
+
}));
|
|
136
176
|
}
|
|
137
177
|
/**
|
|
138
178
|
* Replay a session's base records into the base transcript: `reset` replaces the
|
|
@@ -286,6 +326,23 @@ export class EventLog {
|
|
|
286
326
|
readAttachments(id) {
|
|
287
327
|
return replayAttachments(this.entries(id));
|
|
288
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Record an agent-sent (outbound) attachment, anchored at the current base
|
|
331
|
+
* length so history replay interleaves it where it was emitted. Coalesces on
|
|
332
|
+
* the transcript-entry id so a re-emit of the same attachment updates in place.
|
|
333
|
+
*/
|
|
334
|
+
appendOutboundAttachment(id, entry) {
|
|
335
|
+
this.load(id);
|
|
336
|
+
const record = {
|
|
337
|
+
bivyKind: "outbound-attachment",
|
|
338
|
+
createdAt: Date.now(),
|
|
339
|
+
afterMessageCount: entry.afterMessageCount,
|
|
340
|
+
id: entry.id,
|
|
341
|
+
ref: { ...entry.ref },
|
|
342
|
+
...(entry.caption ? { caption: entry.caption } : {}),
|
|
343
|
+
};
|
|
344
|
+
this.enqueue(id, `oa:${entry.id}`, record);
|
|
345
|
+
}
|
|
289
346
|
/** Replay the overlay entries (disk + pending) into the flat `extras` list. */
|
|
290
347
|
read(id) {
|
|
291
348
|
return replayExtras(this.entries(id));
|
|
@@ -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
|
+
}
|
package/dist/wire-format.js
CHANGED
|
@@ -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;
|