@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,75 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Shared runtime normalization helpers (docs/dramatic-simplification-plan.md,
5
+ // slice 3). Every runtime adapter has to turn its own model shape into the
6
+ // runtime-neutral `ModelInfo`. Each used to reimplement that mapping (pi.ts,
7
+ // claude-code.ts, …), so the same field had subtly different fallbacks per
8
+ // runtime. This is the one home for that mapping; adapters pass their per-runtime
9
+ // quirks (default provider, whether the model is `configured`) as options.
10
+ //
11
+ // Pure and defensive — a bare string, a `{model}` blob, or a rich record all map
12
+ // without throwing — so it is unit-testable without a live agent (see
13
+ // test/runtime-normalize.test.ts).
14
+ /**
15
+ * Pull a usable string model id out of a loose record.
16
+ *
17
+ * A runtime (e.g. the Claude Agent SDK's `supportedModels()`) can hand us a
18
+ * record whose id lives under a field other than `id`/`model`, or whose `model`
19
+ * is itself a nested object (`{ model: { id, displayName } }`). The old
20
+ * `id ?? model ?? String(model)` chain coerced those objects with `String()`,
21
+ * yielding the literal string "[object Object]" — which then flowed all the way
22
+ * to the agent CLI as a model id and was rejected ("Model '[object Object]' is
23
+ * not a recognized model id"). Resolve defensively and never stringify an
24
+ * object into an id: try the known id-bearing fields, recurse into a nested
25
+ * `model` object, and only fall back to `String()` for a primitive.
26
+ */
27
+ function resolveModelId(model) {
28
+ if (model == null)
29
+ return "";
30
+ if (typeof model === "string")
31
+ return model;
32
+ if (typeof model === "number")
33
+ return String(model);
34
+ if (typeof model === "object") {
35
+ for (const key of ["id", "model", "value", "slug", "name"]) {
36
+ const candidate = model[key];
37
+ if (typeof candidate === "string" && candidate)
38
+ return candidate;
39
+ // `model.model` (and friends) can be a nested record — dig in.
40
+ if (candidate && typeof candidate === "object") {
41
+ const nested = resolveModelId(candidate);
42
+ if (nested)
43
+ return nested;
44
+ }
45
+ }
46
+ return "";
47
+ }
48
+ return String(model);
49
+ }
50
+ /**
51
+ * Map a runtime's loose model record into the neutral `ModelInfo`.
52
+ *
53
+ * Field resolution is the lenient superset of what the per-runtime versions did:
54
+ * - id: `id` → `model` → nested `model.*` id → `value`/`slug`/`name`
55
+ * - name: `displayName` → `name` → id
56
+ * - reasoning: `reasoning` || `supportsThinking`
57
+ * - maxTokens: `maxTokens` → `maxOutputTokens`
58
+ * - input/configured: passed through only when present (key stays absent otherwise)
59
+ */
60
+ export function toModelInfo(model, opts = {}) {
61
+ const id = resolveModelId(model);
62
+ const info = {
63
+ provider: model?.provider ?? opts.defaultProvider ?? "",
64
+ id,
65
+ name: model?.displayName ?? model?.name ?? id,
66
+ reasoning: Boolean(model?.reasoning ?? model?.supportsThinking),
67
+ contextWindow: model?.contextWindow,
68
+ maxTokens: model?.maxTokens ?? model?.maxOutputTokens,
69
+ };
70
+ if (model?.input !== undefined)
71
+ info.input = model.input;
72
+ if (opts.configured != null)
73
+ info.configured = opts.configured;
74
+ return info;
75
+ }
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Verified OAuth configs for Bivy-owned subscription providers.
4
+ //
5
+ // Every value here is ported VERBATIM from the shipped @earendil-works/pi-ai
6
+ // source (auth/oauth/*.js in 0.80.9) — real, verified client ids and endpoints,
7
+ // not guesses. Owning these lets Bivy run the OAuth login + token refresh itself,
8
+ // so no credential operation depends on Pi.
9
+ //
10
+ // Covered (fully Bivy-owned): Anthropic (Claude Pro/Max), OpenAI Codex (ChatGPT),
11
+ // xAI (Grok). GitHub Copilot (two-stage token + dynamic base URL entangled with
12
+ // Pi's request layer) and Radius (self-describing gateway) are intentionally not
13
+ // reimplemented here — see docs/keystore-migration-plan.md.
14
+ /** Anthropic uses a JSON token body; OpenAI/xAI use form-encoded. */
15
+ export const MODEL_OAUTH_PROVIDERS = {
16
+ anthropic: {
17
+ id: "anthropic",
18
+ displayName: "Anthropic (Claude Pro/Max)",
19
+ clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
20
+ scopes: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
21
+ flow: "auth_code",
22
+ tokenEncoding: "json",
23
+ authorizeUrl: "https://claude.ai/oauth/authorize",
24
+ tokenUrl: "https://platform.claude.com/v1/oauth/token",
25
+ callback: { port: 53692, path: "/callback", redirectHost: "localhost" },
26
+ authorizeParams: { code: "true" },
27
+ stateIsVerifier: true,
28
+ refreshSkewMs: 5 * 60 * 1000,
29
+ refreshRotates: true,
30
+ },
31
+ "openai-codex": {
32
+ id: "openai-codex",
33
+ displayName: "OpenAI (ChatGPT Plus/Pro)",
34
+ clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
35
+ scopes: "openid profile email offline_access",
36
+ flow: "auth_code",
37
+ tokenEncoding: "form",
38
+ authorizeUrl: "https://auth.openai.com/oauth/authorize",
39
+ tokenUrl: "https://auth.openai.com/oauth/token",
40
+ callback: { port: 1455, path: "/auth/callback", redirectHost: "localhost" },
41
+ authorizeParams: {
42
+ id_token_add_organizations: "true",
43
+ codex_cli_simplified_flow: "true",
44
+ originator: "pi",
45
+ },
46
+ refreshSkewMs: 0,
47
+ refreshRotates: true,
48
+ accountIdClaim: { path: "https://api.openai.com/auth", field: "chatgpt_account_id" },
49
+ },
50
+ xai: {
51
+ id: "xai",
52
+ displayName: "xAI (Grok / X subscription)",
53
+ clientId: "b1a00492-073a-47ea-816f-4c329264a828",
54
+ scopes: "openid profile email offline_access grok-cli:access api:access",
55
+ flow: "device_code",
56
+ tokenEncoding: "form",
57
+ tokenUrl: "https://auth.x.ai/oauth2/token",
58
+ deviceAuthUrl: "https://auth.x.ai/oauth2/device/code",
59
+ deviceParams: { referrer: "pi" },
60
+ deviceGrantType: "urn:ietf:params:oauth:grant-type:device_code",
61
+ refreshSkewMs: 5 * 60 * 1000,
62
+ refreshRotates: false,
63
+ },
64
+ };
65
+ /** Provider ids Bivy can natively drive an OAuth subscription login/refresh for. */
66
+ export function nativeOAuthProviderIds() {
67
+ return Object.keys(MODEL_OAUTH_PROVIDERS);
68
+ }
69
+ /** Whether Bivy owns OAuth for this provider (vs. it being api-key or unsupported). */
70
+ export function isNativeOAuthProvider(id) {
71
+ return Object.prototype.hasOwnProperty.call(MODEL_OAUTH_PROVIDERS, id.trim().toLowerCase());
72
+ }
73
+ export function getModelOAuthProvider(id) {
74
+ return MODEL_OAUTH_PROVIDERS[id.trim().toLowerCase()];
75
+ }
@@ -0,0 +1,324 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Bivy-owned OAuth engine for subscription model providers.
4
+ //
5
+ // Login (authorization-code + PKCE with a local callback server and manual-paste
6
+ // fallback, or RFC-8628 device-code) and token refresh are performed here,
7
+ // natively, with no dependency on Pi. Verified provider configs live in
8
+ // model-oauth-providers.ts. Credentials are persisted through Bivy's own store.
9
+ //
10
+ // The interaction contract (prompt/notify) is Bivy-owned here too, so the OAuth
11
+ // UX layer (server.ts, bivy-login.ts) no longer imports any Pi type.
12
+ import http from "node:http";
13
+ import { randomBytes } from "node:crypto";
14
+ import { createPkce } from "../../integrations/oauth.js";
15
+ import { createCredentialVault } from "../credential-store.js";
16
+ import { getModelOAuthProvider } from "./model-oauth-providers.js";
17
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
+ // --- HTTP helpers ------------------------------------------------------------
19
+ async function postToken(url, encoding, body) {
20
+ const res = await fetch(url, {
21
+ method: "POST",
22
+ headers: encoding === "json"
23
+ ? { "content-type": "application/json", accept: "application/json" }
24
+ : { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
25
+ body: encoding === "json" ? JSON.stringify(body) : new URLSearchParams(body).toString(),
26
+ });
27
+ const text = await res.text();
28
+ let payload;
29
+ try {
30
+ payload = JSON.parse(text);
31
+ }
32
+ catch {
33
+ throw new Error(`Token endpoint returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
34
+ }
35
+ if (!res.ok) {
36
+ const err = (payload.error_description || payload.error || text.slice(0, 200));
37
+ throw new Error(`OAuth token request failed (${res.status}): ${err}`);
38
+ }
39
+ return payload;
40
+ }
41
+ /** Decode a JWT and read `claimPath` → `field` (used for OpenAI's chatgpt_account_id). */
42
+ function jwtClaim(token, claimPath, field) {
43
+ const parts = token.split(".");
44
+ if (parts.length < 2 || !parts[1])
45
+ return undefined;
46
+ try {
47
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
48
+ const claim = payload[claimPath];
49
+ if (claim && typeof claim === "object") {
50
+ const value = claim[field];
51
+ return typeof value === "string" ? value : undefined;
52
+ }
53
+ }
54
+ catch {
55
+ // malformed token — treat as no claim
56
+ }
57
+ return undefined;
58
+ }
59
+ function tokensFrom(provider, payload, prev) {
60
+ const access = typeof payload.access_token === "string" ? payload.access_token : "";
61
+ if (!access)
62
+ throw new Error("OAuth token response is missing access_token");
63
+ const rotated = typeof payload.refresh_token === "string" ? payload.refresh_token : "";
64
+ const refresh = rotated || prev?.refresh || "";
65
+ const expiresIn = Number(payload.expires_in) || 3600;
66
+ const expires = Date.now() + expiresIn * 1000 - provider.refreshSkewMs;
67
+ let accountId = prev?.accountId;
68
+ if (provider.accountIdClaim) {
69
+ accountId = jwtClaim(access, provider.accountIdClaim.path, provider.accountIdClaim.field) ?? accountId;
70
+ if (!accountId)
71
+ throw new Error(`Could not extract account id for "${provider.id}" from the OAuth token`);
72
+ }
73
+ return { access, refresh, expires, ...(accountId ? { accountId } : {}) };
74
+ }
75
+ // --- Authorization-code flow (browser + callback server + manual paste) ------
76
+ function buildAuthorizeUrl(provider, opts) {
77
+ const url = new URL(provider.authorizeUrl);
78
+ for (const [k, v] of Object.entries(provider.authorizeParams ?? {}))
79
+ url.searchParams.set(k, v);
80
+ url.searchParams.set("client_id", provider.clientId);
81
+ url.searchParams.set("response_type", "code");
82
+ url.searchParams.set("redirect_uri", opts.redirectUri);
83
+ url.searchParams.set("scope", provider.scopes);
84
+ url.searchParams.set("code_challenge", opts.challenge);
85
+ url.searchParams.set("code_challenge_method", "S256");
86
+ url.searchParams.set("state", opts.state);
87
+ return url.toString();
88
+ }
89
+ /** Pull the bare authorization code out of a pasted redirect URL / `code#state` / raw code. */
90
+ export function extractAuthCode(value) {
91
+ const v = value.trim();
92
+ if (!v)
93
+ throw new Error("No authorization code provided");
94
+ if (v.includes("://")) {
95
+ try {
96
+ const code = new URL(v).searchParams.get("code");
97
+ if (code)
98
+ return code;
99
+ }
100
+ catch {
101
+ // not a URL after all — fall through
102
+ }
103
+ }
104
+ const hash = v.indexOf("#");
105
+ return hash > 0 ? v.slice(0, hash) : v;
106
+ }
107
+ function respondHtml(res, status, title, body) {
108
+ res.statusCode = status;
109
+ res.setHeader("content-type", "text/html; charset=utf-8");
110
+ res.end(`<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:3rem;max-width:32rem"><h2>${title}</h2><p>${body}</p><p>You can close this tab and return to Bivy.</p></body>`);
111
+ }
112
+ /** Local HTTP listener that resolves the authorization code from the provider redirect. */
113
+ function startCallbackServer(host, port, pathName, expectedState, signal) {
114
+ let server;
115
+ let closed = false;
116
+ const close = () => {
117
+ if (closed)
118
+ return;
119
+ closed = true;
120
+ try {
121
+ server?.close();
122
+ }
123
+ catch { /* ignore */ }
124
+ };
125
+ const promise = new Promise((resolve, reject) => {
126
+ server = http.createServer((req, res) => {
127
+ let url;
128
+ try {
129
+ url = new URL(req.url ?? "", `http://${host}:${port}`);
130
+ }
131
+ catch {
132
+ res.statusCode = 400;
133
+ res.end("bad request");
134
+ return;
135
+ }
136
+ if (url.pathname !== pathName) {
137
+ res.statusCode = 404;
138
+ res.end("not found");
139
+ return;
140
+ }
141
+ const error = url.searchParams.get("error");
142
+ const code = url.searchParams.get("code");
143
+ const state = url.searchParams.get("state");
144
+ if (error) {
145
+ respondHtml(res, 400, "Sign-in failed", `The provider returned: ${error}`);
146
+ close();
147
+ reject(new Error(`OAuth error: ${error}`));
148
+ return;
149
+ }
150
+ if (!code) {
151
+ respondHtml(res, 400, "Sign-in failed", "No authorization code in the callback.");
152
+ return;
153
+ }
154
+ if (expectedState && state && state !== expectedState) {
155
+ respondHtml(res, 400, "Sign-in failed", "Authorization state did not match.");
156
+ return;
157
+ }
158
+ respondHtml(res, 200, "Signed in", "Bivy received your authorization.");
159
+ close();
160
+ resolve(code);
161
+ });
162
+ server.on("error", (err) => {
163
+ // e.g. the callback port is busy — the manual-paste path still works.
164
+ close();
165
+ reject(err);
166
+ });
167
+ signal?.addEventListener("abort", () => { close(); reject(new Error("Login aborted")); }, { once: true });
168
+ server.listen(port, host);
169
+ });
170
+ return { promise, close };
171
+ }
172
+ async function loginAuthCode(provider, interaction) {
173
+ const { verifier, challenge } = createPkce();
174
+ const state = provider.stateIsVerifier ? verifier : randomBytes(16).toString("hex");
175
+ const redirectUri = `http://${provider.callback.redirectHost}:${provider.callback.port}${provider.callback.path}`;
176
+ const authorizeUrl = buildAuthorizeUrl(provider, { challenge, state, redirectUri });
177
+ interaction.notify({
178
+ type: "auth_url",
179
+ url: authorizeUrl,
180
+ instructions: "Sign in in your browser. If it doesn't return automatically, paste the redirect URL (or the code) back here.",
181
+ });
182
+ const bindHost = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
183
+ const sources = [];
184
+ const closers = [];
185
+ if (provider.callback) {
186
+ const server = startCallbackServer(bindHost, provider.callback.port, provider.callback.path, state, interaction.signal);
187
+ closers.push(server.close);
188
+ sources.push(server.promise);
189
+ }
190
+ sources.push(interaction.prompt({ type: "manual_code", message: "Paste the redirect URL or authorization code:", signal: interaction.signal }));
191
+ let raw;
192
+ try {
193
+ raw = await Promise.any(sources);
194
+ }
195
+ catch (error) {
196
+ // Every source failed (callback error + paste rejected/cancelled).
197
+ if (error instanceof AggregateError) {
198
+ const last = error.errors[error.errors.length - 1];
199
+ throw last instanceof Error ? last : new Error("Sign-in did not complete");
200
+ }
201
+ throw error;
202
+ }
203
+ finally {
204
+ for (const close of closers)
205
+ close();
206
+ }
207
+ const code = extractAuthCode(raw);
208
+ const body = {
209
+ grant_type: "authorization_code",
210
+ client_id: provider.clientId,
211
+ code,
212
+ code_verifier: verifier,
213
+ redirect_uri: redirectUri,
214
+ };
215
+ if (provider.tokenEncoding === "json")
216
+ body.state = state; // Anthropic includes state
217
+ const payload = await postToken(provider.tokenUrl, provider.tokenEncoding, body);
218
+ return tokensFrom(provider, payload);
219
+ }
220
+ // --- Device-code flow (RFC 8628) ---------------------------------------------
221
+ async function loginDeviceCode(provider, interaction) {
222
+ const start = await postToken(provider.deviceAuthUrl, "form", {
223
+ client_id: provider.clientId,
224
+ scope: provider.scopes,
225
+ ...(provider.deviceParams ?? {}),
226
+ });
227
+ const deviceCode = String(start.device_code ?? "");
228
+ const userCode = String(start.user_code ?? "");
229
+ const verificationUri = String(start.verification_uri_complete ?? start.verification_uri ?? "");
230
+ let interval = (Number(start.interval) || 5) * 1000;
231
+ const deadline = Date.now() + (Number(start.expires_in) || 900) * 1000;
232
+ if (!deviceCode || !userCode)
233
+ throw new Error("Device authorization did not return a device/user code");
234
+ interaction.notify({
235
+ type: "device_code",
236
+ userCode,
237
+ verificationUri,
238
+ intervalSeconds: Number(start.interval) || 5,
239
+ expiresInSeconds: Number(start.expires_in) || undefined,
240
+ });
241
+ // Providers that use device-code sleep one interval before the first poll.
242
+ await sleep(interval);
243
+ for (;;) {
244
+ if (interaction.signal?.aborted)
245
+ throw new Error("Login aborted");
246
+ if (Date.now() > deadline)
247
+ throw new Error("Device login timed out. Please try again.");
248
+ let payload;
249
+ try {
250
+ payload = await postToken(provider.tokenUrl, "form", {
251
+ grant_type: provider.deviceGrantType ?? "urn:ietf:params:oauth:grant-type:device_code",
252
+ client_id: provider.clientId,
253
+ device_code: deviceCode,
254
+ });
255
+ }
256
+ catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ if (/authorization_pending/i.test(message)) {
259
+ await sleep(interval);
260
+ continue;
261
+ }
262
+ if (/slow_down/i.test(message)) {
263
+ interval += 5000;
264
+ await sleep(interval);
265
+ continue;
266
+ }
267
+ throw error; // access_denied / expired_token / other
268
+ }
269
+ return tokensFrom(provider, payload);
270
+ }
271
+ }
272
+ // --- Public API --------------------------------------------------------------
273
+ /** Provider ids Bivy can natively drive a subscription login for. */
274
+ export { isNativeOAuthProvider, nativeOAuthProviderIds } from "./model-oauth-providers.js";
275
+ /**
276
+ * Run a provider's OAuth login natively and persist the credential into Bivy's
277
+ * store. The interaction (prompt/notify) is caller-supplied; the whole flow —
278
+ * PKCE, callback server, token exchange — is Bivy's.
279
+ */
280
+ export async function loginModelOAuth(credsDir, providerId, interaction) {
281
+ const provider = getModelOAuthProvider(providerId);
282
+ if (!provider)
283
+ throw new Error(`Provider "${providerId}" does not support subscription login`);
284
+ const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction) : await loginAuthCode(provider, interaction);
285
+ const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
286
+ await createCredentialVault(credsDir).modify(providerId, async () => credential);
287
+ }
288
+ /** Exchange the refresh token for a fresh credential (network call; throws on failure). */
289
+ async function refreshTokens(provider, current) {
290
+ const refresh = typeof current.refresh === "string" ? current.refresh : "";
291
+ if (!refresh)
292
+ throw new Error(`No refresh token stored for "${provider.id}"`);
293
+ const payload = await postToken(provider.tokenUrl, provider.tokenEncoding, {
294
+ grant_type: "refresh_token",
295
+ client_id: provider.clientId,
296
+ refresh_token: refresh,
297
+ });
298
+ return tokensFrom(provider, payload, {
299
+ refresh: provider.refreshRotates ? undefined : refresh,
300
+ accountId: typeof current.accountId === "string" ? current.accountId : undefined,
301
+ });
302
+ }
303
+ /**
304
+ * Ensure a fresh OAuth access token for a provider and return it. The refresh
305
+ * runs inside the store's per-provider lock, and re-checks expiry there, so
306
+ * concurrent callers (and other processes) can't double-spend a rotating refresh
307
+ * token — the single-flight guarantee. Returns undefined if the provider has no
308
+ * stored OAuth credential or isn't natively supported.
309
+ */
310
+ export async function refreshModelOAuth(credsDir, providerId) {
311
+ const provider = getModelOAuthProvider(providerId);
312
+ if (!provider)
313
+ return undefined;
314
+ const result = await createCredentialVault(credsDir).modify(providerId, async (current) => {
315
+ if (!current || current.type !== "oauth")
316
+ return current;
317
+ // Someone else already refreshed while we waited for the lock — use theirs.
318
+ if (Number(current.expires) > Date.now())
319
+ return current;
320
+ const fresh = await refreshTokens(provider, current);
321
+ return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
322
+ });
323
+ return result?.type === "oauth" ? result.access : undefined;
324
+ }
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // OpenCode credential preflight.
4
+ //
5
+ // `opencode run` boots OpenCode's own internal server and calls the selected
6
+ // provider through it. When that provider has no credential, OpenCode does NOT
7
+ // surface a clean 401 — its server throws and prints its opaque `NamedError`
8
+ // envelope to stdout:
9
+ //
10
+ // Error: { "name": "UnknownError", "data": {
11
+ // "message": "Unexpected server error. Check server logs for details.",
12
+ // "ref": "err_25799b27" } }
13
+ //
14
+ // Bivy relays that verbatim into the agent output, and the `err_` ref points at
15
+ // OpenCode's server log — which Bivy doesn't capture — so the user is stuck. Like
16
+ // codex-preflight.ts, this backstop detects the missing credential up front (the
17
+ // vault handoff in credentials.ts injects each provider's key into the env) and
18
+ // returns a clear, actionable message instead of letting OpenCode 500.
19
+ //
20
+ // It's provider-aware: OpenCode models are `provider/model` ids, so the run's
21
+ // selected provider tells us exactly which key var must be present.
22
+ import { apiKeyEnvVar } from "./credentials.js";
23
+ /** Actionable message shown when OpenCode's selected provider has no credential. */
24
+ export function opencodeNoCredentialMessage(provider, envVar) {
25
+ const label = provider.charAt(0).toUpperCase() + provider.slice(1);
26
+ return [
27
+ `OpenCode has no ${label} credential on this node, so it can't start a turn — its server would fail with an opaque \`UnknownError: Unexpected server error\` (the \`err_…\` ref points at OpenCode's own server log, which Bivy can't read).`,
28
+ "",
29
+ "Fix it any of these ways:",
30
+ `• Add a ${label} API key under Keys & OAuth — Bivy passes it to OpenCode as ${envVar}, or`,
31
+ "• Pick a different model whose provider you've already connected, or",
32
+ `• Run \`opencode auth login\` on this node to sign in directly.`,
33
+ ].join("\n");
34
+ }
35
+ /**
36
+ * Returns a human-readable error when OpenCode's selected provider has no usable
37
+ * credential in the resolved environment, or undefined to proceed.
38
+ *
39
+ * Conservative by design: if the provider is unknown (no model selected, or a
40
+ * provider we don't map) we return undefined rather than block a run that might
41
+ * authenticate some other way (e.g. an on-disk `opencode auth` login). Anthropic
42
+ * is additionally satisfied by a Claude Code OAuth token.
43
+ */
44
+ export function opencodeCredentialPreflight(env, ctx = {}) {
45
+ const provider = ctx.provider?.trim().toLowerCase();
46
+ if (!provider)
47
+ return undefined;
48
+ // Anthropic can authenticate via the Claude Code OAuth token, not just a key.
49
+ if (provider === "anthropic" && env.CLAUDE_CODE_OAUTH_TOKEN?.trim())
50
+ return undefined;
51
+ const envVar = apiKeyEnvVar(provider);
52
+ if (env[envVar]?.trim())
53
+ return undefined;
54
+ return opencodeNoCredentialMessage(provider, envVar);
55
+ }
@@ -0,0 +1,82 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Model-provider auth surface for the daemon (the "Models & providers" screen).
4
+ //
5
+ // Storage is Bivy's own (credential-store.ts): set / remove / export / import all
6
+ // operate directly on the encrypted vault with no Pi involvement. The one thing
7
+ // that legitimately comes from Pi is the model-provider *catalog* — the list of
8
+ // providers and their display names / OAuth capability — which the isolated
9
+ // pi-oauth bridge supplies (Bivy has no model catalog of its own).
10
+ import { createCredentialVault } from "./credential-store.js";
11
+ import { listPiProviders } from "./pi-oauth.js";
12
+ /**
13
+ * Enumerate model providers with their current auth status. The catalog + status
14
+ * come from Pi's provider list (joined with our injected store); the stored
15
+ * credential `kind` comes from Bivy's vault.
16
+ */
17
+ export async function listProviders(credsDir, piDir) {
18
+ const [catalog, stored] = await Promise.all([listPiProviders(credsDir, piDir), createCredentialVault(credsDir).list()]);
19
+ const infoById = new Map(stored.map((info) => [info.providerId, info]));
20
+ return catalog.map((provider) => ({
21
+ id: provider.id,
22
+ name: provider.name,
23
+ oauth: provider.oauth,
24
+ configured: provider.configured,
25
+ kind: infoById.get(provider.id)?.type,
26
+ source: provider.source,
27
+ expiresAt: infoById.get(provider.id)?.expiresAt,
28
+ }));
29
+ }
30
+ /** Export configured model-provider credentials, keyed by provider id. */
31
+ export async function exportProviderAuth(credsDir) {
32
+ return createCredentialVault(credsDir).exportAll();
33
+ }
34
+ /**
35
+ * Import a cross-node provider auth snapshot into the local vault.
36
+ *
37
+ * Merge, never destroy (see BivyCredentialStore.importAll): the account snapshot
38
+ * is applied on top of the local vault rather than replacing it, and a
39
+ * locally-fresher OAuth token is never overwritten by an older one in the
40
+ * snapshot (rotated refresh tokens are single-use). Provider removal propagates
41
+ * via removeProvider() re-pushing, not destructive imports.
42
+ */
43
+ export async function importProviderAuth(credsDir, providers) {
44
+ await createCredentialVault(credsDir).importAll(providers);
45
+ }
46
+ /** Store an API key for a model provider (shared by every agent via the vault). */
47
+ export async function setProviderApiKey(credsDir, provider, key) {
48
+ const id = provider.trim().toLowerCase();
49
+ if (!id)
50
+ throw new Error("Provider is required");
51
+ await createCredentialVault(credsDir).setApiKey(id, key);
52
+ }
53
+ /**
54
+ * Store an api-key credential together with provider-scoped `env` (e.g. a custom
55
+ * base URL). Used for user-provided endpoints: the base URL travels with the
56
+ * credential so it can be injected into non-Pi agents. A `key`/`env` left
57
+ * undefined preserves whatever the vault already holds; an empty-key credential
58
+ * is valid (a keyless local server that still needs its base URL).
59
+ */
60
+ export async function setProviderCredential(credsDir, provider, opts) {
61
+ const id = provider.trim().toLowerCase();
62
+ if (!id)
63
+ throw new Error("Provider is required");
64
+ await createCredentialVault(credsDir).modify(id, async (prev) => {
65
+ const prevApiKey = prev && prev.type === "api_key" ? prev : undefined;
66
+ const key = (opts.key ?? prevApiKey?.key ?? "").toString();
67
+ const env = opts.env ?? prevApiKey?.env;
68
+ const cred = { type: "api_key" };
69
+ if (key)
70
+ cred.key = key;
71
+ if (env && Object.keys(env).length)
72
+ cred.env = env;
73
+ return cred;
74
+ });
75
+ }
76
+ /** Forget a provider's stored credential (API key or OAuth token). */
77
+ export async function removeProvider(credsDir, provider) {
78
+ const id = provider.trim().toLowerCase();
79
+ if (!id)
80
+ throw new Error("Provider is required");
81
+ await createCredentialVault(credsDir).delete(id);
82
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Pi model-catalog bridge.
4
+ //
5
+ // Bivy owns credentials end-to-end: storage (credential-store.ts) and OAuth
6
+ // login/refresh (oauth/model-oauth.ts). The one thing that legitimately remains
7
+ // Pi's is the model/provider CATALOG — the list of providers and their models —
8
+ // because Bivy has no model catalog of its own. That is model metadata, not a
9
+ // credential, and it is isolated here. Pi never performs a credential operation
10
+ // for Bivy; it only enumerates models and (as an agent) reads through the store.
11
+ import path from "node:path";
12
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
13
+ import { createCredentialVault } from "./credential-store.js";
14
+ import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
15
+ /** Adapt Bivy's store to pi-ai's structurally-identical CredentialStore for injection. */
16
+ export function piCredentialStore(store) {
17
+ return store;
18
+ }
19
+ /**
20
+ * Build a Pi ModelRuntime backed by Bivy's credential store. Credentials come
21
+ * from the node's shared vault (`credsDir`); the model catalog cache
22
+ * (`models.json`) lives in Pi's own dir (`piDir`). `allowModelNetwork` defaults
23
+ * to false for the catalog paths (fast, offline); the pi *session* runtime
24
+ * (pi.ts) allows network so dynamic model lists load.
25
+ */
26
+ export function createPiModelRuntime(opts) {
27
+ const store = opts.store ?? createCredentialVault(opts.credsDir);
28
+ return ModelRuntime.create({
29
+ credentials: piCredentialStore(store),
30
+ modelsPath: path.join(opts.piDir, "models.json"),
31
+ allowModelNetwork: opts.allowModelNetwork ?? false,
32
+ });
33
+ }
34
+ /** Enumerate the model-provider catalog with each provider's current auth status. */
35
+ export async function listPiProviders(credsDir, piDir) {
36
+ const runtime = await createPiModelRuntime({ credsDir, piDir });
37
+ return runtime
38
+ .getProviders()
39
+ .map((provider) => {
40
+ const status = runtime.getProviderAuthStatus(provider.id);
41
+ return {
42
+ id: provider.id,
43
+ name: provider.name,
44
+ // Whether we offer a subscription login is decided by Bivy's own OAuth
45
+ // registry, not Pi's provider metadata.
46
+ oauth: isNativeOAuthProvider(provider.id),
47
+ configured: status.configured,
48
+ source: status.source,
49
+ };
50
+ })
51
+ .sort((a, b) => a.name.localeCompare(b.name));
52
+ }