@botbuddy/cli 1.64.5 → 1.64.7
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.
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/stack-native-callback.mjs +53 -25
- package/src/stack.mjs +172 -43
- package/src/staging-pool/errors.mjs +6 -2
- package/src/staging-pool/signin-gotrue-totp.mjs +10 -2
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.7",
|
|
3
|
+
"source_commit": "52077eb9393ab47e2a35b6ddba00a9a62955ca07",
|
|
4
|
+
"source_sha256": "9ac2dd0f2b02bdb4eac636e402b63620881c126c760d60b5313122323830b18a",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "7ab6917ca6dd35571ff58b30e3baa08c387b7395a61e8e75d833e86ba379bfea"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.7",
|
|
3
|
+
"source_commit": "52077eb9393ab47e2a35b6ddba00a9a62955ca07",
|
|
4
|
+
"source_sha256": "9ac2dd0f2b02bdb4eac636e402b63620881c126c760d60b5313122323830b18a",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "0752327535efa03af48f49c97eca74b0df6050b46f16ef44ccc0ebdf042169d6"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.64.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.64.7",
|
|
4
|
+
"source_commit": "52077eb9393ab47e2a35b6ddba00a9a62955ca07",
|
|
5
|
+
"source_sha256": "9ac2dd0f2b02bdb4eac636e402b63620881c126c760d60b5313122323830b18a",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "7ab6917ca6dd35571ff58b30e3baa08c387b7395a61e8e75d833e86ba379bfea",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "0752327535efa03af48f49c97eca74b0df6050b46f16ef44ccc0ebdf042169d6",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
|
@@ -81,18 +81,24 @@ function callbackReadiness(value,callbackPort){
|
|
|
81
81
|
|
|
82
82
|
// Renewal cadence and acknowledgement budgets. The first renewal also runs the relay's
|
|
83
83
|
// consuming-namespace probe (validation + docker exec), so it gets the larger budget.
|
|
84
|
-
const GENERATION_MS=30_000,RENEW_INTERVAL_MS=10_000,FIRST_ACK_MS=15_000,RENEW_ACK_MS=6_000,STATUS_DEADLINE_MS=5_000,
|
|
85
|
-
// A
|
|
86
|
-
// acknowledgement)
|
|
87
|
-
const
|
|
88
|
-
|
|
84
|
+
const GENERATION_MS=30_000,RENEW_INTERVAL_MS=10_000,FIRST_ACK_MS=15_000,RENEW_ACK_MS=6_000,STATUS_DEADLINE_MS=5_000,REFUSAL_RETRY_MS=1_000;
|
|
85
|
+
// A definitive answer stops the child only if one re-read repeats it, and that re-read (read +
|
|
86
|
+
// acknowledgement) must still land inside the current 30 s generation.
|
|
87
|
+
const REFUSAL_GRACE_MS=GENERATION_MS-REFUSAL_RETRY_MS-STATUS_DEADLINE_MS-RENEW_ACK_MS;
|
|
88
|
+
// BOT-2957: the longest the route may live on its last positive answer while the control plane
|
|
89
|
+
// does not answer, counted from when that read was sent. A control-plane stall must not kill a
|
|
90
|
+
// healthy run, so the requester keeps renewing the 30 s generation for this long; this number is
|
|
91
|
+
// also the longest a callback revoked during such an outage can keep running. The relay and the
|
|
92
|
+
// gateway keep their own 30 s generation, so a requester that dies still loses the route at once.
|
|
93
|
+
export const CALLBACK_STALE_PROOF_MAX_MS=180_000;
|
|
94
|
+
export async function openNativeCallbackTransport({current,leaseId,hostKey,callbackPort,requesterMachineId,sshTarget,readCurrent,sshBin='ssh',env=process.env,run=execFile,spawnImpl=spawn,verifyRuntime=verifyInstalledCallbackRuntime,openGateway=openCallbackGateway,renewIntervalMs=RENEW_INTERVAL_MS,firstAckMs=FIRST_ACK_MS,renewAckMs=RENEW_ACK_MS,statusDeadlineMs=STATUS_DEADLINE_MS,refusalRetryMs=REFUSAL_RETRY_MS,refusalGraceMs=REFUSAL_GRACE_MS,staleProofMaxMs=CALLBACK_STALE_PROOF_MAX_MS,onOutage=()=>{}}){
|
|
89
95
|
if(typeof readCurrent!=='function')fail('CALLBACK_AUTHORIZATION_LOST','current callback status reader required');
|
|
90
96
|
const routeGeneration=randomUUID(),options={leaseId,hostKey,callbackPort,requesterMachineId,routeGeneration};
|
|
91
97
|
const binding=currentCallbackBinding(current,options),snapshot=JSON.stringify(binding),target=assertSshTarget(sshTarget),d=binding.descriptor;
|
|
92
98
|
await verifyRuntime({descriptor:d,sshTarget:target,sshBin,env,run});
|
|
93
99
|
const capability=randomBytes(32).toString('hex'),readinessNonce=randomBytes(16).toString('hex'),directory='/tmp/bb-callback-'+randomUUID(),socket=directory+'/callback.sock';
|
|
94
100
|
const sshArgs=['-T','-o','BatchMode=yes','-o','ControlMaster=no','-o','ControlPath=none','--',target];
|
|
95
|
-
let gateway,child,terminal,refreshTimer,statusAbort,pendingReady,pendingAck,closed=false,refreshing=false,cleanupPromise,exitResult;const exits=new Set();
|
|
101
|
+
let gateway,child,terminal,refreshTimer,retryTimer,staleTimer,statusAbort,pendingReady,pendingAck,closed=false,refreshing=false,cleanupPromise,exitResult;const exits=new Set();
|
|
96
102
|
const reportExit=exit=>{exitResult=exit;for(const listener of exits)listener(exit)};
|
|
97
103
|
const remote=async command=>{try{return await run(sshBin,[...sshArgs,command],{env,timeout:15_000,maxBuffer:4096})}catch{fail('CALLBACK_REMOTE_OPERATION_FAILED','owned callback operation failed');}};
|
|
98
104
|
const retire=async()=>{
|
|
@@ -102,7 +108,7 @@ export async function openNativeCallbackTransport({current,leaseId,hostKey,callb
|
|
|
102
108
|
await remote(command);
|
|
103
109
|
};
|
|
104
110
|
const close=()=>cleanupPromise??=(async()=>{
|
|
105
|
-
closed=true;clearInterval(refreshTimer);statusAbort?.abort();pendingReady?.reject(Error('callback closed'));pendingAck?.reject(Error('callback closed'));await gateway?.close();
|
|
111
|
+
closed=true;clearInterval(refreshTimer);clearTimeout(retryTimer);clearTimeout(staleTimer);statusAbort?.abort();pendingReady?.reject(Error('callback closed'));pendingAck?.reject(Error('callback closed'));await gateway?.close();
|
|
106
112
|
if(child){child.stdin.end();const term=setTimeout(()=>child.kill('SIGTERM'),3000);const kill=setTimeout(()=>child.kill('SIGKILL'),6000);term.unref();kill.unref();try{await terminal}finally{clearTimeout(term);clearTimeout(kill)}}
|
|
107
113
|
try{await retire()}catch{fail('CALLBACK_CLEANUP_UNCERTAIN','owned callback cleanup could not be verified');}
|
|
108
114
|
})();
|
|
@@ -130,33 +136,55 @@ export async function openNativeCallbackTransport({current,leaseId,hostKey,callb
|
|
|
130
136
|
child.stdin.write(JSON.stringify({binding,capability,socket_path:socket,readiness_nonce:readinessNonce})+'\n');
|
|
131
137
|
});
|
|
132
138
|
const callbackUrl=callbackReadiness(ready,callbackPort);
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
let renewedAt=null,refusal=null,provenAt=null,provenWall=null,lastReadFailure=null;
|
|
140
|
+
const outage={episodes:0,failed_reads:0,longest_ms:0,last_failure:null,recovered:0};let outageActive=false;
|
|
141
|
+
// BOT-2957: both clocks, because a sleeping requester stops one of them on some platforms.
|
|
142
|
+
const staleFor=()=>provenAt===null?Infinity:Math.max(performance.now()-provenAt,Date.now()-provenWall);
|
|
143
|
+
const noteOutage=active=>{outage.longest_ms=Math.max(outage.longest_ms,Math.round(staleFor()));if(active&&!outageActive)outage.episodes++;else if(!active&&outageActive)outage.recovered++;outageActive=active;onOutage({...outage});};
|
|
144
|
+
// Ends the route without a positive answer. A pending refusal keeps its own name.
|
|
145
|
+
const stopUnproven=()=>{
|
|
146
|
+
if(closed)return;
|
|
147
|
+
const lost=refusal?{code:refusal.code??'CALLBACK_AUTHORIZATION_LOST',authorization_reason:boundedAuthorizationReason(refusal.authorizationReason)??'refused'}:{code:'CALLBACK_STATUS_UNREADABLE',authorization_reason:boundedAuthorizationReason('status_unreadable:'+(lastReadFailure??'no_answer'))??'status_unreadable'};
|
|
148
|
+
if(outageActive)noteOutage(true);reportExit({reason:'callback_authorization_lost',...lost});void close().catch(()=>{});
|
|
149
|
+
};
|
|
150
|
+
const proven=(atMonotonic,atWall)=>{provenAt=atMonotonic;provenWall=atWall;refusal=null;clearTimeout(staleTimer);staleTimer=setTimeout(stopUnproven,Math.max(0,staleProofMaxMs-(performance.now()-atMonotonic)));staleTimer.unref();};
|
|
151
|
+
const rereadSoon=()=>{clearTimeout(retryTimer);retryTimer=setTimeout(()=>{void refresh()},refusalRetryMs);retryTimer.unref();};
|
|
152
|
+
gateway.onClose(()=>{if(closed)return;if(staleFor()>=staleProofMaxMs){stopUnproven();return;}reportExit({reason:'callback_gateway_closed',code:'CALLBACK_GATEWAY_EXPIRED'});void close().catch(()=>{})});
|
|
135
153
|
const refresh=async(first=false)=>{
|
|
136
|
-
if(closed||refreshing)return;refreshing=true;
|
|
154
|
+
if(closed||refreshing)return;refreshing=true;clearTimeout(retryTimer);
|
|
155
|
+
// BOT-2957: every failure below names the step it failed in.
|
|
156
|
+
let step='status_read';
|
|
137
157
|
try{
|
|
138
|
-
statusAbort=new AbortController();let statusTimeout,renewed;
|
|
158
|
+
statusAbort=new AbortController();let statusTimeout,renewed=binding;const readAt=performance.now(),readWall=Date.now();
|
|
139
159
|
try{
|
|
140
|
-
const status=await new Promise((resolve,reject)=>{statusTimeout=setTimeout(()=>{statusAbort.abort();reject(Object.assign(Error('callback status deadline'),{transient:true}));},statusDeadlineMs);statusTimeout.unref();Promise.resolve().then(()=>readCurrent(statusAbort.signal)).then(resolve,reject).finally(()=>clearTimeout(statusTimeout));});
|
|
141
|
-
if(closed)return;renewed=currentCallbackBinding(status,options);
|
|
160
|
+
const status=await new Promise((resolve,reject)=>{statusTimeout=setTimeout(()=>{statusAbort.abort();reject(Object.assign(Error('callback status deadline'),{transient:true,readFailure:'deadline'}));},statusDeadlineMs);statusTimeout.unref();Promise.resolve().then(()=>readCurrent(statusAbort.signal)).then(resolve,reject).finally(()=>clearTimeout(statusTimeout));});
|
|
161
|
+
if(closed)return;step='status_answer';renewed=currentCallbackBinding(status,options);
|
|
142
162
|
if(JSON.stringify(renewed)!==snapshot)refuse(tokenReason('binding_changed',changedBindingField(binding,renewed)),'callback context changed');
|
|
143
|
-
|
|
163
|
+
if(outageActive)noteOutage(false);proven(readAt,readWall);lastReadFailure=null;
|
|
144
164
|
}catch(error){
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
if(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
// The opening renewal never rides on anything: it needs a fresh positive answer.
|
|
166
|
+
if(first){if(error?.transient===true&&!error.authorizationReason)error.authorizationReason=boundedAuthorizationReason('status_unreadable:'+(error.readFailure??'no_answer'))??'status_unreadable';throw error;}
|
|
167
|
+
if(error?.transient===true){
|
|
168
|
+
lastReadFailure=typeof error.readFailure==='string'?error.readFailure:'no_answer';
|
|
169
|
+
// An answered refusal is never extended on stale proof: its re-read has only the live generation.
|
|
170
|
+
if(refusal){if(performance.now()-renewedAt<refusalGraceMs){rereadSoon();return;}stopUnproven();return;}
|
|
171
|
+
// BOT-2957: an unanswered read is not an authorization answer. Renew on the last positive
|
|
172
|
+
// answer, only until the bound since that read, and never on any read after a refusal.
|
|
173
|
+
if(staleFor()>=staleProofMaxMs){stopUnproven();return;}
|
|
174
|
+
outage.failed_reads++;outage.last_failure=lastReadFailure;noteOutage(true);
|
|
175
|
+
}else{
|
|
176
|
+
// BOT-2880: a definitive answer stops the child only if one re-read repeats it, while the live
|
|
177
|
+
// generation can still carry that re-read. An expired grace fails closed.
|
|
178
|
+
if(!refusal&&performance.now()-renewedAt<refusalGraceMs){refusal=error;rereadSoon();return;}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
153
181
|
}
|
|
154
|
-
const sentAt=performance.now();
|
|
182
|
+
step='renewal_ack';const sentAt=performance.now();
|
|
155
183
|
await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Object.assign(Error('callback renewal acknowledgement deadline'),first?{}:{code:'CALLBACK_RENEWAL_ACK_TIMEOUT',transport:true})),first?firstAckMs:renewAckMs);timeout.unref();pendingAck={resolve:value=>{clearTimeout(timeout);resolve(value)},reject:error=>{clearTimeout(timeout);reject(error)}};child.stdin.write(JSON.stringify({binding:renewed,renew:true})+'\n');});
|
|
156
|
-
if(closed)return;renewedAt=sentAt;gateway.refresh(Object.freeze({snapshot}),30_000-(performance.now()-sentAt));
|
|
184
|
+
if(closed)return;renewedAt=sentAt;step='gateway_refresh';gateway.refresh(Object.freeze({snapshot}),Math.min(30_000-(performance.now()-sentAt),staleProofMaxMs-staleFor()));
|
|
157
185
|
}
|
|
158
186
|
// The first renewal carries the consuming-namespace probe: its uncoded failure is readiness.
|
|
159
|
-
catch(error){if(!closed){const authorizationReason=boundedAuthorizationReason(error?.authorizationReason);reportExit({reason:error?.transport===true?'callback_renewal_unacknowledged':'callback_authorization_lost',code:error?.code??(first?'CALLBACK_NOT_READY':'CALLBACK_AUTHORIZATION_LOST'),...(authorizationReason?{authorization_reason:authorizationReason}:{})});void close().catch(()=>{});}}
|
|
187
|
+
catch(error){if(!closed){const authorizationReason=boundedAuthorizationReason(error?.authorizationReason)??(error?.transport===true?null:'renewal_failed:'+step);reportExit({reason:error?.transport===true?'callback_renewal_unacknowledged':'callback_authorization_lost',code:error?.code??(first?'CALLBACK_NOT_READY':'CALLBACK_AUTHORIZATION_LOST'),...(authorizationReason?{authorization_reason:authorizationReason}:{})});void close().catch(()=>{});}}
|
|
160
188
|
finally{refreshing=false}
|
|
161
189
|
};
|
|
162
190
|
await refresh(true);if(closed||!gateway.isLive())throw Object.assign(Error('callback route not ready after readiness'),{code:exitResult?.code??'CALLBACK_NOT_READY',...(exitResult?.authorization_reason?{authorizationReason:exitResult.authorization_reason}:{})});
|
package/src/stack.mjs
CHANGED
|
@@ -53,7 +53,7 @@ import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
|
|
|
53
53
|
import { machineUuid } from "./machine-id.mjs";
|
|
54
54
|
import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validateBundleIncludePath } from "./stack-bundle.mjs";
|
|
55
55
|
import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
|
|
56
|
-
import { NATIVE_CALLBACK_TRANSPORT, boundedAuthorizationReason, openNativeCallbackTransport } from "./stack-native-callback.mjs";
|
|
56
|
+
import { CALLBACK_STALE_PROOF_MAX_MS, NATIVE_CALLBACK_TRANSPORT, boundedAuthorizationReason, openNativeCallbackTransport } from "./stack-native-callback.mjs";
|
|
57
57
|
import { FORWARD_ATTESTATION_ENV, startForwardAttestation, readForwardAttestation } from "./stack-forward-attestation.mjs";
|
|
58
58
|
import { stackServicesForForwards } from "./stack-service-discovery.mjs";
|
|
59
59
|
import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
|
|
@@ -381,7 +381,13 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
|
|
|
381
381
|
A refusal must repeat on one re-read before it stops the child (BOT-2880), and
|
|
382
382
|
callback_authorization_reason names the refused check, e.g. requester_host_disabled
|
|
383
383
|
(the requester machine's own Docker host was set to disabled: use maintenance),
|
|
384
|
-
lease_state:reaping, binding_changed:descriptor.producer_pid
|
|
384
|
+
lease_state:reaping, binding_changed:descriptor.producer_pid.
|
|
385
|
+
A control plane that does not answer is not a refusal (BOT-2957): the route rides on
|
|
386
|
+
its last positive answer for up to 180 s. Past that, callback_cause is
|
|
387
|
+
CALLBACK_STATUS_UNREADABLE and the reason names the failed read, e.g.
|
|
388
|
+
status_unreadable:deadline, status_unreadable:http_503, status_unreadable:rpc_error.
|
|
389
|
+
callback_outage on the receipt counts the gaps a run rode through.
|
|
390
|
+
6 LEASE_HEARTBEAT_UNANSWERED every heartbeat touch went unanswered for 180 s; child stopped, lease reaped
|
|
385
391
|
5 CALLBACK_PORT_BUSY --callback-port is already listening on the serving host (another run or
|
|
386
392
|
service), the host refused this run's -R because another run took the
|
|
387
393
|
port first, or another listener shares the port; this run holds no -R —
|
|
@@ -408,6 +414,11 @@ ${bold("EXAMPLE")}
|
|
|
408
414
|
botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
|
|
409
415
|
|
|
410
416
|
const STACK_COMMANDS = new Set(["up", "run", "status", "touch", "done", "cancel"]);
|
|
417
|
+
// BOT-2957: a heartbeat touch that goes unanswered is retried this soon, each with its own deadline,
|
|
418
|
+
// for up to CALLBACK_STALE_PROOF_MAX_MS. Past that the run stops with this code.
|
|
419
|
+
export const STACK_HEARTBEAT_UNANSWERED_CODE = "LEASE_HEARTBEAT_UNANSWERED";
|
|
420
|
+
const HEARTBEAT_RETRY_MS = 15_000;
|
|
421
|
+
const HEARTBEAT_TOUCH_DEADLINE_MS = 15_000;
|
|
411
422
|
|
|
412
423
|
/** Scoped help is assembled from the canonical text, so flags cannot drift. */
|
|
413
424
|
export function stackCommandHelp(command) {
|
|
@@ -1249,6 +1260,23 @@ async function renewStackSession(auth, { cwd, renew }) {
|
|
|
1249
1260
|
return withSource({ "x-agent-api-key": token }, credential.source, credential.identity);
|
|
1250
1261
|
}
|
|
1251
1262
|
|
|
1263
|
+
// BOT-2957: how long one call waits on a credential renewal, shared or its own. The renewal itself
|
|
1264
|
+
// takes no signal, so without this a stalled one would hold cleanup past --reap-timeout. The call
|
|
1265
|
+
// then reports no answer (never a refusal), and the renewal keeps running for whoever comes next.
|
|
1266
|
+
const RENEWAL_WAIT_MS = 30_000;
|
|
1267
|
+
const RENEWAL_UNFINISHED = Symbol("renewal unfinished");
|
|
1268
|
+
function untilRenewalWaitEnds(renewal, signal, waitMs) {
|
|
1269
|
+
if (signal?.aborted) return Promise.resolve(RENEWAL_UNFINISHED);
|
|
1270
|
+
return new Promise((resolve, reject) => {
|
|
1271
|
+
let timer = null;
|
|
1272
|
+
const done = (settle, value) => { clearTimeout(timer); signal?.removeEventListener?.("abort", stop); settle(value); };
|
|
1273
|
+
const stop = () => done(resolve, RENEWAL_UNFINISHED);
|
|
1274
|
+
signal?.addEventListener?.("abort", stop, { once: true });
|
|
1275
|
+
timer = setTimeout(stop, waitMs);
|
|
1276
|
+
renewal.then((value) => done(resolve, value), (error) => done(reject, error));
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1252
1280
|
/**
|
|
1253
1281
|
* BOT-1750 (Codex R10 P2): a stack RPC caller that transparently recovers from a
|
|
1254
1282
|
* server-rejected cached session ONCE — the owner-scoped management commands
|
|
@@ -1260,7 +1288,7 @@ async function renewStackSession(auth, { cwd, renew }) {
|
|
|
1260
1288
|
export function retryingStackCall({
|
|
1261
1289
|
callTool = callToolJson, authProvider = stackAuthHeader,
|
|
1262
1290
|
refresh = refreshRejectedCacheAuth, cwd = () => process.cwd(),
|
|
1263
|
-
auth: seedAuth,
|
|
1291
|
+
auth: seedAuth, renewalWaitMs = RENEWAL_WAIT_MS,
|
|
1264
1292
|
} = {}) {
|
|
1265
1293
|
// A pre-resolved `auth` seed lets a caller that already resolved the credential
|
|
1266
1294
|
// (runStackLifecycle short-circuits its own await) skip init() — avoiding an extra
|
|
@@ -1272,13 +1300,30 @@ export function retryingStackCall({
|
|
|
1272
1300
|
// rejected is a new rotation, not the same failure — so it may renew again. A renewal
|
|
1273
1301
|
// whose credential is itself rejected stays disarmed (never a per-call mint loop).
|
|
1274
1302
|
let renewalArmed = true;
|
|
1303
|
+
let pendingRenewal = null;
|
|
1275
1304
|
return {
|
|
1276
1305
|
async init() { if (auth === undefined) auth = await authProvider(); return auth; },
|
|
1277
1306
|
async call(name, args, callOptions = {}) {
|
|
1278
1307
|
let r = await callTool(name, args, { ...callOptions, auth });
|
|
1279
|
-
if (
|
|
1280
|
-
|
|
1281
|
-
|
|
1308
|
+
if (!r?.ok && r?.auth && (renewalArmed || pendingRenewal)) {
|
|
1309
|
+
// BOT-2957: a 401 while a renewal is still running says nothing yet: wait for that renewal
|
|
1310
|
+
// (the caller's own deadline bounds the wait). Reading it as an answer fenced a healthy run.
|
|
1311
|
+
if (!pendingRenewal) {
|
|
1312
|
+
renewalArmed = false;
|
|
1313
|
+
const rejected = auth;
|
|
1314
|
+
const renewal = (async () => {
|
|
1315
|
+
// A renewal the backend could not answer was not refused: keep it available, or the
|
|
1316
|
+
// first read after an outage is a dead credential's 401 that reads as a refusal.
|
|
1317
|
+
// The renewal adopts its own result, because every caller may have stopped waiting by then.
|
|
1318
|
+
try { const renewed = await refresh(rejected, { cwd: cwd() }); if (renewed) auth = renewed; return renewed; }
|
|
1319
|
+
catch (error) { if (classifyStackAuthError(error) === EXIT.BACKEND) renewalArmed = true; throw error; }
|
|
1320
|
+
})();
|
|
1321
|
+
pendingRenewal = renewal;
|
|
1322
|
+
const settled = () => { if (pendingRenewal === renewal) pendingRenewal = null; };
|
|
1323
|
+
renewal.then(settled, settled);
|
|
1324
|
+
}
|
|
1325
|
+
const fresh = await untilRenewalWaitEnds(pendingRenewal, callOptions.signal, renewalWaitMs);
|
|
1326
|
+
if (fresh === RENEWAL_UNFINISHED) return { ok: false, status: null, error: "transport: credential renewal still running" };
|
|
1282
1327
|
if (fresh) { auth = fresh; r = await callTool(name, args, { ...callOptions, auth }); }
|
|
1283
1328
|
}
|
|
1284
1329
|
if (r?.ok) renewalArmed = true;
|
|
@@ -3756,7 +3801,8 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
3756
3801
|
const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
|
|
3757
3802
|
const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
|
|
3758
3803
|
const withWebUrl = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
|
|
3759
|
-
const
|
|
3804
|
+
const withStopSignal = incidentBox.stopSignal && withWebUrl ? { ...withWebUrl, stopSignal: incidentBox.stopSignal } : withWebUrl;
|
|
3805
|
+
const final = incidentBox.callbackOutage && withStopSignal ? { ...withStopSignal, callbackOutage: incidentBox.callbackOutage } : withStopSignal;
|
|
3760
3806
|
// BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
|
|
3761
3807
|
if (final && typeof final === "object" && incidentBox.outcomeChannel) {
|
|
3762
3808
|
Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
|
|
@@ -3795,7 +3841,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3795
3841
|
getAdmission: (args) => rpc.call("get_stack_lease_admission", args),
|
|
3796
3842
|
get: (leaseId, { signal } = {}) => rpc.call("get_stack_lease", { lease_id: leaseId }, { signal }),
|
|
3797
3843
|
// BOT-1803: a heartbeat carries the measured client use when there is one.
|
|
3798
|
-
touch: (leaseId, usage) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}) }),
|
|
3844
|
+
touch: (leaseId, usage, { signal } = {}) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}) }, { signal }),
|
|
3799
3845
|
// BOT-1803: `{ idle: true }` idles an ACTIVE lease instead of reaping it.
|
|
3800
3846
|
release: (leaseId, signal, { idle = false } = {}) => rpc.call("release_stack_lease", { lease_id: leaseId, disposition: "destroy", ...(idle ? { idle: true } : {}) }, { signal }),
|
|
3801
3847
|
// BOT-2400: keep a queued lease's place when the park deadline passes.
|
|
@@ -3871,6 +3917,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3871
3917
|
let childResult = null;
|
|
3872
3918
|
let connectionFile = null;
|
|
3873
3919
|
let heartbeat = null;
|
|
3920
|
+
let heartbeatRetry = null;
|
|
3921
|
+
let heartbeatUnanswered = false;
|
|
3874
3922
|
let retention = null;
|
|
3875
3923
|
let hardTimer = null;
|
|
3876
3924
|
let cleanupResult = null;
|
|
@@ -4915,17 +4963,13 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4915
4963
|
if (useNativeCallback) {
|
|
4916
4964
|
try {
|
|
4917
4965
|
nativeCallback = await openCallback({ current: current.data, leaseId, hostKey: placement.hostKey, callbackPort: opts.callbackPort, requesterMachineId: machineUuidFn(), sshTarget,
|
|
4918
|
-
readCurrent:
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
}
|
|
4925
|
-
// BOT-2790 AC-6 (BOT-2828): a backend RPC_ERROR (e.g. a statement timeout) is an unanswered read.
|
|
4926
|
-
if (isTransientStackRpcResult(observed)) throw Object.assign(new Error("current callback status unreadable (RPC_ERROR)"), { transient: true });
|
|
4927
|
-
if (observed.data?.success !== true) throw Object.assign(new Error("current callback authorization unavailable"), { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
|
|
4928
|
-
return observed.data; },
|
|
4966
|
+
readCurrent: (signal) => readCallbackStatus(api, leaseId, signal),
|
|
4967
|
+
onOutage: (outage) => {
|
|
4968
|
+
const previous = incidentBox.callbackOutage;
|
|
4969
|
+
incidentBox.callbackOutage = outage;
|
|
4970
|
+
if (outage.episodes > (previous?.episodes ?? 0)) stderrLine(`stack: the control plane is not answering (${outage.last_failure}); the callback route stays on its last positive answer for up to ${CALLBACK_STALE_PROOF_MAX_MS / 1000} s`);
|
|
4971
|
+
if (outage.recovered > (previous?.recovered ?? 0)) stderrLine("stack: the control plane answered again; the callback route stayed up");
|
|
4972
|
+
},
|
|
4929
4973
|
});
|
|
4930
4974
|
nativeCallback.onExit((exit) => { if (childResult) return; callbackLost = exit; fencing = true; stopChild("SIGTERM", EXIT.LEASE_FAILED); });
|
|
4931
4975
|
placement.callbackTransport = NATIVE_CALLBACK_TRANSPORT;
|
|
@@ -5036,35 +5080,80 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
5036
5080
|
// BOT-1803 (AC-12): a co-located lease's heartbeat carries the measured client use; a
|
|
5037
5081
|
// remote one (or an unreadable measurement) sends no usage at all.
|
|
5038
5082
|
const usageLease = { ...current.data, execution_mode: current.data.execution_mode ?? (servedRemote ? "remote" : "local") };
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
if (fencing || childResult) return;
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
// BOT-
|
|
5050
|
-
//
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5083
|
+
const heartbeatNow = adapters.now ?? Date.now;
|
|
5084
|
+
let heartbeating = false;
|
|
5085
|
+
let touchUnansweredSince = null;
|
|
5086
|
+
const touchLease = async () => {
|
|
5087
|
+
if (fencing || childResult || heartbeating) return;
|
|
5088
|
+
heartbeating = true;
|
|
5089
|
+
try {
|
|
5090
|
+
let usage = null;
|
|
5091
|
+
if (!servedRemote) { try { usage = await measureUsage(usageLease); } catch { usage = null; } }
|
|
5092
|
+
if (fencing || childResult) return;
|
|
5093
|
+
// BOT-2237: a touch whose renewal throws is a failed heartbeat (fence), never an
|
|
5094
|
+
// unhandled rejection from a timer callback.
|
|
5095
|
+
let touched;
|
|
5096
|
+
let unanswered = false;
|
|
5097
|
+
const touchAbort = new AbortController();
|
|
5098
|
+
let touchTimer = null;
|
|
5099
|
+
// The deadline ends the whole operation, not only its fetch: a credential renewal inside the
|
|
5100
|
+
// touch takes no signal, and a touch that never settled would stop every later heartbeat.
|
|
5101
|
+
const touchDeadline = new Promise((resolve) => {
|
|
5102
|
+
touchTimer = clock.setTimeout(() => { touchAbort.abort("touch-deadline"); resolve({ ok: false, status: null, error: "transport: touch deadline" }); }, HEARTBEAT_TOUCH_DEADLINE_MS);
|
|
5103
|
+
touchTimer?.unref?.();
|
|
5104
|
+
});
|
|
5105
|
+
try {
|
|
5106
|
+
const touching = Promise.resolve(api.touch(leaseId, usage ?? undefined, { signal: touchAbort.signal }));
|
|
5107
|
+
touching.catch(() => {}); // a late failure after the deadline has nobody left to hear it
|
|
5108
|
+
touched = await Promise.race([touching, touchDeadline]);
|
|
5109
|
+
unanswered = isTransientStackRpcResult(touched);
|
|
5110
|
+
} catch (error) {
|
|
5111
|
+
touched = { ok: false, error: String(error?.message ?? error) };
|
|
5112
|
+
unanswered = error?.code === "session_mint_failed";
|
|
5113
|
+
} finally {
|
|
5114
|
+
clock.clearTimeout(touchTimer);
|
|
5115
|
+
}
|
|
5116
|
+
if (fencing || childResult) return;
|
|
5117
|
+
if (touched?.ok && isPreemptedAnswer(touched.data)) {
|
|
5118
|
+
// BOT-1803 (AC-11): the control plane reclaimed this lease's capacity. Stop the
|
|
5119
|
+
// child and end typed — never the generic lost-lease wording.
|
|
5120
|
+
preempted = { state: touched.data.state ?? "reaping", reap_reason: touched.data.reap_reason ?? "preempted" };
|
|
5121
|
+
fencing = true;
|
|
5122
|
+
stopChild("SIGTERM", EXIT.LEASE_FAILED);
|
|
5123
|
+
return;
|
|
5124
|
+
}
|
|
5125
|
+
if (!touched?.ok || !touched?.data?.success) {
|
|
5126
|
+
// BOT-2957: an unanswered touch says nothing about the lease, and one of them used to
|
|
5127
|
+
// stop a healthy child during a control-plane stall. Only a real answer fences at once.
|
|
5128
|
+
if (unanswered) {
|
|
5129
|
+
touchUnansweredSince ??= heartbeatNow();
|
|
5130
|
+
if (heartbeatNow() - touchUnansweredSince < CALLBACK_STALE_PROOF_MAX_MS) {
|
|
5131
|
+
clock.clearTimeout(heartbeatRetry);
|
|
5132
|
+
heartbeatRetry = clock.setTimeout(() => touchLease(), HEARTBEAT_RETRY_MS);
|
|
5133
|
+
heartbeatRetry?.unref?.();
|
|
5134
|
+
return;
|
|
5135
|
+
}
|
|
5136
|
+
heartbeatUnanswered = true;
|
|
5137
|
+
}
|
|
5138
|
+
fencing = true;
|
|
5139
|
+
stopChild("SIGTERM", EXIT.LEASE_FAILED);
|
|
5140
|
+
return;
|
|
5141
|
+
}
|
|
5142
|
+
touchUnansweredSince = null;
|
|
5143
|
+
clock.clearTimeout(heartbeatRetry);
|
|
5144
|
+
heartbeatRetry = null;
|
|
5145
|
+
keepAlive();
|
|
5146
|
+
} finally {
|
|
5147
|
+
heartbeating = false;
|
|
5060
5148
|
}
|
|
5061
|
-
|
|
5062
|
-
|
|
5149
|
+
};
|
|
5150
|
+
heartbeat = clock.setInterval(touchLease, cadenceMs);
|
|
5063
5151
|
heartbeat.unref?.();
|
|
5064
5152
|
if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
|
|
5065
5153
|
hardTimer?.unref?.();
|
|
5066
5154
|
childResult = await waitForChild(child);
|
|
5067
5155
|
if (heartbeat) clock.clearInterval(heartbeat);
|
|
5156
|
+
if (heartbeatRetry) clock.clearTimeout(heartbeatRetry);
|
|
5068
5157
|
if (retention) { clock.clearInterval(retention); retention = null; }
|
|
5069
5158
|
if (hardTimer) clock.clearTimeout(hardTimer);
|
|
5070
5159
|
await closeTunnel();
|
|
@@ -5101,7 +5190,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
5101
5190
|
};
|
|
5102
5191
|
}
|
|
5103
5192
|
const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : callbackCleanupError ? EXIT.CLEANUP_FAILED : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : callbackCleanupError || !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
|
|
5104
|
-
return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, ...(callbackCleanupError ? { code: callbackCleanupError } : {}), fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) };
|
|
5193
|
+
return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, ...(callbackCleanupError ? { code: callbackCleanupError } : heartbeatUnanswered ? { code: STACK_HEARTBEAT_UNANSWERED_CODE } : {}), fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) };
|
|
5105
5194
|
} catch (error) {
|
|
5106
5195
|
const cleanup = await requestCleanup();
|
|
5107
5196
|
// BOT-1750 (Codex R10 P2): a thrown auth/self-heal failure (e.g. a refresh that
|
|
@@ -5203,6 +5292,8 @@ export function runReceiptFields(result) {
|
|
|
5203
5292
|
...(result.callbackCause ? { callback_cause: result.callbackCause } : {}),
|
|
5204
5293
|
// BOT-2880: which check refused a CALLBACK_AUTHORIZATION_LOST (a bounded token, never a value).
|
|
5205
5294
|
...(result.callbackAuthorizationReason ? { callback_authorization_reason: result.callbackAuthorizationReason } : {}),
|
|
5295
|
+
// BOT-2957: the control-plane gaps this run's callback route rode through (never on a run that had none).
|
|
5296
|
+
...(result.callbackOutage ? { callback_outage: result.callbackOutage } : {}),
|
|
5206
5297
|
// BOT-2780: the reserved requester origin generated auth links name (never a credential).
|
|
5207
5298
|
...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
|
|
5208
5299
|
// BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy" or "new".
|
|
@@ -5261,6 +5352,44 @@ export async function cmdRun(opts, childArgv, {
|
|
|
5261
5352
|
* receipt deliberately does not carry (which wait a timeout ended and the original
|
|
5262
5353
|
* lease fence). Never emitted.
|
|
5263
5354
|
*/
|
|
5355
|
+
// BOT-2957: what the callback status read could not get, as a bounded token for
|
|
5356
|
+
// callback_authorization_reason (status_unreadable:<token>).
|
|
5357
|
+
function callbackReadFailure(observed) {
|
|
5358
|
+
if (observed == null) return "no_answer";
|
|
5359
|
+
if (typeof observed.status === "number" && observed.status >= 300) return `http_${observed.status}`;
|
|
5360
|
+
if (observed.ok && observed.data?.code === "RPC_ERROR") return "rpc_error";
|
|
5361
|
+
if (observed.error === "invalid_json") return "invalid_json";
|
|
5362
|
+
if (!observed.ok && observed.status == null) return "transport";
|
|
5363
|
+
return "malformed_answer";
|
|
5364
|
+
}
|
|
5365
|
+
const unansweredCallbackRead = (failure) => Object.assign(new Error(`current callback status unreadable (${failure})`), { transient: true, readFailure: failure });
|
|
5366
|
+
const refusedCallbackRead = (observed) => Object.assign(new Error("current callback authorization refused"), { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
|
|
5367
|
+
|
|
5368
|
+
// One callback status read. It throws one of two things. Only an answer is a refusal: a 401/403
|
|
5369
|
+
// that survived the auth refresh, a typed JSON-RPC or tool refusal, or a credential that cannot be
|
|
5370
|
+
// renewed. Everything else is unanswered (transport, 5xx/408/429, a backend RPC_ERROR such as a
|
|
5371
|
+
// statement timeout, an unreadable or untyped body; BOT-2790 AC-6, BOT-2847, BOT-2957).
|
|
5372
|
+
export async function readCallbackStatus(api, leaseId, signal) {
|
|
5373
|
+
let observed;
|
|
5374
|
+
try {
|
|
5375
|
+
observed = await api.get(leaseId, { signal });
|
|
5376
|
+
} catch (error) {
|
|
5377
|
+
const authClass = classifyStackAuthError(error);
|
|
5378
|
+
if (authClass === EXIT.AUTH) throw refusedCallbackRead({ code: error.code });
|
|
5379
|
+
throw unansweredCallbackRead(authClass === EXIT.BACKEND ? "session_mint" : "transport");
|
|
5380
|
+
}
|
|
5381
|
+
if (observed == null || isTransientStackRpcResult(observed)) throw unansweredCallbackRead(callbackReadFailure(observed));
|
|
5382
|
+
if (!observed.ok) {
|
|
5383
|
+
if (observed.auth || observed.code) throw refusedCallbackRead(observed);
|
|
5384
|
+
throw unansweredCallbackRead(callbackReadFailure(observed));
|
|
5385
|
+
}
|
|
5386
|
+
if (observed.data?.success !== true) {
|
|
5387
|
+
if (typeof observed.data?.code === "string" && observed.data.code) throw refusedCallbackRead(observed);
|
|
5388
|
+
throw unansweredCallbackRead(callbackReadFailure(observed));
|
|
5389
|
+
}
|
|
5390
|
+
return observed.data;
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5264
5393
|
// BOT-2880: a definitive RPC refusal of the callback status read names itself by the backend's
|
|
5265
5394
|
// typed code or its HTTP status. The shared bound drops anything that is not a plain token.
|
|
5266
5395
|
function refusedRpcReason(observed) {
|
|
@@ -155,7 +155,7 @@ export const ERROR_CODES = Object.freeze({
|
|
|
155
155
|
"launchctl kickstart -k gui/$(id -u)/{label}"),
|
|
156
156
|
mint_backing_off: entry("human", "repair",
|
|
157
157
|
"Staging sign-in for {persona} keeps failing, so the job is backing off.",
|
|
158
|
-
"The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
|
|
158
|
+
"The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; mint_retries_exhausted = a staging Auth outage, so wait for the next attempt; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
|
|
159
159
|
"grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
|
|
160
160
|
last_tick_failed: entry("human", "repair",
|
|
161
161
|
"The pool job's last tick had sign-in (mint) failures.",
|
|
@@ -285,6 +285,10 @@ export const ERROR_CODES = Object.freeze({
|
|
|
285
285
|
"Staging Auth rejected the persona password.",
|
|
286
286
|
"Seed drift: the staging persona was re-seeded with another password, or the env file is stale.",
|
|
287
287
|
"bb staging-pool mint-one --persona {persona} # after re-running the staging persona seed or updating the env file"),
|
|
288
|
+
password_grant_failed: entry("human", "repair",
|
|
289
|
+
"Staging Auth answered the first sign-in step with an unexpected status, which is not a credential refusal.",
|
|
290
|
+
"A blocked request, a banned or unconfirmed user, or a GoTrue change; {log} has the status and error code.",
|
|
291
|
+
"grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
|
|
288
292
|
no_verified_totp_factor: entry("human", "repair",
|
|
289
293
|
"The persona has no verified TOTP factor on staging.",
|
|
290
294
|
"Seed drift: the staging seed job has not enrolled the factor.",
|
|
@@ -303,7 +307,7 @@ export const ERROR_CODES = Object.freeze({
|
|
|
303
307
|
"grep mint_failed {log} | tail -1 # report this code"),
|
|
304
308
|
mint_retries_exhausted: entry("human", "repair",
|
|
305
309
|
"The sign-in gave up after its retries.",
|
|
306
|
-
"
|
|
310
|
+
"A staging Auth outage (for example a Cloudflare 52x while staging restarts) or network failures; {log} names the last one.",
|
|
307
311
|
"grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
|
|
308
312
|
not_aal2: entry("human", "repair",
|
|
309
313
|
"MFA verify returned a session that is not AAL2.",
|
|
@@ -25,7 +25,9 @@ export class SignInError extends Error {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// 520-524 are Cloudflare's own answers while the origin is down or unreachable (BOT-2947):
|
|
29
|
+
// Auth never saw the request or never answered it, so they say nothing about the credentials.
|
|
30
|
+
const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524]);
|
|
29
31
|
const REVOKED_ERROR_CODES = new Set(["session_not_found", "bad_jwt", "refresh_token_not_found"]);
|
|
30
32
|
|
|
31
33
|
function describeFailure(response) {
|
|
@@ -75,6 +77,7 @@ export function createGoTrueTotpSignIn({
|
|
|
75
77
|
const isTransient = (response) => response.status === 0 || RETRYABLE_STATUSES.has(response.status);
|
|
76
78
|
const isRevoked = (response) =>
|
|
77
79
|
(response.status === 401 || response.status === 403) && REVOKED_ERROR_CODES.has(response.body?.error_code);
|
|
80
|
+
const isCredentialRefusal = (response) => response.status === 400 && response.body?.error_code === "invalid_credentials";
|
|
78
81
|
|
|
79
82
|
function assertAal2ForPersona(storedSession, persona) {
|
|
80
83
|
const claims = decodeJwtClaims(storedSession.access_token);
|
|
@@ -108,7 +111,12 @@ export function createGoTrueTotpSignIn({
|
|
|
108
111
|
await retry(`password grant ${describeFailure(password)}`);
|
|
109
112
|
continue;
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
if (isCredentialRefusal(password)) {
|
|
115
|
+
throw new SignInError("password_rejected", `Password grant refused: ${describeFailure(password)}.`, {
|
|
116
|
+
status: password.status,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
throw new SignInError("password_grant_failed", `Password grant failed: ${describeFailure(password)}.`, {
|
|
112
120
|
status: password.status,
|
|
113
121
|
});
|
|
114
122
|
}
|