@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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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")
|