@bivy/bivy 0.3.0-staging.45 → 0.3.0-staging.47

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.
@@ -29,15 +29,60 @@ export function parseGitHubRemote(input) {
29
29
  return parseRepo(`${ssh[1]}/${ssh[2]}`);
30
30
  return undefined;
31
31
  }
32
- /** Infer owner/repo from a workspace's origin remote, if it is a GitHub checkout. */
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
- try {
35
- const { stdout } = await exec("git", ["-C", workspace, "remote", "get-url", "origin"], { cwd: workspace });
36
- return parseGitHubRemote(stdout);
37
- }
38
- catch {
39
- return undefined;
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) {
@@ -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";
@@ -46,7 +47,7 @@ import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint }
46
47
  import { PolicyEngine } from "./policy/policy-engine.js";
47
48
  import { TerminalManager } from "./terminal.js";
48
49
  import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
49
- import { createWorktree, removeWorktree, branchSlug } from "./worktree.js";
50
+ import { createWorktree, removeWorktree, branchSlug, gitRepoRoot } from "./worktree.js";
50
51
  import { HarnessManager } from "./harness/manager.js";
51
52
  import { startEgressProxyIfEnabled } from "./harness/egress.js";
52
53
  import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
@@ -54,7 +55,7 @@ import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
54
55
  import { checkDiskAdmission } from "./harness/disk-admission.js";
55
56
  import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
56
57
  import { injectMcpProxyForSession } from "./harness/mcp-inject.js";
57
- 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";
58
59
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
59
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";
60
61
  import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
@@ -755,6 +756,40 @@ let relay;
755
756
  const clients = new Set();
756
757
  const commandProcesses = new Map();
757
758
  const oauthLogins = new Map();
759
+ // A browser-initiated subscription login parks the node on `manualCodePromise`
760
+ // until the remote device pastes the code (`provider.oauth.code`). If the user
761
+ // abandons it, the entry — AND its local callback http.Server — would otherwise
762
+ // linger until the process exits. That matters especially on a short-lived
763
+ // ephemeral node. Sweep periodically: abort (which closes the callback server,
764
+ // see startCallbackServer) + drop any in-flight login past its TTL, and drop a
765
+ // finished one after a short grace so clients can still read the final status.
766
+ const OAUTH_LOGIN_TTL_MS = 10 * 60_000;
767
+ const OAUTH_LOGIN_DONE_GRACE_MS = 2 * 60_000;
768
+ function sweepOauthLogins(now = Date.now()) {
769
+ for (const [id, login] of oauthLogins.entries()) {
770
+ const { drop, abort } = decideOAuthLoginSweep(login.status, now - login.createdAt, {
771
+ ttlMs: OAUTH_LOGIN_TTL_MS,
772
+ graceMs: OAUTH_LOGIN_DONE_GRACE_MS,
773
+ });
774
+ if (!drop)
775
+ continue;
776
+ if (abort) {
777
+ login.cancelled = true;
778
+ try {
779
+ login.abort.abort();
780
+ }
781
+ catch { /* already settled */ }
782
+ }
783
+ oauthLogins.delete(id);
784
+ }
785
+ }
786
+ let oauthLoginSweepTimer;
787
+ function startOAuthLoginSweeper() {
788
+ if (oauthLoginSweepTimer)
789
+ return;
790
+ oauthLoginSweepTimer = setInterval(() => sweepOauthLogins(), 60_000);
791
+ oauthLoginSweepTimer.unref?.();
792
+ }
758
793
  const openSessions = new Map();
759
794
  // Stage 2 (docs/agent-node-decoupling.md): sessionId -> agent-service address for
760
795
  // live REMOTE sessions the agent service keeps running across an eviction/
