@bivy/bivy 0.0.0 → 0.1.0-staging.2

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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Unified credential provisioning — one login in Bivy, every agent works.
4
+ //
5
+ // Bivy holds the single source of truth (the encrypted vault) and *projects* it
6
+ // into whatever form each native agent consumes when that agent runs:
7
+ // - env vars (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, …) for agents that
8
+ // read the environment;
9
+ // - a native on-disk store for agents that read their own file (Pi's auth.json,
10
+ // Codex's ~/.codex/auth.json).
11
+ // Every projection is refreshed-before-use, so agents receive a fresh access
12
+ // token and rarely need to self-refresh — which keeps Bivy the single OAuth
13
+ // refresh authority (rotating refresh tokens are single-use; two agents
14
+ // refreshing the same credential would invalidate each other).
15
+ //
16
+ // This is the seam behind `bivy run <agent>`, `bivy shim`, and the chat→native
17
+ // TUI hand-off. Claude Code manages its own env projection (claude-code.ts) and
18
+ // is intentionally left as-is.
19
+ import { createCredentialVault } from "./credential-store.js";
20
+ import { buildAgentCredentialEnv, createCredentialStore } from "./credentials.js";
21
+ import { ensureCodexAuth, ensureCodexTrusted } from "./codex-auth.js";
22
+ import { refreshModelOAuth } from "./oauth/model-oauth.js";
23
+ import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
24
+ /**
25
+ * Refresh any stored OAuth credential that is already expired, centrally, so a
26
+ * projection hands the agent a fresh access token. No-op for fresh tokens and
27
+ * non-OAuth providers; runs under the store lock (single-flight).
28
+ */
29
+ export async function refreshExpiringOAuth(credsDir) {
30
+ const infos = await createCredentialVault(credsDir).list().catch(() => []);
31
+ await Promise.all(infos
32
+ .filter((info) => info.type === "oauth" && isNativeOAuthProvider(info.providerId))
33
+ .map((info) => refreshModelOAuth(credsDir, info.providerId).catch(() => undefined)));
34
+ }
35
+ /**
36
+ * Environment variables that project Bivy's unified logins onto an agent
37
+ * process. Refreshes expiring OAuth first. This is how `bivy run <agent>` and
38
+ * the TUI hand-off make one Bivy sign-in serve any agent that reads env vars.
39
+ */
40
+ export async function provisionAgentEnv(credsDir, providers) {
41
+ await refreshExpiringOAuth(credsDir);
42
+ return buildAgentCredentialEnv(createCredentialStore(credsDir), providers);
43
+ }
44
+ /**
45
+ * Materialize Pi's plaintext auth.json from the shared vault with fresh tokens,
46
+ * for Pi's own CLI/TUI (which reads its file store in `piDir`). Pair with the
47
+ * daemon's auth.json watcher (ingestPlaintext), which folds any TUI-time
48
+ * login/refresh back in.
49
+ */
50
+ export async function provisionPiAuthJson(credsDir, piDir) {
51
+ await refreshExpiringOAuth(credsDir);
52
+ createCredentialVault(credsDir, piDir).materializePlaintext();
53
+ }
54
+ /**
55
+ * Full projection for a native `bivy run <agent>` launch: env vars for every
56
+ * agent, plus the native on-disk store for agents that need one. Returns the env
57
+ * to set on the PTY (may include CODEX_HOME). `credsDir` is the shared vault;
58
+ * `piDir` is Pi's own dir (used only for Pi's plaintext auth.json).
59
+ */
60
+ export async function provisionAgentRun(credsDir, piDir, agentId, workspace) {
61
+ const env = await provisionAgentEnv(credsDir);
62
+ if (agentId === "pi") {
63
+ await provisionPiAuthJson(credsDir, piDir).catch((error) => {
64
+ console.warn("[provision] pi auth.json materialization failed:", error.message);
65
+ });
66
+ }
67
+ else if (agentId === "codex") {
68
+ // Codex reads OPENAI_API_KEY (already in env) or its own auth.json; mint the
69
+ // latter from a connected ChatGPT subscription when present.
70
+ const home = await ensureCodexAuth(credsDir).catch(() => undefined);
71
+ if (home)
72
+ env.CODEX_HOME = home;
73
+ // Pre-trust the run workspace so Codex doesn't stall on its first-run trust
74
+ // prompt (which blocks it from writing the rollout takeover relies on).
75
+ if (workspace)
76
+ ensureCodexTrusted(workspace);
77
+ }
78
+ return env;
79
+ }
@@ -0,0 +1,435 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Bivy's app-owned credential store — the source of truth for model credentials.
4
+ //
5
+ // This module is deliberately PI-FREE (no import from any @earendil-works
6
+ // package, not even a type). Bivy owns the storage, the credential shape, the
7
+ // encryption, and the locking. Pi consumes this store as just another agent
8
+ // (see pi-oauth.ts, which adapts it to pi-ai's structurally-identical
9
+ // CredentialStore interface for injection into ModelRuntime).
10
+ //
11
+ // At rest the vault is encrypted (AES-256-GCM via the repo's own seal/open — no
12
+ // third crypto implementation) under a 0600 key minted once. Writes are
13
+ // serialized twice over: an in-process per-provider promise chain, and a
14
+ // cross-process mkdir lock (the `bivy login` CLI writes the same file as the
15
+ // running daemon). `modify()` is the only write path, so every mutation is a
16
+ // read-modify-write under the lock — the ordering OAuth refresh depends on
17
+ // (rotated refresh tokens are single-use; a read-then-write loses that race).
18
+ import fs from "node:fs";
19
+ import fsp from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { randomBytes } from "node:crypto";
22
+ import { seal, open } from "../e2e.js";
23
+ const LOCK_STALE_MS = 30_000;
24
+ const LOCK_RETRY_MS = 25;
25
+ const LOCK_TIMEOUT_MS = 10_000;
26
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27
+ function isStoredCredential(value) {
28
+ if (!value || typeof value !== "object")
29
+ return false;
30
+ const type = value.type;
31
+ return type === "api_key" || type === "oauth";
32
+ }
33
+ /** Normalize a provider id the way every path expects (trimmed, lowercased). */
34
+ function providerId(id) {
35
+ return String(id ?? "").trim().toLowerCase();
36
+ }
37
+ /**
38
+ * Encrypted, cross-process-locked credential vault backed by `<vaultDir>/auth.enc`.
39
+ *
40
+ * `vaultDir` is the node's shared, agent-neutral credential directory
41
+ * (`.bivy/credentials`) — NOT any one agent's directory. The optional
42
+ * `plaintextDir` is where the decrypted `auth.json` projection is written for an
43
+ * agent whose native CLI/TUI reads a plaintext store (Pi's own dir); it defaults
44
+ * to `vaultDir` for callers that never materialize plaintext.
45
+ *
46
+ * The public surface intentionally matches pi-ai's `CredentialStore`
47
+ * (`read`/`list`/`modify`/`delete`) so pi-oauth.ts can inject it into a
48
+ * `ModelRuntime` with a single structural cast — plus Bivy-owned `exportAll` /
49
+ * `importAll` for cross-node sync. Nothing here depends on pi.
50
+ */
51
+ export class BivyCredentialStore {
52
+ vaultDir;
53
+ blobFile;
54
+ keyFile;
55
+ legacyFile;
56
+ lockDir;
57
+ plaintextDir;
58
+ chains = new Map();
59
+ migrated = false;
60
+ constructor(vaultDir, plaintextDir) {
61
+ this.vaultDir = vaultDir;
62
+ this.blobFile = path.join(vaultDir, "auth.enc");
63
+ this.keyFile = path.join(vaultDir, "auth.key");
64
+ this.lockDir = path.join(vaultDir, "auth.enc.lock");
65
+ // The plaintext projection is an agent-specific concern (an agent whose CLI
66
+ // reads its own `auth.json`), so it can live in a different dir than the
67
+ // shared encrypted vault.
68
+ this.plaintextDir = plaintextDir ?? vaultDir;
69
+ this.legacyFile = path.join(this.plaintextDir, "auth.json");
70
+ }
71
+ // --- pi-ai CredentialStore surface ---------------------------------------
72
+ async read(provider) {
73
+ const id = providerId(provider);
74
+ if (!id)
75
+ return undefined;
76
+ return this.readBlob()[id];
77
+ }
78
+ async list() {
79
+ return Object.entries(this.readBlob()).map(([id, cred]) => ({
80
+ providerId: id,
81
+ type: cred.type,
82
+ ...(cred.type === "oauth" ? { expiresAt: cred.expires } : {}),
83
+ }));
84
+ }
85
+ /**
86
+ * Serialized read-modify-write — the only write path. `fn` sees the credential
87
+ * as of this write (not as of when the caller decided to write), so a refresh
88
+ * cannot be clobbered by a concurrent import. Returns the post-write
89
+ * credential; `fn` returning undefined leaves the entry unchanged.
90
+ */
91
+ async modify(provider, fn) {
92
+ const id = providerId(provider);
93
+ if (!id)
94
+ throw new Error("Provider is required");
95
+ return this.enqueue(id, async () => {
96
+ await this.acquireLock();
97
+ try {
98
+ const vault = this.readBlob();
99
+ const next = await fn(vault[id]);
100
+ if (next === undefined)
101
+ return vault[id];
102
+ if (!isStoredCredential(next))
103
+ throw new Error(`Invalid credential for "${id}"`);
104
+ vault[id] = next;
105
+ this.writeBlob(vault);
106
+ return next;
107
+ }
108
+ finally {
109
+ await this.releaseLock();
110
+ }
111
+ });
112
+ }
113
+ async delete(provider) {
114
+ const id = providerId(provider);
115
+ if (!id)
116
+ return;
117
+ await this.enqueue(id, async () => {
118
+ await this.acquireLock();
119
+ try {
120
+ const vault = this.readBlob();
121
+ if (!(id in vault))
122
+ return;
123
+ delete vault[id];
124
+ this.writeBlob(vault);
125
+ }
126
+ finally {
127
+ await this.releaseLock();
128
+ }
129
+ });
130
+ }
131
+ // --- Bivy-owned convenience ---------------------------------------------
132
+ /** Store an API key (the common non-OAuth login). */
133
+ async setApiKey(provider, key) {
134
+ const apiKey = String(key ?? "").trim();
135
+ if (!apiKey)
136
+ throw new Error("API key cannot be empty");
137
+ await this.modify(provider, async () => ({ type: "api_key", key: apiKey }));
138
+ }
139
+ /** Every stored credential, keyed by provider id — the cross-node snapshot. */
140
+ async exportAll() {
141
+ return this.readBlob();
142
+ }
143
+ /** The plaintext `auth.json` path an agent's own CLI/TUI reads (`<plaintextDir>/auth.json`). */
144
+ get legacyAuthPath() {
145
+ return this.legacyFile;
146
+ }
147
+ /**
148
+ * Write the decrypted vault to `<plaintextDir>/auth.json` so a subprocess that
149
+ * reads a plaintext store (e.g. Pi's own interactive TUI) can use the same
150
+ * logins.
151
+ * Synchronous, 0600. This is the bridge for the native-TUI hand-off; daemon
152
+ * agents get credentials via env injection and never see this file. Pair with
153
+ * `ingestPlaintext()` to fold TUI-time logins back into the vault.
154
+ */
155
+ materializePlaintext() {
156
+ const vault = this.readBlob();
157
+ const next = `${JSON.stringify(vault, null, 2)}\n`;
158
+ // Write only when the projection actually changes. This keeps the file's
159
+ // mtime stable so a live re-materialize (on a vault change while a native Pi
160
+ // TUI is running) can't ping-pong with the auth.json watcher's ingest.
161
+ try {
162
+ if (fs.readFileSync(this.legacyFile, "utf8") === next)
163
+ return this.legacyFile;
164
+ }
165
+ catch { /* missing/unreadable — fall through and write */ }
166
+ fs.mkdirSync(this.plaintextDir, { recursive: true, mode: 0o700 });
167
+ const tmp = `${this.legacyFile}.${process.pid}.tmp`;
168
+ fs.writeFileSync(tmp, next, { mode: 0o600 });
169
+ fs.renameSync(tmp, this.legacyFile);
170
+ try {
171
+ fs.chmodSync(this.legacyFile, 0o600);
172
+ }
173
+ catch { /* best effort */ }
174
+ return this.legacyFile;
175
+ }
176
+ /** Fold any credentials in the plaintext `auth.json` (e.g. a TUI login) back into the vault. */
177
+ async ingestPlaintext() {
178
+ let legacy;
179
+ try {
180
+ legacy = JSON.parse(fs.readFileSync(this.legacyFile, "utf8"));
181
+ }
182
+ catch {
183
+ return 0;
184
+ }
185
+ return this.importAll(normalizeMap(legacy));
186
+ }
187
+ /**
188
+ * Merge an account snapshot into the vault. Merge, never destroy: a lagging
189
+ * snapshot that omits a provider must not delete a fresh local login, and a
190
+ * locally-fresher OAuth token must win over an older one in the snapshot
191
+ * (rotated refresh tokens are single-use — importing a stale one breaks the
192
+ * next refresh). Runs under the lock so it can't race a refresh.
193
+ */
194
+ async importAll(snapshot) {
195
+ await this.acquireLock();
196
+ try {
197
+ const vault = this.readBlob();
198
+ let imported = 0;
199
+ let changed = false;
200
+ for (const [rawId, incoming] of Object.entries(snapshot ?? {})) {
201
+ const id = providerId(rawId);
202
+ if (!id || !isStoredCredential(incoming))
203
+ continue;
204
+ const local = vault[id];
205
+ if (incoming.type === "oauth" && local?.type === "oauth") {
206
+ const localExpires = Number(local.expires) || 0;
207
+ const incomingExpires = Number(incoming.expires) || 0;
208
+ if (localExpires > incomingExpires)
209
+ continue;
210
+ }
211
+ if (!(id in vault))
212
+ imported += 1;
213
+ // Only mark dirty on a real content change, so a snapshot that merely
214
+ // re-states what we already hold doesn't rewrite (and re-encrypt) the
215
+ // vault — which would needlessly bump auth.enc's mtime and re-fire the
216
+ // vault watcher (materialize → ingest → import loop protection).
217
+ if (JSON.stringify(local) !== JSON.stringify(incoming)) {
218
+ vault[id] = incoming;
219
+ changed = true;
220
+ }
221
+ }
222
+ if (changed)
223
+ this.writeBlob(vault);
224
+ return imported;
225
+ }
226
+ finally {
227
+ await this.releaseLock();
228
+ }
229
+ }
230
+ // --- storage internals ---------------------------------------------------
231
+ key() {
232
+ try {
233
+ const key = Buffer.from(fs.readFileSync(this.keyFile, "utf8").trim(), "base64");
234
+ if (key.length === 32)
235
+ return key;
236
+ // Present but malformed — surface it rather than mint a new key that makes
237
+ // every stored credential undecryptable.
238
+ throw new Error(`Credential key at ${this.keyFile} is invalid (expected 32 bytes)`);
239
+ }
240
+ catch (error) {
241
+ // Mint only when the key genuinely does not exist. A transient read
242
+ // failure (EMFILE, permission blip) must NOT regenerate the key.
243
+ if (error?.code !== "ENOENT")
244
+ throw error;
245
+ }
246
+ fs.mkdirSync(this.vaultDir, { recursive: true, mode: 0o700 });
247
+ const key = randomBytes(32);
248
+ fs.writeFileSync(this.keyFile, `${key.toString("base64")}\n`, { mode: 0o600 });
249
+ try {
250
+ fs.chmodSync(this.keyFile, 0o600);
251
+ }
252
+ catch { /* best effort */ }
253
+ return key;
254
+ }
255
+ readBlob() {
256
+ this.ensureMigrated();
257
+ let raw;
258
+ try {
259
+ raw = fs.readFileSync(this.blobFile, "utf8");
260
+ }
261
+ catch {
262
+ return {};
263
+ }
264
+ let parsed;
265
+ try {
266
+ parsed = JSON.parse(open(this.key(), raw.trim()));
267
+ }
268
+ catch {
269
+ // A truncated/corrupt/undecryptable vault is treated as empty rather than
270
+ // taking the node down: every caller already handles "no credential".
271
+ return {};
272
+ }
273
+ return normalizeMap(parsed);
274
+ }
275
+ writeBlob(vault) {
276
+ fs.mkdirSync(this.vaultDir, { recursive: true, mode: 0o700 });
277
+ const ciphertext = seal(this.key(), JSON.stringify(vault));
278
+ const tmp = `${this.blobFile}.${process.pid}.tmp`;
279
+ fs.writeFileSync(tmp, `${ciphertext}\n`, { mode: 0o600 });
280
+ fs.renameSync(tmp, this.blobFile);
281
+ try {
282
+ fs.chmodSync(this.blobFile, 0o600);
283
+ }
284
+ catch { /* best effort */ }
285
+ }
286
+ /**
287
+ * One-time, best-effort import of a legacy plaintext `auth.json` when the
288
+ * encrypted vault does not exist yet. Dev convenience so an existing install
289
+ * keeps its logins across the upgrade; non-destructive (auth.json is left in
290
+ * place). Pre-users, so this is the only migration we owe.
291
+ */
292
+ ensureMigrated() {
293
+ if (this.migrated)
294
+ return;
295
+ this.migrated = true;
296
+ if (fs.existsSync(this.blobFile))
297
+ return;
298
+ let legacy;
299
+ try {
300
+ legacy = JSON.parse(fs.readFileSync(this.legacyFile, "utf8"));
301
+ }
302
+ catch {
303
+ return; // no legacy file / unreadable — nothing to import
304
+ }
305
+ const map = normalizeMap(legacy);
306
+ if (Object.keys(map).length === 0)
307
+ return;
308
+ try {
309
+ this.writeBlob(map);
310
+ }
311
+ catch {
312
+ // If we can't write the encrypted vault, fall back to reading legacy on
313
+ // the next call rather than crashing.
314
+ this.migrated = false;
315
+ }
316
+ }
317
+ // --- locking -------------------------------------------------------------
318
+ enqueue(id, task) {
319
+ const prior = this.chains.get(id) ?? Promise.resolve();
320
+ // Chain off settlement, not value: one caller's rejection must not cancel
321
+ // the next caller's write.
322
+ const next = prior.then(task, task);
323
+ this.chains.set(id, next.catch(() => undefined));
324
+ return next;
325
+ }
326
+ async acquireLock() {
327
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
328
+ for (;;) {
329
+ try {
330
+ fs.mkdirSync(this.vaultDir, { recursive: true, mode: 0o700 });
331
+ fs.mkdirSync(this.lockDir);
332
+ return;
333
+ }
334
+ catch (error) {
335
+ if (error.code !== "EEXIST")
336
+ throw error;
337
+ if (this.breakIfStale())
338
+ continue;
339
+ if (Date.now() > deadline) {
340
+ throw new Error(`Timed out waiting for the credential lock at ${this.lockDir}`);
341
+ }
342
+ await sleep(LOCK_RETRY_MS);
343
+ }
344
+ }
345
+ }
346
+ /** Remove a lock whose owner died mid-write. Returns true if it broke one. */
347
+ breakIfStale() {
348
+ try {
349
+ const age = Date.now() - fs.statSync(this.lockDir).mtimeMs;
350
+ if (age < LOCK_STALE_MS)
351
+ return false;
352
+ fs.rmdirSync(this.lockDir);
353
+ return true;
354
+ }
355
+ catch {
356
+ // Lost the race to another breaker, or it was released under us — the next
357
+ // mkdir attempt is the source of truth.
358
+ return false;
359
+ }
360
+ }
361
+ async releaseLock() {
362
+ await fsp.rmdir(this.lockDir).catch(() => { });
363
+ }
364
+ }
365
+ /** Coerce arbitrary parsed JSON into a `{ [id]: StoredCredential }` map. */
366
+ function normalizeMap(parsed) {
367
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
368
+ return {};
369
+ const out = {};
370
+ for (const [id, value] of Object.entries(parsed)) {
371
+ if (isStoredCredential(value))
372
+ out[providerId(id)] = value;
373
+ }
374
+ return out;
375
+ }
376
+ /**
377
+ * Build the node's shared credential vault at `<vaultDir>/auth.enc`. Pass
378
+ * `plaintextDir` when an agent's native CLI reads a plaintext `auth.json` in a
379
+ * different directory (e.g. Pi's own dir); it defaults to `vaultDir`.
380
+ */
381
+ export function createCredentialVault(vaultDir, plaintextDir) {
382
+ return new BivyCredentialStore(vaultDir, plaintextDir);
383
+ }
384
+ /**
385
+ * One-time, best-effort relocation of the encrypted vault (`auth.enc` +
386
+ * `auth.key`) from a legacy directory to the node's dedicated credentials dir.
387
+ * This is the migration for installs created before the vault was split out of an
388
+ * agent's own directory — so an existing user keeps their logins across the
389
+ * upgrade instead of re-authenticating.
390
+ *
391
+ * Idempotent: it only runs when the destination has no vault yet and the source
392
+ * does. The key is moved before the ciphertext, so a crash mid-move leaves the
393
+ * destination without an `auth.enc` (the guard re-runs the move next boot) rather
394
+ * than an undecryptable one. The plaintext `auth.json` is intentionally NOT moved
395
+ * — it stays in the agent's own dir, where its native CLI/TUI reads it.
396
+ * Returns true if it moved a vault.
397
+ */
398
+ export function migrateVaultDir(fromDir, toDir) {
399
+ if (fromDir === toDir)
400
+ return false;
401
+ try {
402
+ // Guard: never overwrite a vault already in place at the destination, and do
403
+ // nothing when there is no legacy vault to move.
404
+ if (fs.existsSync(path.join(toDir, "auth.enc")) || !fs.existsSync(path.join(fromDir, "auth.enc")))
405
+ return false;
406
+ fs.mkdirSync(toDir, { recursive: true, mode: 0o700 });
407
+ // Move the key first, then the ciphertext (see doc-comment ordering rationale).
408
+ for (const name of ["auth.key", "auth.enc"]) {
409
+ const from = path.join(fromDir, name);
410
+ const to = path.join(toDir, name);
411
+ if (!fs.existsSync(from) || fs.existsSync(to))
412
+ continue;
413
+ try {
414
+ fs.renameSync(from, to);
415
+ }
416
+ catch {
417
+ // Cross-device or transient failure — copy then unlink the source.
418
+ fs.copyFileSync(from, to);
419
+ try {
420
+ fs.unlinkSync(from);
421
+ }
422
+ catch { /* leave the source; the guard stops a re-copy */ }
423
+ }
424
+ try {
425
+ fs.chmodSync(to, 0o600);
426
+ }
427
+ catch { /* best effort */ }
428
+ }
429
+ return fs.existsSync(path.join(toDir, "auth.enc"));
430
+ }
431
+ catch {
432
+ // Best-effort: a failed migration just means the user re-authenticates.
433
+ return false;
434
+ }
435
+ }
@@ -0,0 +1,153 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Shared credential resolver for agent env injection.
4
+ //
5
+ // A node-level resolver so every agent runtime can reuse the model-provider
6
+ // logins the user did once, instead of re-authenticating per agent. It reads
7
+ // Bivy's own credential store (credential-store.ts) DIRECTLY — api keys and
8
+ // non-expired OAuth tokens need no Pi at all. Only an expired OAuth token
9
+ // touches Pi, via the isolated pi-oauth bridge, to run the provider's refresh.
10
+ //
11
+ // This keeps Bivy's hot credential path decoupled from Pi: Pi is just another
12
+ // agent that reads the same store.
13
+ import { createCredentialVault } from "./credential-store.js";
14
+ import { refreshModelOAuth } from "./oauth/model-oauth.js";
15
+ /** Refresh an OAuth token this many ms before it expires (clock-skew guard). */
16
+ const OAUTH_REFRESH_SKEW_MS = 60_000;
17
+ /** Resolver over Bivy's credential store, with OAuth refresh-on-read via the bridge. */
18
+ export class NodeCredentialResolver {
19
+ credsDir;
20
+ store;
21
+ constructor(credsDir) {
22
+ this.credsDir = credsDir;
23
+ this.store = createCredentialVault(credsDir);
24
+ }
25
+ async getCredential(provider) {
26
+ const id = provider.trim().toLowerCase();
27
+ if (!id)
28
+ return undefined;
29
+ const cred = await this.store.read(id).catch(() => undefined);
30
+ if (!cred)
31
+ return undefined;
32
+ if (cred.type === "api_key") {
33
+ const token = typeof cred.key === "string" ? cred.key : "";
34
+ if (!token)
35
+ return undefined;
36
+ return { provider: id, kind: "api_key", token, ...(cred.env ? { env: cred.env } : {}) };
37
+ }
38
+ // OAuth: use the stored access token while it's fresh; otherwise ask Pi to
39
+ // run the provider's refresh (which rotates + persists back into our store
40
+ // under the store lock).
41
+ let token = typeof cred.access === "string" ? cred.access : "";
42
+ const expires = Number(cred.expires) || 0;
43
+ if (!token || expires <= Date.now() + OAUTH_REFRESH_SKEW_MS) {
44
+ const refreshed = await refreshModelOAuth(this.credsDir, id).catch(() => undefined);
45
+ if (refreshed)
46
+ token = refreshed;
47
+ }
48
+ if (!token)
49
+ return undefined;
50
+ const env = cred.env;
51
+ return { provider: id, kind: "oauth", token, ...(env ? { env } : {}) };
52
+ }
53
+ /** Providers with a stored credential — the vault's contents (not ambient env). */
54
+ async listConfigured() {
55
+ const infos = await this.store.list().catch(() => []);
56
+ return infos.map((info) => info.providerId);
57
+ }
58
+ }
59
+ /** Build the shared credential resolver from the node's credential vault dir. */
60
+ export function createCredentialStore(credsDir) {
61
+ return new NodeCredentialResolver(credsDir);
62
+ }
63
+ // Conventional environment variables agent CLIs/SDKs read for each provider. This
64
+ // is how a login the user did once inside Bivy reaches an arbitrary agent process:
65
+ // the agent doesn't know about Bivy's vault, but it does read these env vars.
66
+ const PROVIDER_ENV_KEYS = {
67
+ anthropic: "ANTHROPIC_API_KEY",
68
+ openai: "OPENAI_API_KEY",
69
+ openrouter: "OPENROUTER_API_KEY",
70
+ google: "GEMINI_API_KEY",
71
+ gemini: "GEMINI_API_KEY",
72
+ "google-vertex": "GOOGLE_API_KEY",
73
+ groq: "GROQ_API_KEY",
74
+ mistral: "MISTRAL_API_KEY",
75
+ deepseek: "DEEPSEEK_API_KEY",
76
+ xai: "XAI_API_KEY",
77
+ together: "TOGETHER_API_KEY",
78
+ fireworks: "FIREWORKS_API_KEY",
79
+ cohere: "COHERE_API_KEY",
80
+ perplexity: "PERPLEXITY_API_KEY",
81
+ };
82
+ /** Env var an api-key credential for `provider` should be exposed under. */
83
+ export function apiKeyEnvVar(provider) {
84
+ return PROVIDER_ENV_KEYS[provider] ?? `${provider.replace(/[^a-z0-9]+/gi, "_").toUpperCase()}_API_KEY`;
85
+ }
86
+ // A custom endpoint's base URL is stored in its credential `env` under one of
87
+ // these vars. When that endpoint is the session's ACTIVE provider we also alias
88
+ // its key to the matching standard key var, so an agent that reads OPENAI_* /
89
+ // AZURE_OPENAI_* / ANTHROPIC_* picks up the custom endpoint. Injected only for
90
+ // the active provider — a global base-URL var would hijack the real provider.
91
+ const BASE_URL_TO_KEY_VAR = {
92
+ OPENAI_BASE_URL: "OPENAI_API_KEY",
93
+ AZURE_OPENAI_BASE_URL: "AZURE_OPENAI_API_KEY",
94
+ ANTHROPIC_BASE_URL: "ANTHROPIC_API_KEY",
95
+ };
96
+ /**
97
+ * Map every credential the node holds (or a chosen subset) to the environment
98
+ * variables an agent process reads, so one sign-in inside Bivy serves whatever
99
+ * runtime/model the user selects — not just the Pi agent. Anthropic OAuth
100
+ * (Claude Pro/Max) is exposed as CLAUDE_CODE_OAUTH_TOKEN; every other credential
101
+ * is exposed as the provider's API-key var.
102
+ *
103
+ * Provider-scoped `env` (custom base URLs) is injected ONLY for `activeProvider`
104
+ * — the session's selected provider. Base-URL vars like OPENAI_BASE_URL are
105
+ * global to the agent process, so injecting a custom endpoint's base URL for a
106
+ * non-active provider would silently redirect the real provider. Best-effort: a
107
+ * provider that fails to resolve is skipped rather than failing the session.
108
+ */
109
+ export async function buildAgentCredentialEnv(store, providers, activeProvider) {
110
+ const ids = providers ?? (store.listConfigured ? await store.listConfigured().catch(() => []) : []);
111
+ const active = activeProvider?.trim().toLowerCase() || undefined;
112
+ // Process the active provider LAST so its base URL and standard-key alias win
113
+ // over a same-named var from another provider (e.g. a real "openai" key var
114
+ // must not clobber an active custom endpoint's aliased OPENAI_API_KEY).
115
+ const ordered = active ? [...ids].sort((a, b) => Number(a === active) - Number(b === active)) : ids;
116
+ const env = {};
117
+ for (const id of ordered) {
118
+ let cred;
119
+ try {
120
+ cred = await store.getCredential(id);
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ if (!cred)
126
+ continue;
127
+ const isActive = !!active && cred.provider === active;
128
+ if (cred.kind === "oauth") {
129
+ // OAuth *subscription* tokens are provider-specific and are not accepted
130
+ // as a plain API key. Only Anthropic has a documented env var an external
131
+ // agent reads (Claude Code's OAuth token); other providers' subscription
132
+ // logins can't be handed off this way, so don't emit a misleading key.
133
+ if (cred.provider !== "anthropic")
134
+ continue;
135
+ Object.assign(env, cred.env ?? {});
136
+ env.CLAUDE_CODE_OAUTH_TOKEN = cred.token;
137
+ }
138
+ else {
139
+ env[apiKeyEnvVar(cred.provider)] = cred.token;
140
+ // A custom endpoint's base URL (and standard-key alias) is injected only
141
+ // when it's the active provider, so it can't hijack the real provider.
142
+ if (isActive && cred.env) {
143
+ Object.assign(env, cred.env);
144
+ for (const baseVar of Object.keys(cred.env)) {
145
+ const keyVar = BASE_URL_TO_KEY_VAR[baseVar];
146
+ if (keyVar && cred.token)
147
+ env[keyVar] = cred.token;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return env;
153
+ }