@bivy/bivy 0.6.0-staging.87 → 0.6.0-staging.89
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/dist/harness/egress.js
CHANGED
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
// subprocess — without process.ts importing the server. Opt-in via the
|
|
8
8
|
// BIVY_EGRESS_PROXY env var, so routing all agent traffic through the broker is
|
|
9
9
|
// an explicit choice (it adds a hop and logs destinations).
|
|
10
|
-
import { EgressProxy } from "./net-proxy.js";
|
|
10
|
+
import { EgressProxy, denyAllDecider } from "./net-proxy.js";
|
|
11
11
|
let proxy;
|
|
12
|
+
// Per-session egress proxies, keyed by session id. This is the plan's
|
|
13
|
+
// "per-workflow proxy/decider, never the singleton": a session that needs its own
|
|
14
|
+
// network policy (e.g. a read-only sandbox that must actually block egress, or a
|
|
15
|
+
// workflow with an allowlist) gets its OWN EgressProxy with its OWN decider,
|
|
16
|
+
// injected into just that session's subprocess — the node-global `proxy` above and
|
|
17
|
+
// every other session are untouched. Empty by default, so nothing here changes the
|
|
18
|
+
// default path.
|
|
19
|
+
const sessionProxies = new Map();
|
|
12
20
|
/** Start the egress proxy if BIVY_EGRESS_PROXY is set. Idempotent. */
|
|
13
21
|
export async function startEgressProxyIfEnabled(onEvent) {
|
|
14
22
|
if (proxy)
|
|
@@ -28,3 +36,58 @@ export async function stopEgressProxy() {
|
|
|
28
36
|
await proxy.stop();
|
|
29
37
|
proxy = undefined;
|
|
30
38
|
}
|
|
39
|
+
// --- Per-session egress (the per-workflow proxy/decider) --------------------
|
|
40
|
+
/**
|
|
41
|
+
* Start a per-session egress proxy governed by `decide`, keyed to `sessionId`.
|
|
42
|
+
* Its `env()` is what `sessionEgressEnv(sessionId)` returns, so the runtime
|
|
43
|
+
* injects it into that session's subprocess *instead of* the node-global proxy.
|
|
44
|
+
* Idempotent per session. Best-effort — a listen failure leaves the session on the
|
|
45
|
+
* default path rather than blocking it.
|
|
46
|
+
*/
|
|
47
|
+
export async function startSessionEgress(sessionId, decide, onEvent) {
|
|
48
|
+
if (sessionProxies.has(sessionId))
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
const p = await EgressProxy.start({ decide, onEvent });
|
|
52
|
+
// A concurrent start for the same id won the race — keep the first, stop this.
|
|
53
|
+
if (sessionProxies.has(sessionId)) {
|
|
54
|
+
await p.stop();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
sessionProxies.set(sessionId, p);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Leave the session on the default egress path (global proxy or none).
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The per-session proxy env to inject for `sessionId`, or undefined when it has
|
|
64
|
+
* none (the caller then falls back to the node-global `egressEnv()`). */
|
|
65
|
+
export function sessionEgressEnv(sessionId) {
|
|
66
|
+
return sessionProxies.get(sessionId)?.env();
|
|
67
|
+
}
|
|
68
|
+
/** Tear down a session's own egress proxy (call on session close). Idempotent. */
|
|
69
|
+
export async function stopSessionEgress(sessionId) {
|
|
70
|
+
const p = sessionProxies.get(sessionId);
|
|
71
|
+
if (!p)
|
|
72
|
+
return;
|
|
73
|
+
sessionProxies.delete(sessionId);
|
|
74
|
+
await p.stop().catch(() => { });
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Apply the sandbox tier's network policy to a session as a per-session proxy.
|
|
78
|
+
* `read-only` means "no writes, no network" (see sandbox.ts), but only agents with
|
|
79
|
+
* a native sandbox enforce the network half — a CLI agent without one (opencode,
|
|
80
|
+
* aider, goose) would still reach the internet. When enforcement is opted in
|
|
81
|
+
* (`BIVY_SANDBOX_NET`), a read-only session gets a deny-all egress proxy so the
|
|
82
|
+
* contract holds for every agent. Other tiers (workspace-write, danger-full-access)
|
|
83
|
+
* allow network and get no per-session proxy. No-op unless opted in, so the default
|
|
84
|
+
* path is unchanged. Node-local traffic (the daemon's MCP/API) is exempt via the
|
|
85
|
+
* proxy env's NO_PROXY, so read-only sessions keep working against localhost.
|
|
86
|
+
*/
|
|
87
|
+
export async function applySessionSandboxEgress(sessionId, tier, onEvent) {
|
|
88
|
+
if (!process.env.BIVY_SANDBOX_NET)
|
|
89
|
+
return;
|
|
90
|
+
if (tier !== "read-only")
|
|
91
|
+
return;
|
|
92
|
+
await startSessionEgress(sessionId, denyAllDecider(), onEvent);
|
|
93
|
+
}
|
|
@@ -18,6 +18,34 @@
|
|
|
18
18
|
// networking, unit-tested in test/harness-net-proxy.test.ts.
|
|
19
19
|
import http from "node:http";
|
|
20
20
|
import net from "node:net";
|
|
21
|
+
/** Allow every destination (the proxy's default — pure observe-and-log). */
|
|
22
|
+
export const allowAllDecider = () => ({ allow: true });
|
|
23
|
+
/**
|
|
24
|
+
* Deny every destination. Used for a per-session egress proxy that enforces the
|
|
25
|
+
* `read-only` sandbox tier's "no network" contract for agents whose own sandbox
|
|
26
|
+
* doesn't (see egress.ts). Node-local traffic never reaches here — the proxy env's
|
|
27
|
+
* NO_PROXY exempts localhost — so the agent can still reach the daemon's own MCP/API.
|
|
28
|
+
*/
|
|
29
|
+
export function denyAllDecider(reason = "read-only sandbox: outbound network is disabled") {
|
|
30
|
+
return () => ({ allow: false, reason });
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Allow only hosts in `hosts` (exact, or a subdomain of a listed apex — "api.x.com"
|
|
34
|
+
* matches an entry "x.com"), denying everything else. The building block for a
|
|
35
|
+
* per-workflow egress allowlist that never touches the node-global decider. Host
|
|
36
|
+
* matching is case-insensitive; an empty list denies all.
|
|
37
|
+
*/
|
|
38
|
+
export function allowlistDecider(hosts, reason = "not on this session's egress allowlist") {
|
|
39
|
+
const allow = new Set(hosts.map((h) => h.trim().toLowerCase()).filter(Boolean));
|
|
40
|
+
return (host) => {
|
|
41
|
+
const h = host.trim().toLowerCase();
|
|
42
|
+
for (const entry of allow) {
|
|
43
|
+
if (h === entry || h.endsWith(`.${entry}`))
|
|
44
|
+
return { allow: true };
|
|
45
|
+
}
|
|
46
|
+
return { allow: false, reason };
|
|
47
|
+
};
|
|
48
|
+
}
|
|
21
49
|
/** Split "host:port" (CONNECT target) into parts, defaulting the port. */
|
|
22
50
|
export function parseHostPort(authority, defaultPort) {
|
|
23
51
|
// IPv6 literal like [::1]:443
|
|
@@ -34,6 +34,37 @@ function isStoredCredential(value) {
|
|
|
34
34
|
function providerId(id) {
|
|
35
35
|
return String(id ?? "").trim().toLowerCase();
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Should an `incoming` credential replace the `local` one during a non-destructive
|
|
39
|
+
* `importAll` merge? Pure and exported so the convergence rule is unit-testable
|
|
40
|
+
* without a vault. Rules:
|
|
41
|
+
* - No local entry → take the incoming one.
|
|
42
|
+
* - Only OAuth-vs-OAuth needs freshness arbitration (an api-key set/replace, or a
|
|
43
|
+
* type switch, keeps the existing "incoming wins on a real content change").
|
|
44
|
+
* - A snapshot that omits the refresh token must never clobber a usable one —
|
|
45
|
+
* rotated refresh tokens are single-use, so an incoming with a blank refresh is
|
|
46
|
+
* strictly worse than a local one that still has it.
|
|
47
|
+
* - Prefer the token minted LATER by `refreshedAt` (monotonic mint order) when
|
|
48
|
+
* both carry it; otherwise fall back to the access-token `expires`. In both
|
|
49
|
+
* cases a tie KEEPS the local credential (strictly-greater wins), so an equal
|
|
50
|
+
* stamp can't needlessly churn/rotate the vault, and clock skew can't let an
|
|
51
|
+
* equal-`expires` stale token win.
|
|
52
|
+
*/
|
|
53
|
+
export function preferIncomingCredential(local, incoming) {
|
|
54
|
+
if (!local)
|
|
55
|
+
return true;
|
|
56
|
+
if (local.type !== "oauth" || incoming.type !== "oauth")
|
|
57
|
+
return true;
|
|
58
|
+
const localRefresh = String(local.refresh ?? "").trim();
|
|
59
|
+
const incomingRefresh = String(incoming.refresh ?? "").trim();
|
|
60
|
+
if (!incomingRefresh && localRefresh)
|
|
61
|
+
return false;
|
|
62
|
+
const lt = Number(local.refreshedAt);
|
|
63
|
+
const it = Number(incoming.refreshedAt);
|
|
64
|
+
if (Number.isFinite(lt) && Number.isFinite(it))
|
|
65
|
+
return it > lt;
|
|
66
|
+
return (Number(incoming.expires) || 0) > (Number(local.expires) || 0);
|
|
67
|
+
}
|
|
37
68
|
/**
|
|
38
69
|
* Encrypted, cross-process-locked credential vault backed by `<vaultDir>/auth.enc`.
|
|
39
70
|
*
|
|
@@ -202,12 +233,10 @@ export class BivyCredentialStore {
|
|
|
202
233
|
if (!id || !isStoredCredential(incoming))
|
|
203
234
|
continue;
|
|
204
235
|
const local = vault[id];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
236
|
+
// Freshest-wins, rotation-safe (see preferIncomingCredential): a lagging
|
|
237
|
+
// or refresh-less snapshot must not overwrite a fresher local login.
|
|
238
|
+
if (!preferIncomingCredential(local, incoming))
|
|
239
|
+
continue;
|
|
211
240
|
if (!(id in vault))
|
|
212
241
|
imported += 1;
|
|
213
242
|
// Only mark dirty on a real content change, so a snapshot that merely
|
|
@@ -63,14 +63,15 @@ function tokensFrom(provider, payload, prev) {
|
|
|
63
63
|
const rotated = typeof payload.refresh_token === "string" ? payload.refresh_token : "";
|
|
64
64
|
const refresh = rotated || prev?.refresh || "";
|
|
65
65
|
const expiresIn = Number(payload.expires_in) || 3600;
|
|
66
|
-
const
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const expires = now + expiresIn * 1000 - provider.refreshSkewMs;
|
|
67
68
|
let accountId = prev?.accountId;
|
|
68
69
|
if (provider.accountIdClaim) {
|
|
69
70
|
accountId = jwtClaim(access, provider.accountIdClaim.path, provider.accountIdClaim.field) ?? accountId;
|
|
70
71
|
if (!accountId)
|
|
71
72
|
throw new Error(`Could not extract account id for "${provider.id}" from the OAuth token`);
|
|
72
73
|
}
|
|
73
|
-
return { access, refresh, expires, ...(accountId ? { accountId } : {}) };
|
|
74
|
+
return { access, refresh, expires, refreshedAt: now, ...(accountId ? { accountId } : {}) };
|
|
74
75
|
}
|
|
75
76
|
// --- Authorization-code flow (browser + callback server + manual paste) ------
|
|
76
77
|
function buildAuthorizeUrl(provider, opts) {
|
|
@@ -282,7 +283,7 @@ export async function loginModelOAuth(credsDir, providerId, interaction) {
|
|
|
282
283
|
if (!provider)
|
|
283
284
|
throw new Error(`Provider "${providerId}" does not support subscription login`);
|
|
284
285
|
const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction) : await loginAuthCode(provider, interaction);
|
|
285
|
-
const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
|
|
286
|
+
const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, refreshedAt: tokens.refreshedAt, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
|
|
286
287
|
await createCredentialVault(credsDir).modify(providerId, async () => credential);
|
|
287
288
|
}
|
|
288
289
|
/** Exchange the refresh token for a fresh credential (network call; throws on failure). */
|
|
@@ -318,7 +319,7 @@ export async function refreshModelOAuth(credsDir, providerId) {
|
|
|
318
319
|
if (Number(current.expires) > Date.now())
|
|
319
320
|
return current;
|
|
320
321
|
const fresh = await refreshTokens(provider, current);
|
|
321
|
-
return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
|
|
322
|
+
return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, refreshedAt: fresh.refreshedAt, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
|
|
322
323
|
});
|
|
323
324
|
return result?.type === "oauth" ? result.access : undefined;
|
|
324
325
|
}
|
package/dist/runtime/process.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
6
|
import { stripAnsi } from "./ansi.js";
|
|
7
7
|
import { buildAgentCredentialEnv } from "./credentials.js";
|
|
8
|
-
import { egressEnv } from "../harness/egress.js";
|
|
8
|
+
import { egressEnv, sessionEgressEnv } from "../harness/egress.js";
|
|
9
9
|
import { depCacheEnv } from "../harness/dep-cache.js";
|
|
10
10
|
import { bivySessionEnv } from "./session-env.js";
|
|
11
11
|
/**
|
|
@@ -300,12 +300,14 @@ class ProcessSession {
|
|
|
300
300
|
// src/harness/sandbox.ts). Bivy no longer wraps the process in an OS jail.
|
|
301
301
|
const child = spawn(this.runtimeOptions.command, args, {
|
|
302
302
|
cwd: this.cwd,
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
env
|
|
303
|
+
// Route this agent's outbound traffic through an egress proxy: this
|
|
304
|
+
// session's OWN proxy if it has one (a per-session sandbox/workflow network
|
|
305
|
+
// policy — sessionEgressEnv), else the node-global broker when
|
|
306
|
+
// BIVY_EGRESS_PROXY is enabled (else {}). bivySessionEnv() lets the agent's
|
|
307
|
+
// own shell resolve its session for `bivy attach <path>` (see
|
|
308
|
+
// session-env.ts); spread last so it can never be shadowed by an operator-
|
|
309
|
+
// configured env var of the same name.
|
|
310
|
+
env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...(sessionEgressEnv(this.id) ?? egressEnv()), ...bivySessionEnv(this.id) },
|
|
309
311
|
stdio: "pipe",
|
|
310
312
|
// Detached so the child becomes the leader of its own process group
|
|
311
313
|
// (POSIX) — see killProcessGroup() / abort() below, which kill that whole
|
package/dist/server.js
CHANGED
|
@@ -53,7 +53,7 @@ import { commandLaunch } from "./command-launch.js";
|
|
|
53
53
|
import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
|
|
54
54
|
import { createWorktree, removeWorktree, branchSlug, gitRepoRoot } from "./worktree.js";
|
|
55
55
|
import { HarnessManager } from "./harness/manager.js";
|
|
56
|
-
import { startEgressProxyIfEnabled } from "./harness/egress.js";
|
|
56
|
+
import { startEgressProxyIfEnabled, applySessionSandboxEgress, stopSessionEgress } from "./harness/egress.js";
|
|
57
57
|
import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
|
|
58
58
|
import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
|
|
59
59
|
import { checkDiskAdmission } from "./harness/disk-admission.js";
|
|
@@ -6772,6 +6772,9 @@ function closeSessionRecord(record, reason = "closed") {
|
|
|
6772
6772
|
sessionEvents.clear(record.id);
|
|
6773
6773
|
record.session.dispose();
|
|
6774
6774
|
harness.detach(record.id);
|
|
6775
|
+
// Tear down this session's own egress proxy, if it started one (read-only /
|
|
6776
|
+
// workflow network policy). No-op for the default path.
|
|
6777
|
+
void stopSessionEgress(record.id);
|
|
6775
6778
|
record.mcpRestore?.();
|
|
6776
6779
|
openSessions.delete(record.id);
|
|
6777
6780
|
if (record.sessionFile)
|
|
@@ -7951,6 +7954,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7951
7954
|
// session legitimately starts "active now".
|
|
7952
7955
|
const resumedLastActive = requestedSessionFile ? metaLastActiveMs(storedMeta) : undefined;
|
|
7953
7956
|
const record = { id: sessionId, session, runtimeId: rt.id, sandbox: sessionSandbox, approvalMode: opts.approvalMode, workspace: sessionWorkspace, sessionFile: session.sessionFile, agentServiceAddress: attachedAddress ?? rt.agentServiceAddress, worktree, source, prUrl: storedMeta?.prUrl, prs: storedMeta?.prs, lastTouchedAt: resumedLastActive ?? Date.now(), warning: modelFallbackMessage, ephemeral: opts.ephemeral };
|
|
7957
|
+
// Apply this session's sandbox network policy as a per-session egress proxy
|
|
7958
|
+
// (its own proxy/decider, never the node-global one). Opt-in via BIVY_SANDBOX_NET:
|
|
7959
|
+
// a read-only session then actually blocks outbound network even for a CLI agent
|
|
7960
|
+
// whose own sandbox doesn't (opencode/aider/goose). No-op otherwise. Fire-and-
|
|
7961
|
+
// forget — a slow proxy listen never delays session creation.
|
|
7962
|
+
void applySessionSandboxEgress(record.id, sessionSandbox, (event) => broadcast({ type: "node.egress", event }));
|
|
7954
7963
|
// Stage 2 slice 4: a re-attached session recovers its still-running TUI
|
|
7955
7964
|
// terminal link (the PTY survives a detach) from the session→terminal registry.
|
|
7956
7965
|
if (attached) {
|
package/package.json
CHANGED