@@ -5140,6 +5175,9 @@ async function startOAuthLogin(provider) {
5140
5175
  // OpenAI's browser flow redirects to http://localhost:1455. Listen on IPv6
5141
5176
  // wildcard so browsers resolving localhost to ::1 can reach the callback.
5142
5177
  process.env.PI_OAUTH_CALLBACK_HOST ||= "::";
5178
+ // Opportunistically drop stale/abandoned logins whenever a new one starts, so a
5179
+ // long-lived node doesn't accumulate them between sweeps (and tests can drive it).
5180
+ sweepOauthLogins();
5143
5181
  const id = randomUUID();
5144
5182
  const abort = new AbortController();
5145
5183
  const state = { id, provider, status: "starting", abort, createdAt: Date.now(), progress: [] };
@@ -5487,6 +5525,22 @@ async function standUpFork(opts) {
5487
5525
  cwd = wt.path;
5488
5526
  worktree = wt;
5489
5527
  }
5528
+ else {
5529
+ // Non-repo-backed source. The fork would otherwise reuse the PARENT's cwd,
5530
+ // putting two sessions in one working tree — so when that cwd is itself a git
5531
+ // checkout (a local repo without a GitHub origin), cut the fork its own
5532
+ // worktree on a fresh branch. Best-effort: a non-git workspace has no tree to
5533
+ // isolate, so the fork keeps the fallback cwd (no git collisions possible).
5534
+ const forkRepoRoot = await gitRepoRoot(cwd);
5535
+ if (forkRepoRoot) {
5536
+ const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
5537
+ const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
5538
+ applyDirtyPatch(wt.path, bundle.dirtyPatch);
5539
+ workspace = forkRepoRoot;
5540
+ cwd = wt.path;
5541
+ worktree = wt;
5542
+ }
5543
+ }
5490
5544
  // Materialise the transcript, then stand the session up — resume the imported
5491
5545
  // transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
5492
5546
  const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
@@ -7126,9 +7180,52 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7126
7180
  if (!admission.allowed)
7127
7181
  throw new Error(`Not enough disk to start a new worktree session: ${admission.reason}`);
7128
7182
  const wtOpts = typeof opts.worktree === "object" ? opts.worktree : {};
7129
- worktree = await createWorktree({ repoDir: workspace, id: wtOpts.branch ?? `session-${Date.now()}`, branch: wtOpts.branch, base: wtOpts.base });
7183
+ // A random suffix, never a timestamp: two sessions started in the same
7184
+ // millisecond would otherwise resolve to the same slug → same worktree path
7185
+ // and branch, and `createWorktree` would ADOPT the first's worktree, dropping
7186
+ // the second session into a directory another session already owns.
7187
+ worktree = await createWorktree({ repoDir: workspace, id: wtOpts.branch ?? `session-${randomBytes(6).toString("hex")}`, branch: wtOpts.branch, base: wtOpts.base });
7130
7188
  runtimeWorkspace = worktree.path;
7131
7189
  }
