@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.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- 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
|
+
}
|