@bivy/bivy 0.5.1-staging.76 → 0.5.1-staging.78

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.
@@ -15,6 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
+ import { isModelAuthError } from "./auth-errors.js";
18
19
  /** Candidate paths for the `claude` CLI's own stored login (`.credentials.json`). */
19
20
  export function claudeCredentialFiles(deps = {}) {
20
21
  const home = deps.home ?? os.homedir();
@@ -68,7 +69,7 @@ export function anthropicCredentialPreflight(env, deps = {}) {
68
69
  }
69
70
  /** True when a raw error string looks like an Anthropic auth failure (401 etc.). */
70
71
  export function isAnthropicAuthError(raw) {
71
- return /\b401\b|unauthorized|authentication|invalid x-api-key|(missing|invalid).*(bearer|api[\s_-]?key|token)/i.test(raw);
72
+ return isModelAuthError(raw);
72
73
  }
73
74
  /**
74
75
  * Phrase an SDK error for the user: an auth failure gets the sign-in guidance
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Classify model auth failures and map a failing session to the provider the
4
+ // user needs to (re)authenticate.
5
+ //
6
+ // When a runtime has no usable model credential — or a present-but-expired one —
7
+ // its first upstream request fails with a 401. Codex surfaces this as
8
+ // `failed to connect to websocket: HTTP error: 401 Unauthorized, url:
9
+ // wss://api.openai.com/v1/responses`; Anthropic as `401 Unauthorized`; others
10
+ // similarly. Rather than let that stream past as a raw error, the daemon runs
11
+ // `isModelAuthError` over surfaced errors and, when one matches, broadcasts a
12
+ // `session.auth_required` targeted at `authProviderForSession(...)` so the client
13
+ // can pop the "Sign in to your model" sheet for the right provider.
14
+ import { MODEL_OAUTH_PROVIDERS } from "./oauth/model-oauth-providers.js";
15
+ /**
16
+ * True when a raw error string looks like a model auth failure (401 / missing
17
+ * bearer / invalid key). Covers both the generic SDK phrasing and Codex's
18
+ * websocket-connect form.
19
+ */
20
+ export function isModelAuthError(raw) {
21
+ const text = String(raw || "");
22
+ // Generic: an explicit 401, "unauthorized"/"authentication", or a
23
+ // missing/invalid bearer/api-key/token phrase.
24
+ if (/\b401\b|unauthorized|authentication|invalid x-api-key|(missing|invalid)[\s\S]*(bearer|api[\s_-]?key|token)/i.test(text))
25
+ return true;
26
+ // Codex app-server: websocket connect rejected with an HTTP 401/403.
27
+ if (/failed to connect to websocket[\s\S]*http error:\s*40[13]/i.test(text))
28
+ return true;
29
+ return false;
30
+ }
31
+ // Provider ids the app knows how to authenticate (OAuth subscription providers
32
+ // plus any provider that has a conventional API-key env var). Used to validate
33
+ // a model provider before signalling the client to sign in for it.
34
+ const KNOWN_KEY_PROVIDERS = new Set([
35
+ "anthropic",
36
+ "openai",
37
+ "openrouter",
38
+ "google",
39
+ "gemini",
40
+ "groq",
41
+ "mistral",
42
+ "deepseek",
43
+ "xai",
44
+ "together",
45
+ "fireworks",
46
+ "cohere",
47
+ "perplexity",
48
+ ]);
49
+ /**
50
+ * Resolve which credential provider the user should sign in for, given the
51
+ * failing runtime id and (optionally) its active model provider. Returns
52
+ * undefined when we can't confidently name a provider — in that case the caller
53
+ * should not raise the sign-in sheet.
54
+ *
55
+ * Codex runtimes (`codex`, `codex-approvals`) are served by the ChatGPT
56
+ * subscription, whose vault/provider id is `openai-codex`; that's the id the
57
+ * "Sign in with OpenAI" OAuth button targets.
58
+ */
59
+ export function authProviderForSession(runtimeId, modelProvider) {
60
+ const id = String(runtimeId || "").trim().toLowerCase();
61
+ if (id.startsWith("codex"))
62
+ return "openai-codex";
63
+ const provider = String(modelProvider || "").trim().toLowerCase();
64
+ if (!provider)
65
+ return undefined;
66
+ if (Object.prototype.hasOwnProperty.call(MODEL_OAUTH_PROVIDERS, provider))
67
+ return provider;
68
+ if (KNOWN_KEY_PROVIDERS.has(provider))
69
+ return provider;
70
+ return undefined;
71
+ }
@@ -1071,6 +1071,16 @@ function codexAppServerRuntime(credsDir, tier) {
1071
1071
  args: [shim],
1072
1072
  ...(policy ? { env: { BIVY_CODEX_SANDBOX: policy.sandbox, BIVY_CODEX_APPROVAL_POLICY: policy.approvalPolicy } } : {}),
1073
1073
  credentials: createCredentialStore(credsDir),
1074
+ // Mint ~/.codex/auth.json from the vault before the app-server spawns, then
1075
+ // preflight — mirroring the `codex` exec path (see below). Without this the
1076
+ // shim would launch uncredentialed and 401 on its first /responses call with
1077
+ // no actionable message. `prepare` runs pre-spawn (the shim reads auth.json at
1078
+ // launch); `preflight` backstops the genuinely uncredentialed case.
1079
+ prepare: async () => {
1080
+ const home = await ensureCodexAuth(credsDir);
1081
+ return home ? { CODEX_HOME: home } : {};
1082
+ },
1083
+ preflight: (env) => codexCredentialPreflight(env),
1074
1084
  // Session-less catalog contribution: Codex runs OpenAI models under a ChatGPT
1075
1085
  // subscription (provider id "openai-codex"). The authoritative per-session
1076
1086
  // list comes from the app-server; this is the picker preview.
@@ -43,7 +43,9 @@ export const MODEL_OAUTH_PROVIDERS = {
43
43
  codex_cli_simplified_flow: "true",
44
44
  originator: "pi",
45
45
  },
46
- refreshSkewMs: 0,
46
+ // Refresh a little early (like Anthropic/xAI) so Codex never bakes an
47
+ // already-expired access token into ~/.codex/auth.json and 401s mid-turn.
48
+ refreshSkewMs: 5 * 60 * 1000,
47
49
  refreshRotates: true,
48
50
  accountIdClaim: { path: "https://api.openai.com/auth", field: "chatgpt_account_id" },
49
51
  },
@@ -212,6 +212,9 @@ class ProtocolSession {
212
212
  currentModelId;
213
213
  /** Provider of the selected model — scopes custom base-URL env injection. */
214
214
  currentModelProvider;
215
+ /** Env patch from the last `prepare` run (e.g. Codex's minted CODEX_HOME),
216
+ * applied to the spawned child and reused by the per-turn preflight. */
217
+ prepareEnv = {};
215
218
  getModels() { return this.models; }
216
219
  getCurrentModel() {
217
220
  if (!this.currentModelId)
@@ -257,12 +260,19 @@ class ProtocolSession {
257
260
  const credentialEnv = this.runtimeOptions.credentials
258
261
  ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
259
262
  : {};
263
+ // Optional prepare step, run before the child spawns because a shim reads its
264
+ // credential at launch (e.g. Codex mints ~/.codex/auth.json from the vault and
265
+ // pins CODEX_HOME). Stored so the per-turn preflight sees the same env. Best-
266
+ // effort: a throw is swallowed and treated as no patch.
267
+ this.prepareEnv = this.runtimeOptions.prepare
268
+ ? (await Promise.resolve(this.runtimeOptions.prepare({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv })).catch(() => undefined)) ?? {}
269
+ : {};
260
270
  const child = spawn(this.runtimeOptions.command, this.runtimeOptions.args ?? [], {
261
271
  cwd: this.cwd,
262
272
  // bivySessionEnv() lets the agent's own shell resolve its session for
263
273
  // `bivy attach <path>` (see session-env.ts); spread last so it can never
264
274
  // be shadowed by an operator-configured env var of the same name.
265
- env: { ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...bivySessionEnv(this.id) },
275
+ env: { ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...this.prepareEnv, ...bivySessionEnv(this.id) },
266
276
  stdio: "pipe",
267
277
  });
268
278
  this.child = child;
@@ -503,6 +513,38 @@ class ProtocolSession {
503
513
  async prompt(text, options) {
504
514
  const wasStarted = this.started;
505
515
  await this.open();
516
+ // Per-turn prepare + credential preflight, mirroring ProcessRuntime. Unlike a
517
+ // fresh-process runtime, the protocol child is long-lived, so a credential
518
+ // connected AFTER it spawned (a mid-session sign-in from the "Sign in to your
519
+ // model" sheet) would never be materialized by start()'s one-shot prepare.
520
+ // Re-run prepare here so e.g. Codex mints ~/.codex/auth.json from the just-
521
+ // completed sign-in before this turn — the app-server reads the default auth
522
+ // file, so it recovers on the next prompt instead of staying stuck on the
523
+ // initial 401. Then preflight backstops the genuinely uncredentialed case with
524
+ // an actionable error instead of an opaque upstream 401. ensureCodexAuth is
525
+ // idempotent (it no-ops once auth.json exists), so the repeat is cheap.
526
+ if (this.runtimeOptions.prepare || this.runtimeOptions.preflight) {
527
+ const credentialEnv = this.runtimeOptions.credentials
528
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
529
+ : {};
530
+ if (this.runtimeOptions.prepare) {
531
+ this.prepareEnv =
532
+ (await Promise.resolve(this.runtimeOptions.prepare({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv })).catch(() => undefined)) ??
533
+ this.prepareEnv;
534
+ }
535
+ const preflightError = this.runtimeOptions.preflight?.({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...this.prepareEnv }, { provider: this.currentModelProvider });
536
+ if (preflightError) {
537
+ this.streaming = false;
538
+ const message = { role: "assistant", content: "", errorMessage: preflightError };
539
+ this.messages.push(message);
540
+ this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
541
+ this.emit({ type: "session.error", error: preflightError });
542
+ this.emit({ type: "message_end", message });
543
+ this.emit({ type: "turn_end" });
544
+ this.emit({ type: "agent_end", code: 1, signal: null });
545
+ return;
546
+ }
547
+ }
506
548
  if (!wasStarted)
507
549
  this.emit({ type: "agent_start" });
508
550
  const prompt = text.trim();
package/dist/server.js CHANGED
@@ -22,6 +22,7 @@ import { InMemoryLocationRegistry } from "./runtime/location-registry.js";
22
22
  import { ControlPlaneSessionLocationRegistry, LayeredSessionLocationRegistry } from "./runtime/control-plane-location.js";
23
23
  import { attachAdoptedSessions, classifyAttachFailure } from "./runtime/adoption.js";
24
24
  import { createCredentialStore } from "./runtime/credentials.js";
25
+ import { isModelAuthError, authProviderForSession } from "./runtime/auth-errors.js";
25
26
  import { createCredentialVault, migrateVaultDir } from "./runtime/credential-store.js";
26
27
  import { provisionAgentRun } from "./runtime/credential-provisioning.js";
27
28
  import { ingestAgentCredentials } from "./runtime/credential-ingest.js";
@@ -6794,6 +6795,24 @@ function terminalTurnError(event) {
6794
6795
  }
6795
6796
  return undefined;
6796
6797
  }
6798
+ /**
6799
+ * When a surfaced error looks like a model auth failure (no credential, or an
6800
+ * expired/invalid one that 401'd upstream), tell the client which provider to
6801
+ * (re)authenticate so it can pop the "Sign in to your model" sheet instead of
6802
+ * leaving a bare error bubble. Fires at most once per turn (reset on turn_start)
6803
+ * so a retry storm — e.g. Codex's repeated websocket 401s — raises the sheet once.
6804
+ */
6805
+ function maybeSignalAuthRequired(record, errorText) {
6806
+ if (record.authRequiredSignaled)
6807
+ return;
6808
+ if (!isModelAuthError(errorText))
6809
+ return;
6810
+ const provider = authProviderForSession(record.runtimeId, record.session.getCurrentModel()?.provider);
6811
+ if (!provider)
6812
+ return;
6813
+ record.authRequiredSignaled = true;
6814
+ broadcast({ type: "session.auth_required", sessionId: record.id, provider, reason: errorText.slice(0, 400) });
6815
+ }
6797
6816
  function attachSessionListeners(record) {
6798
6817
  record.unsubscribe?.();
6799
6818
  // In-session model reroute controller (inert unless BIVY_SESSION_MODEL_FALLBACK
@@ -6843,6 +6862,10 @@ function attachSessionListeners(record) {
6843
6862
  ].includes(event.type)) {
6844
6863
  markSessionWorking(record, event);
6845
6864
  }
6865
+ // A fresh turn re-arms the once-per-turn auth-required signal, so a credential
6866
+ // that was fixed (or newly broke) is re-evaluated on the next prompt.
6867
+ if (event.type === "turn_start")
6868
+ record.authRequiredSignaled = false;
6846
6869
  if (event.type === "message_update" && event.message && (event.message?.role === "assistant")) {
6847
6870
  persistIntermediateFromEvent(record, event, false);
6848
6871
  }
@@ -6882,6 +6905,12 @@ function attachSessionListeners(record) {
6882
6905
  const e = event;
6883
6906
  broadcast({ type: "session.notice", sessionId: record.id, level: e.level ?? "info", message: String(e.message ?? ""), ...(e.action ? { action: e.action } : {}) });
6884
6907
  }
6908
+ if (event.type === "session.error") {
6909
+ // A runtime-emitted auth failure (Codex's app-server websocket 401, or a
6910
+ // ProcessRuntime/ProtocolRuntime credential preflight) — raise the sign-in
6911
+ // sheet for the right provider alongside the inline error bubble.
6912
+ maybeSignalAuthRequired(record, String(event.error ?? ""));
6913
+ }
6885
6914
  if (event.type === "runtime.commands") {
6886
6915
  // The agent learned its own slash commands mid-session (e.g. Claude Code's
6887
6916
  // system/init reports slash_commands only after the first turn starts).
@@ -6930,6 +6959,9 @@ function attachSessionListeners(record) {
6930
6959
  metadata.touchSession(record.id, "failed");
6931
6960
  scheduleAdvertise();
6932
6961
  broadcast({ type: "session.error", sessionId: record.id, error: turnError });
6962
+ // If the terminal error is an auth failure (expired key/token → 4xx),
6963
+ // also raise the sign-in sheet for the failing provider.
6964
+ maybeSignalAuthRequired(record, turnError);
6933
6965
  void sendNotificationHint({
6934
6966
  kind: "session_error",
6935
6967
  sessionId: record.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.5.1-staging.76",
3
+ "version": "0.5.1-staging.78",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",