@bivy/bivy 0.11.0 → 0.11.1-staging.395

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.
@@ -972,12 +972,16 @@ class ClaudeSession {
972
972
  * node lost the race for — the same cross-consumer race codex-auth.ts notes).
973
973
  * Emits a "Refreshing credentials…" notice only when it actually restarts.
974
974
  */
975
- async restartWithFreshCredential() {
975
+ async restartWithFreshCredential(rejectedToken) {
976
976
  if (this.reloading)
977
977
  return false;
978
978
  this.reloading = true;
979
979
  try {
980
- const credEnv = await this.resolveCredentialEnv().catch(() => ({}));
980
+ // On a provider 401, identify the bearer that failed so the resolver can
981
+ // refresh it immediately even if its expiry claims it is still valid.
982
+ // The resolver compares this under the vault lock, making concurrent
983
+ // failures converge on one rotation.
984
+ const credEnv = await this.resolveCredentialEnv(rejectedToken).catch(() => ({}));
981
985
  const nextToken = authTokenFromEnv(credEnv);
982
986
  if (!nextToken || nextToken === this.spawnedToken)
983
987
  return false;
@@ -1018,14 +1022,14 @@ class ClaudeSession {
1018
1022
  * empty object when no vault is wired or no credential is configured, so the
1019
1023
  * SDK falls back to its own auth (ambient env / `claude` CLI login).
1020
1024
  */
1021
- async resolveCredentialEnv() {
1025
+ async resolveCredentialEnv(rejectedToken) {
1022
1026
  const store = this.runtimeOptions.credentials;
1023
1027
  if (!store)
1024
1028
  return {};
1025
1029
  const provider = this.runtimeOptions.credentialProvider?.trim() || "anthropic";
1026
1030
  let cred;
1027
1031
  try {
1028
- cred = await store.getCredential(provider, { workspace: this.cwd });
1032
+ cred = await store.getCredential(provider, { workspace: this.cwd, ...(rejectedToken ? { rejectedToken } : {}) });
1029
1033
  }
1030
1034
  catch {
1031
1035
  return {};
@@ -1042,27 +1046,13 @@ class ClaudeSession {
1042
1046
  async consume(q) {
1043
1047
  try {
1044
1048
  for await (const message of q)
1045
- this.handle(message);
1049
+ await this.handle(message);
1046
1050
  }
1047
1051
  catch (error) {
1048
1052
  this.streaming = false;
1049
1053
  const raw = error instanceof Error ? error.message : String(error);
1050
- // Mid-flight credential reload: a long-lived query bakes its OAuth token in
1051
- // at spawn, so a turn that outlives the token fails here with a 401 even
1052
- // though the vault holds a freshly-refreshed one. Re-spawn once with the
1053
- // fresh credential and re-drive the interrupted prompt so the turn continues
1054
- // instead of dying. Bounded to one attempt per turn (reloadedThisTurn), only
1055
- // when a prompt is actually in flight, and only when the vault produced a
1056
- // *different* token (else restartWithFreshCredential returns false and we
1057
- // fall through to surfacing the error — no retry loop on a dead credential).
1058
- if (isAnthropicAuthError(raw) && !this.reloadedThisTurn && this.inFlightPrompt !== undefined) {
1059
- this.reloadedThisTurn = true;
1060
- if (await this.restartWithFreshCredential()) {
1061
- this.streaming = true;
1062
- this.input.push({ type: "user", message: { role: "user", content: this.inFlightPrompt }, parent_tool_use_id: null });
1063
- return; // the re-spawned query's consume() now drives the turn to completion.
1064
- }
1065
- }
1054
+ if (await this.recoverFromAuthError(raw))
1055
+ return;
1066
1056
  // Emit session.error (the toast path) — agent_end's `error` field is not
1067
1057
  // surfaced by the client, so without this a thrown SDK error (e.g. a 401)
1068
1058
  // stopped the turn silently. Auth failures get sign-in guidance appended.
@@ -1071,6 +1061,20 @@ class ClaudeSession {
1071
1061
  this.emit({ type: "agent_end", error: raw });
1072
1062
  }
1073
1063
  }
1064
+ /** Recover a provider 401 regardless of whether the SDK throws it, emits it as
1065
+ * assistant text, or puts it in an error result. The latter is how revoked
1066
+ * OAuth tokens commonly arrive, so only handling consume()'s catch would leave
1067
+ * the turn stopped until the user sent another message. */
1068
+ async recoverFromAuthError(raw) {
1069
+ if (!isAnthropicAuthError(raw) || this.reloadedThisTurn || this.inFlightPrompt === undefined)
1070
+ return false;
1071
+ this.reloadedThisTurn = true;
1072
+ if (!(await this.restartWithFreshCredential(this.spawnedToken)))
1073
+ return false;
1074
+ this.streaming = true;
1075
+ this.input.push({ type: "user", message: { role: "user", content: this.inFlightPrompt }, parent_tool_use_id: null });
1076
+ return true;
1077
+ }
1074
1078
  beginMessage() {
1075
1079
  if (this.startedMessage)
1076
1080
  return;
@@ -1102,7 +1106,7 @@ class ClaudeSession {
1102
1106
  this.emit({ type: "tool_image", toolUseId, toolName, mimeType: image.mimeType, data: image.data });
1103
1107
  }
1104
1108
  }
1105
- handle(message) {
1109
+ async handle(message) {
1106
1110
  switch (message?.type) {
1107
1111
  case "stream_event": {
1108
1112
  const event = message.event;
@@ -1129,6 +1133,12 @@ class ClaudeSession {
1129
1133
  // not the human — never persist or surface them as chat.
1130
1134
  if (hasMetaFlag(message))
1131
1135
  break;
1136
+ // Claude Code sometimes reports auth failures as an ordinary assistant
1137
+ // text message rather than throwing. Intercept it before it is persisted
1138
+ // or shown, refresh, and transparently continue the same prompt.
1139
+ const assistantText = extractText(message.message);
1140
+ if (assistantText && await this.recoverFromAuthError(assistantText))
1141
+ break;
1132
1142
  const model = message.message?.model;
1133
1143
  if (model)
1134
1144
  this.currentModel = toModelInfo({ id: model });
@@ -1233,6 +1243,9 @@ class ClaudeSession {
1233
1243
  break;
1234
1244
  }
1235
1245
  case "result": {
1246
+ const resultError = message.subtype && message.subtype !== "success" ? String(message.result ?? message.subtype) : "";
1247
+ if (resultError && await this.recoverFromAuthError(resultError))
1248
+ break;
1236
1249
  this.streaming = false;
1237
1250
  this.startedMessage = false;
1238
1251
  this.currentText = "";
@@ -95,10 +95,12 @@ export class NodeCredentialResolver {
95
95
  // under the store lock).
96
96
  let token = typeof cred.access === "string" ? cred.access : "";
97
97
  const expires = Number(cred.expires) || 0;
98
- if (!token || expires <= Date.now() + OAUTH_REFRESH_SKEW_MS) {
98
+ if (!token || expires <= Date.now() + OAUTH_REFRESH_SKEW_MS || context?.rejectedToken === token) {
99
99
  // Refresh the SELECTED record (its label), not just the provider default —
100
- // so a second account on the same provider is left untouched.
101
- const refreshed = await this.oauth.refresh(id, selection.record.label).catch(() => undefined);
100
+ // so a second account on the same provider is left untouched. A provider
101
+ // 401 forces refresh even before expiry; the rejected-token guard is
102
+ // re-checked under the OAuth store lock to avoid duplicate rotations.
103
+ const refreshed = await this.oauth.refresh(id, selection.record.label, context?.rejectedToken).catch(() => undefined);
102
104
  if (refreshed)
103
105
  token = refreshed;
104
106
  }
@@ -15,7 +15,7 @@ import { createCredentialStore as createResolver, NodeCredentialResolver, buildA
15
15
  import { testCredential } from "../credentials/api.js";
16
16
  export { NodeCredentialResolver, buildAgentCredentialEnv, apiKeyEnvVar };
17
17
  function oauthAdapter(credsDir) {
18
- return { refresh: (provider, label) => refreshModelOAuth(credsDir, provider, label) };
18
+ return { refresh: (provider, label, rejectedAccess) => refreshModelOAuth(credsDir, provider, label, rejectedAccess) };
19
19
  }
20
20
  /** Build the shared credential resolver, binding the node reference/OAuth adapters. */
21
21
  export function createCredentialStore(credsDir) {
@@ -338,7 +338,7 @@ async function refreshTokens(provider, current) {
338
338
  * token — the single-flight guarantee. Returns undefined if the provider has no
339
339
  * stored OAuth credential or isn't natively supported.
340
340
  */
341
- export async function refreshModelOAuthState(credsDir, providerId, label = DEFAULT_LABEL) {
341
+ export async function refreshModelOAuthState(credsDir, providerId, label = DEFAULT_LABEL, rejectedAccess) {
342
342
  const provider = getModelOAuthProvider(providerId);
343
343
  if (!provider)
344
344
  return { state: "reconnect_required", error: "Provider does not support OAuth refresh" };
@@ -349,9 +349,14 @@ export async function refreshModelOAuthState(credsDir, providerId, label = DEFAU
349
349
  if (!current || current.type !== "oauth")
350
350
  return current;
351
351
  found = true;
352
- if (Number(current.expires) > Date.now())
352
+ const expired = Number(current.expires) <= Date.now();
353
+ // Normal reads refresh on expiry. A 401 may revoke an otherwise unexpired
354
+ // access token, so refresh that exact rejected token too. If another
355
+ // process already replaced it while we waited for the lock, keep the
356
+ // replacement instead of rotating its refresh token again.
357
+ if (!expired && (!rejectedAccess || current.access !== rejectedAccess))
353
358
  return current;
354
- wasExpired = true;
359
+ wasExpired = expired;
355
360
  const fresh = await refreshTokens(provider, current);
356
361
  return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, refreshedAt: fresh.refreshedAt, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
357
362
  });
@@ -364,8 +369,8 @@ export async function refreshModelOAuthState(credsDir, providerId, label = DEFAU
364
369
  return { state: reconnect ? "reconnect_required" : "transient_failure", error: error instanceof Error ? error.message : String(error) };
365
370
  }
366
371
  }
367
- export async function refreshModelOAuth(credsDir, providerId, label = DEFAULT_LABEL) {
368
- const result = await refreshModelOAuthState(credsDir, providerId, label);
372
+ export async function refreshModelOAuth(credsDir, providerId, label = DEFAULT_LABEL, rejectedAccess) {
373
+ const result = await refreshModelOAuthState(credsDir, providerId, label, rejectedAccess);
369
374
  if (result.state === "transient_failure")
370
375
  throw new Error(result.error ?? "OAuth refresh temporarily failed");
371
376
  if (result.state === "reconnect_required" && result.error !== "No OAuth credential is stored")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.11.0",
3
+ "version": "0.11.1-staging.395",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",