@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
|
|
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
|
+
}
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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