@bivy/bivy 0.6.0-staging.86 → 0.6.0-staging.88

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.
@@ -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
@@ -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
- // egressEnv() routes this agent's outbound traffic through the harness
304
- // network broker when BIVY_EGRESS_PROXY is enabled (else it's {}).
305
- // bivySessionEnv() lets the agent's own shell resolve its session for
306
- // `bivy attach <path>` (see session-env.ts); spread last so it can never
307
- // be shadowed by an operator-configured env var of the same name.
308
- env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...egressEnv(), ...bivySessionEnv(this.id) },
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";
@@ -5225,6 +5225,47 @@ async function resolveTokenForRepo(owner, repo) {
5225
5225
  }
5226
5226
  return (await resolveGitHubToken()) ?? (await hostedMintToken());
5227
5227
  }
5228
+ /** The session source a Linear-issue pickup advertises, keyed by the issue's
5229
+ * provider-native id so the control plane can correlate a re-dispatch to it
5230
+ * (findSessionByExternalId → "linear:<externalId>"). The Linear analogue of the
5231
+ * GitHub `issue:owner/repo#N` source. */
5232
+ function linearSessionSource(externalId) {
5233
+ return `linear:${externalId}`;
5234
+ }
5235
+ /**
5236
+ * Case B for a queued follow-up the control plane correlated to an existing
5237
+ * session (`targetKind === "existing_session"`): if that session is still live on
5238
+ * this node, continue it as a normal chat — run `prompt` as a follow-up turn and
5239
+ * re-publish its branch/PR — so a channel reply lands in the same thread. The
5240
+ * provider-agnostic analogue of the GitHub issue follow-up (`runIssueFollowUp`);
5241
+ * used by both the Linear and the generic (Slack) pickup paths. When the session
5242
+ * isn't live here (its machine was torn down), best-effort restore its snapshot so
5243
+ * the caller's fresh pickup continues its branch/transcript instead of cold-
5244
+ * starting, and return false so the caller falls through. Returns true only when
5245
+ * it fully handled the item.
5246
+ */
5247
+ async function continueCorrelatedSession(item, prompt, report) {
5248
+ if (item.targetKind !== "existing_session" || !item.targetSessionId)
5249
+ return false;
5250
+ const record = openSessions.get(item.targetSessionId);
5251
+ if (!record) {
5252
+ await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message));
5253
+ return false;
5254
+ }
5255
+ const branch = record.worktree?.branch;
5256
+ await runSessionTurn(record, prompt);
5257
+ if (record.worktree) {
5258
+ await maybePushWorktreeBranch(record);
5259
+ await maybeDetectPullRequest(record);
5260
+ }
5261
+ await report({
5262
+ output: { sessionId: record.id, branch, prUrl: record.prUrl },
5263
+ events: record.prUrl
5264
+ ? [{ at: new Date().toISOString(), kind: "pull_request", summary: "Pull request updated.", ref: branch, url: record.prUrl }]
5265
+ : undefined,
5266
+ });
5267
+ return true;
5268
+ }
5228
5269
  async function runWorkItem(item, report) {
5229
5270
  if ((item.source === "schedule" || item.source === "manual") && item.body?.startsWith("bivy-room-v1:")) {
5230
5271
  const [, nodeId, ...payload] = item.body.split(":");
@@ -5303,6 +5344,10 @@ async function runWorkItem(item, report) {
5303
5344
  const parsed = parseRepo(repoSlug);
5304
5345
  if (!parsed)
5305
5346
  throw new Error(`Linear work item has an invalid repo "${repoSlug}"`);
5347
+ // Case B: a re-dispatch the control plane correlated to an existing session
5348
+ // continues it as a normal chat instead of starting cold (mirrors GitHub).
5349
+ if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue), report))
5350
+ return;
5306
5351
  const githubToken = await resolveGitHubToken();
5307
5352
  if (!githubToken)
5308
5353
  throw new Error("no GitHub token available to clone the Linear issue repository");
@@ -5313,7 +5358,7 @@ async function runWorkItem(item, report) {
5313
5358
  const record = await createSession(repoDir, undefined, {
5314
5359
  worktree: { branch, base },
5315
5360
  makeActive: false,
5316
- source: "queue:linear:issue",
5361
+ source: linearSessionSource(item.externalId),
5317
5362
  runtimeId: item.runtimeId || nodeConfiguredDefaultAgent(),
5318
5363
  sandbox: normalizeSandboxTier(item.sandbox),
5319
5364
  approvalMode: approvalModeFrom(item.approvalMode),
@@ -5339,6 +5384,13 @@ async function runWorkItem(item, report) {
5339
5384
  const parsedRepo = item.repo ? parseRepo(item.repo) : undefined;
5340
5385
  if (item.repo && !parsedRepo)
5341
5386
  throw new Error(`work item ${item.id} has an invalid repo "${item.repo}"`);
5387
+ const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
5388
+ // Case B (provider-agnostic): a follow-up the control plane correlated to an
5389
+ // existing session continues it as a normal chat. Reached by Slack the moment a
5390
+ // reply carries a thread identity the control plane can correlate; a one-shot
5391
+ // slash command has none, so it simply falls through to a fresh session.
5392
+ if (await continueCorrelatedSession(item, request, report))
5393
+ return;
5342
5394
  const sessionOpts = {
5343
5395
  makeActive: false,
5344
5396
  title: item.title,
@@ -5359,7 +5411,6 @@ async function runWorkItem(item, report) {
5359
5411
  }
5360
5412
  catch { }
5361
5413
  }
5362
- const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
5363
5414
  const prompt = parsedRepo || record.worktree
5364
5415
  ? [
5365
5416
  request,
@@ -6721,6 +6772,9 @@ function closeSessionRecord(record, reason = "closed") {
6721
6772
  sessionEvents.clear(record.id);
6722
6773
  record.session.dispose();
6723
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);
6724
6778
  record.mcpRestore?.();
6725
6779
  openSessions.delete(record.id);
6726
6780
  if (record.sessionFile)
@@ -7900,6 +7954,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7900
7954
  // session legitimately starts "active now".
7901
7955
  const resumedLastActive = requestedSessionFile ? metaLastActiveMs(storedMeta) : undefined;
7902
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 }));
7903
7963
  // Stage 2 slice 4: a re-attached session recovers its still-running TUI
7904
7964
  // terminal link (the PTY survives a detach) from the session→terminal registry.
7905
7965
  if (attached) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.6.0-staging.86",
3
+ "version": "0.6.0-staging.88",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",