@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.
- package/dist/repo-workspace.js +52 -7
- package/dist/runtime/oauth/oauth-login-sweep.js +19 -0
- package/dist/server.js +107 -5
- package/package.json +1 -1
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
7160
|
-
|
|
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