@bivy/bivy 0.3.0-staging.44 → 0.3.0-staging.46

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.
@@ -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";
@@ -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/
@@ -3600,6 +3635,14 @@ async function syncModelAuthFromControlPlane() {
3600
3635
  vaultKeyB64 = pairingStore.unwrapFromNodePublicKey(data.wrappedKey.wrappedByPublicKey, data.wrappedKey.wrappedKey);
3601
3636
  writeLocalModelAuthVaultKey(vaultKeyB64);
3602
3637
  }
3638
+ // Node-less inheritance: a lone HOSTED ephemeral (no peer to wrap the key)
3639
+ // adopts the vault key the control plane escrowed for this hosted account, so
3640
+ // it can decrypt the synced vault (incl. subscription OAuth) on cold start. The
3641
+ // control plane serves `hostedKey` only for hosted-provisioning accounts.
3642
+ if (!vaultKeyB64 && data.hostedKey && Buffer.from(data.hostedKey, "base64").length === 32) {
3643
+ vaultKeyB64 = data.hostedKey;
3644
+ writeLocalModelAuthVaultKey(vaultKeyB64);
3645
+ }
3603
3646
  if (data.vault?.ciphertext && vaultKeyB64) {
3604
3647
  const { providers, localModels } = decryptModelAuthEnvelope(data.vault.ciphertext, vaultKeyB64);
3605
3648
  await importProviderAuth(credsDir, providers);
@@ -3666,6 +3709,13 @@ async function pushModelAuthToControlPlane() {
3666
3709
  method: "PUT",
3667
3710
  body: JSON.stringify({ targetNodeId: identity.nodeId, wrappedByPublicKey: pairingStore.nodePublicKeyB64(), wrappedKey: pairingStore.wrapForNodePublicKey(pairingStore.nodePublicKeyB64(), vaultKeyB64) }),
3668
3711
  });
3712
+ // Node-less inheritance: a HOSTED node also escrows the vault key to the control
3713
+ // plane (sealed at rest, hosted-only) so the account's NEXT hosted ephemeral —
3714
+ // possibly the only node — can decrypt this vault without a peer to wrap the key.
3715
+ // Gated to hosted nodes; the CP double-checks the account is hosted. Best effort.
3716
+ if (process.env.BIVY_GITHUB_HOSTED_TASKS) {
3717
+ await modelAuthFetch("/node/model-auth-key/hosted-escrow", { method: "PUT", body: JSON.stringify({ vaultKeyB64 }) }).catch(() => { });
3718
+ }
3669
3719
  lastPushedModelAuthCiphertext = ciphertext;
3670
3720
  }
3671
3721
  catch (error) {
@@ -5125,6 +5175,9 @@ async function startOAuthLogin(provider) {
5125
5175
  // OpenAI's browser flow redirects to http://localhost:1455. Listen on IPv6
5126
5176
  // wildcard so browsers resolving localhost to ::1 can reach the callback.
5127
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();
5128
5181
  const id = randomUUID();
5129
5182
  const abort = new AbortController();
5130
5183
  const state = { id, provider, status: "starting", abort, createdAt: Date.now(), progress: [] };
@@ -9672,6 +9725,7 @@ const server = app.listen(port, host, async () => {
9672
9725
  if (process.env.BIVY_RESTORE)
9673
9726
  void restoreSessionFromSnapshot(String(process.env.BIVY_RESTORE));
9674
9727
  startModelAuthWatcher();
9728
+ startOAuthLoginSweeper();
9675
9729
  startGithubAppSyncWatcher();
9676
9730
  await startGitHubTasksIfConfigured();
9677
9731
  startControlPlaneTasksIfConfigured();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.3.0-staging.44",
3
+ "version": "0.3.0-staging.46",
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.",