7190
+ // Resume with a reaped worktree. When a repo-backed session is resumed but its
7191
+ // worktree directory was removed while it was closed (disk cleanup, a manual
7192
+ // rm, `git worktree remove`), restoredWorktree is undefined and we'd otherwise
7193
+ // fall back to the shared clone root — which the invariant below then rejects.
7194
+ // Re-provision a fresh worktree on the SAME branch instead (branches survive
7195
+ // `git worktree remove`, so the agent's committed history is intact), restoring
7196
+ // isolation so the resumed session is usable again. Best-effort: if the clone
7197
+ // or branch is gone, we leave it to the invariant to fail safe rather than
7198
+ // corrupt a neighbour. The clone root is reconstructed from `source`
7199
+ // (`repo:owner/repo`) because stored `workspace` is the old worktree path.
7200
+ if (requestedSessionFile && !worktree && storedMeta?.worktree && storedMeta?.branch) {
7201
+ const parsedSource = parseRepoSource(storedMeta.source);
7202
+ if (parsedSource) {
7203
+ const repoDir = path.join(reposRoot, `${parsedSource.owner}__${parsedSource.repo}`);
7204
+ try {
7205
+ // Clear any stale registration left by a dir that was rm'd out from under
7206
+ // git, so re-adding the branch's worktree doesn't hit "already checked out".
7207
+ runGit(["worktree", "prune"], repoDir);
7208
+ const reprovisioned = await createWorktree({ repoDir, id: storedMeta.branch, branch: storedMeta.branch });
7209
+ worktree = reprovisioned;
7210
+ runtimeWorkspace = reprovisioned.path;
7211
+ }
7212
+ catch (error) {
7213
+ console.warn(`Could not re-provision worktree for resumed session on ${storedMeta.branch}: ${error instanceof Error ? error.message : String(error)}`);
7214
+ }
7215
+ }
7216
+ }
7217
+ // Isolation invariant. A session must NEVER run directly in a Bivy-managed
7218
+ // shared clone root (`<reposRoot>/owner__repo`): every session for that repo
7219
+ // shares that one checkout, so an agent running there collides with concurrent
7220
+ // sessions on `git checkout`/`git stash` — exactly the "sessions mixing" bug.
7221
+ // A GitHub-backed session is supposed to get its own worktree; reaching here
7222
+ // without one means an earlier step degraded (e.g. a transient repo-inference
7223
+ // failure, or a resume whose worktree was reaped). Fail loudly instead of
7224
+ // silently sharing the tree and corrupting a neighbouring session's work.
7225
+ if (!worktree && isSharedCloneRoot(runtimeWorkspace, reposRoot)) {
7226
+ throw new Error(`Refusing to start a session in the shared clone root ${runtimeWorkspace} without an isolated worktree — ` +
7227
+ `this would collide with concurrent sessions on the same repo. Retry; if it persists the checkout may be busy.`);
7228
+ }
7132
7229
  const runtimeSessionOptions = { workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
7133
7230
  // Stage 2/3: prefer re-attaching to a still-live remote session — routed to its
7134
7231
  // OWN agent service — over re-opening a fresh copy from disk. Falls back to
@@ -7156,8 +7253,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7156
7253
  if (requestedSessionFile && storedMeta?.name && !session.getName())
7157
7254
  session.setName(storedMeta.name);
7158
7255
  const sessionWorkspace = session.cwd || runtimeWorkspace;
7159
- const inferredRepo = opts.source || storedMeta?.source ? undefined : await inferGitHubRepoFromWorkspace(sessionWorkspace);
7160
- if (!worktree && requestedSessionFile && inferredRepo) {
7256
+ // Best-effort here (unlike createWorkspaceSession, which must fail loudly):
7257
+ // this only decides whether to ADOPT an already-checked-out branch as the
7258
+ // session's worktree label, so a transient inference failure should quietly
7259
+ // skip adoption rather than break resuming the session.
7260
+ const inferredRepo = opts.source || storedMeta?.source ? undefined : await inferGitHubRepoFromWorkspace(sessionWorkspace).catch(() => undefined);
7261
+ if (!worktree && requestedSessionFile && inferredRepo && !isSharedCloneRoot(sessionWorkspace, reposRoot)) {
7161
7262
  const branch = runGit(["branch", "--show-current"], sessionWorkspace) || runGit(["rev-parse", "--short", "HEAD"], sessionWorkspace) || undefined;
7162
7263
  if (branch) {
7163
7264
  const mainWorktree = runGit(["worktree", "list", "--porcelain"], sessionWorkspace)?.split("\n").find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
@@ -9687,6 +9788,7 @@ const server = app.listen(port, host, async () => {
9687
9788
  if (process.env.BIVY_RESTORE)
9688
9789
  void restoreSessionFromSnapshot(String(process.env.BIVY_RESTORE));
9689
9790
  startModelAuthWatcher();
9791
+ startOAuthLoginSweeper();
9690
9792
  startGithubAppSyncWatcher();
9691
9793
  await startGitHubTasksIfConfigured();
9692
9794
  startControlPlaneTasksIfConfigured();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.3.0-staging.45",
3
+ "version": "0.3.0-staging.47",
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.",