@botbuddy/cli 1.63.42 → 1.63.43

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.42",
3
- "source_commit": "68cd7a93519197cc7b96a190fa0c4b4799a9c45d",
4
- "source_sha256": "3a4e94f577771c564aa24d54529368c6b6d325be1e64ef0a30da1531f02be6ed",
2
+ "release_version": "1.63.43",
3
+ "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
4
+ "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
5
5
  "target": "darwin-arm64",
6
- "sha256": "3c5f90061df675f1e06408ce5e1f3c9b6350e5a237216b0dbc11d71d656a1b35"
6
+ "sha256": "ec91fd06a1a4da1a271c5343619f66f18ea16c2d676247fbd22e5bfaa9a9a976"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.42",
3
- "source_commit": "68cd7a93519197cc7b96a190fa0c4b4799a9c45d",
4
- "source_sha256": "3a4e94f577771c564aa24d54529368c6b6d325be1e64ef0a30da1531f02be6ed",
2
+ "release_version": "1.63.43",
3
+ "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
4
+ "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
5
5
  "target": "linux-x64",
6
- "sha256": "a5a474195eca0a8b57ddf8c5d50cfddb88f083a57bc99e80dc4f1a7d10e99535"
6
+ "sha256": "8437df29241846a8dddfad4ce6c5a77237c0dc38bf8006512e4c22fe911fb380"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.42",
4
- "source_commit": "68cd7a93519197cc7b96a190fa0c4b4799a9c45d",
5
- "source_sha256": "3a4e94f577771c564aa24d54529368c6b6d325be1e64ef0a30da1531f02be6ed",
3
+ "release_version": "1.63.43",
4
+ "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
5
+ "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
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": "3c5f90061df675f1e06408ce5e1f3c9b6350e5a237216b0dbc11d71d656a1b35",
11
+ "sha256": "ec91fd06a1a4da1a271c5343619f66f18ea16c2d676247fbd22e5bfaa9a9a976",
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": "a5a474195eca0a8b57ddf8c5d50cfddb88f083a57bc99e80dc4f1a7d10e99535",
18
+ "sha256": "8437df29241846a8dddfad4ce6c5a77237c0dc38bf8006512e4c22fe911fb380",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.63.42",
3
+ "version": "1.63.43",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,19 @@ const fail=(code,message)=>{throw Object.assign(Error(message),{code});};
21
21
  const quote=value=>"'"+String(value).replaceAll("'","'\\''")+"'";
22
22
  // BOT-2828: a relay-reported cause is one fixed upper-case code, never free text.
23
23
  const typedCause=value=>typeof value==='string'&&/^CALLBACK_[A-Z_]{1,64}$/.test(value)?value:null;
24
+ // BOT-2880: which check refused. A bounded token naming the check or changed field, never its value.
25
+ const AUTHORIZATION_REASON=/^[a-z0-9_.:-]{1,64}$/;
26
+ export const boundedAuthorizationReason=value=>typeof value==='string'&&AUTHORIZATION_REASON.test(value)?value:null;
27
+ const refuse=(reason,message)=>{throw Object.assign(Error(message),{code:'CALLBACK_AUTHORIZATION_LOST',authorizationReason:reason});};
28
+ const tokenReason=(name,value)=>boundedAuthorizationReason(name+':'+value)??name;
29
+ function changedBindingField(previous,next,path=''){
30
+ for(const key of new Set([...Object.keys(previous),...Object.keys(next)])){
31
+ const a=previous[key],b=next[key],field=path+key;
32
+ if(a&&b&&typeof a==='object'&&typeof b==='object'){const nested=changedBindingField(a,b,field+'.');if(nested)return nested;}
33
+ else if(a!==b)return field;
34
+ }
35
+ return null;
36
+ }
24
37
  const canonicalMachine=value=>typeof value==='string'&&/^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(value)?value.replaceAll('-','').toLowerCase():null;
25
38
 
26
39
  export function validatedCallbackDescriptor(value){
@@ -31,10 +44,21 @@ export function validatedCallbackDescriptor(value){
31
44
  }
32
45
 
33
46
  export function currentCallbackBinding(current,{leaseId,hostKey,callbackPort,requesterMachineId,routeGeneration}){
34
- if(current?.callback_transport!==NATIVE_CALLBACK_TRANSPORT)fail('HOST_CALLBACK_TRANSPORT_UNSUPPORTED','backend did not acknowledge required callback transport');
35
- if(current.callback_current_authorization!==true||current.state!=='active'||current.execution_mode!=='remote')fail('CALLBACK_AUTHORIZATION_LOST','current callback authorization unavailable');
36
- const context=current.callback_context,d=validatedCallbackDescriptor(context?.descriptor);
37
- if(current.lease_id!==leaseId||current.host_key!==hostKey||d.lease_id!==leaseId||d.host_key!==hostKey||d.callback_port!==callbackPort||current.callback_port!==callbackPort||d.project_id!==current.connection?.project_id||!context||context.tenant_id!==d.tenant_id||!text(context.owner)||!uuid.test(context.requester_machine_id)||canonicalMachine(requesterMachineId)!==canonicalMachine(context.requester_machine_id)||!uuid.test(routeGeneration))fail('CALLBACK_AUTHORIZATION_LOST','current callback context changed');
47
+ if(current?.callback_transport!==NATIVE_CALLBACK_TRANSPORT)throw Object.assign(Error('backend did not acknowledge required callback transport'),{code:'HOST_CALLBACK_TRANSPORT_UNSUPPORTED',authorizationReason:'binding_callback_transport'});
48
+ if(current.state!=='active')refuse(tokenReason('lease_state',current.state),'current callback authorization unavailable');
49
+ if(current.execution_mode!=='remote')refuse(tokenReason('lease_execution_mode',current.execution_mode),'current callback authorization unavailable');
50
+ if(current.callback_current_authorization!==true)refuse(boundedAuthorizationReason(current.callback_authorization_reason)??'server_not_authorized','current callback authorization unavailable');
51
+ const context=current.callback_context;let d;
52
+ try{d=validatedCallbackDescriptor(context?.descriptor)}catch(error){throw Object.assign(error,{authorizationReason:'descriptor_refused'});}
53
+ for(const [changed,reason] of [
54
+ [current.lease_id!==leaseId,'binding_lease_id'],[current.host_key!==hostKey,'binding_host_key'],
55
+ [d.lease_id!==leaseId,'binding_descriptor_lease_id'],[d.host_key!==hostKey,'binding_descriptor_host_key'],
56
+ [d.callback_port!==callbackPort,'binding_descriptor_callback_port'],[current.callback_port!==callbackPort,'binding_callback_port'],
57
+ [d.project_id!==current.connection?.project_id,'binding_project_id'],[context.tenant_id!==d.tenant_id,'binding_tenant_id'],
58
+ [!text(context.owner),'binding_owner'],
59
+ [!uuid.test(context.requester_machine_id)||canonicalMachine(requesterMachineId)!==canonicalMachine(context.requester_machine_id),'binding_requester_machine_id'],
60
+ [!uuid.test(routeGeneration),'binding_route_generation'],
61
+ ])if(changed)refuse(reason,'current callback context changed');
38
62
  return Object.freeze({owner:context.owner,tenant_id:context.tenant_id,requester_machine_id:context.requester_machine_id,route_generation:routeGeneration,origin:'requester_http',descriptor:d});
39
63
  }
40
64
 
@@ -107,29 +131,35 @@ export async function openNativeCallbackTransport({current,leaseId,hostKey,callb
107
131
  });
108
132
  const callbackUrl=callbackReadiness(ready,callbackPort);
109
133
  gateway.onClose(()=>{if(!closed){reportExit({reason:'callback_gateway_closed',code:'CALLBACK_GATEWAY_EXPIRED'});void close().catch(()=>{})}});
110
- let renewedAt=null;
134
+ let renewedAt=null,refusalPending=false;
111
135
  const refresh=async(first=false)=>{
112
136
  if(closed||refreshing)return;refreshing=true;
113
137
  try{
114
- statusAbort=new AbortController();let statusTimeout,status;
115
- try{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));});}
116
- catch(error){
138
+ statusAbort=new AbortController();let statusTimeout,renewed;
139
+ 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);
142
+ if(JSON.stringify(renewed)!==snapshot)refuse(tokenReason('binding_changed',changedBindingField(binding,renewed)),'callback context changed');
143
+ refusalPending=false;
144
+ }catch(error){
117
145
  // A failed status READ is not an authorization answer: keep the live generation (relay and
118
146
  // gateway still expire it at 30 s) and retry on the next tick while inside the grace window.
119
147
  if(!first&&error?.transient===true&&renewedAt!==null&&performance.now()-renewedAt<transientGraceMs){const retry=setTimeout(()=>{void refresh()},transientRetryMs);retry.unref();return;}
120
148
  if(error?.transient===true&&!error.code&&!first)error.code='CALLBACK_STATUS_UNREADABLE';
149
+ // BOT-2880: a definitive answer stops the child only if one re-read repeats it, while the live
150
+ // generation can still carry that re-read. The opening renewal and an expired grace fail closed.
151
+ if(!first&&error?.transient!==true&&!refusalPending&&renewedAt!==null&&performance.now()-renewedAt<transientGraceMs){refusalPending=true;const retry=setTimeout(()=>{void refresh()},transientRetryMs);retry.unref();return;}
121
152
  throw error;
122
153
  }
123
- if(closed)return;const renewed=currentCallbackBinding(status,options);if(JSON.stringify(renewed)!==snapshot)fail('CALLBACK_AUTHORIZATION_LOST','callback context changed');
124
154
  const sentAt=performance.now();
125
155
  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');});
126
156
  if(closed)return;renewedAt=sentAt;gateway.refresh(Object.freeze({snapshot}),30_000-(performance.now()-sentAt));
127
157
  }
128
158
  // The first renewal carries the consuming-namespace probe: its uncoded failure is readiness.
129
- catch(error){if(!closed){reportExit({reason:error?.transport===true?'callback_renewal_unacknowledged':'callback_authorization_lost',code:error?.code??(first?'CALLBACK_NOT_READY':'CALLBACK_AUTHORIZATION_LOST')});void close().catch(()=>{});}}
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(()=>{});}}
130
160
  finally{refreshing=false}
131
161
  };
132
- await refresh(true);if(closed||!gateway.isLive())fail(exitResult?.code??'CALLBACK_NOT_READY','callback route not ready after readiness');
162
+ 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}:{})});
133
163
  refreshTimer=setInterval(refresh,renewIntervalMs);refreshTimer.unref();
134
164
  return{callbackUrl,close,isLive:()=>!closed&&gateway.isLive(),onExit(listener){exits.add(listener);if(exitResult)listener(exitResult)}};
135
165
  }catch(error){try{await close()}catch{fail('CALLBACK_CLEANUP_UNCERTAIN','owned callback cleanup could not be verified');}throw error;}
@@ -31,6 +31,7 @@ const LANE_RE = /^[A-Za-z0-9:_.-]{1,128}$/;
31
31
  const TICKET_RE = /^[A-Za-z][A-Za-z0-9]*-\d{1,9}$/;
32
32
  const REPO_RE = /^[A-Za-z0-9._/-]{1,128}$/;
33
33
  const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
34
+ const AUTHORIZATION_REASON_RE = /^[a-z0-9_.:-]{1,64}$/;
34
35
 
35
36
  // Mirrors stack.mjs EXIT (kept local so this module stays dependency-free for tests).
36
37
  const EXIT_TIMEOUT = 2;
@@ -109,6 +110,8 @@ const exitInt = (value) => (Number.isInteger(value) && value >= 0 && value <= 25
109
110
  */
110
111
  export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = randomUUID(), opts = {}, env = process.env, durationMs = null }) {
111
112
  const { category, code } = classifyStackRunOutcome(fields, exitCode);
113
+ // BOT-2880: sent only when present, because an older server refuses an unknown key.
114
+ const callbackAuthorizationReason = pick(fields.callback_authorization_reason, AUTHORIZATION_REASON_RE);
112
115
  return {
113
116
  run_id: runId,
114
117
  lease_id: validStackLeaseId(fields.lease_id),
@@ -126,6 +129,7 @@ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = ran
126
129
  repo: pick(opts.repo, REPO_RE),
127
130
  duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? Math.round(durationMs) : null,
128
131
  cli_version: pick(VERSION, VERSION_RE) ?? "unknown",
132
+ ...(callbackAuthorizationReason ? { callback_authorization_reason: callbackAuthorizationReason } : {}),
129
133
  };
130
134
  }
131
135
 
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, openNativeCallbackTransport } from "./stack-native-callback.mjs";
56
+ import { 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";
@@ -374,7 +374,11 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
374
374
  CALLBACK_VALIDATION_UNAVAILABLE (Docker unanswered 20 s), CALLBACK_PRODUCER_ENDED,
375
375
  CALLBACK_GENERATION_EXPIRED, CALLBACK_RENEWAL_REFUSED; requester CALLBACK_SSH_LOST,
376
376
  CALLBACK_RELAY_EXITED, CALLBACK_GATEWAY_EXPIRED, CALLBACK_RENEWAL_ACK_TIMEOUT,
377
- CALLBACK_STATUS_UNREADABLE, CALLBACK_PROTOCOL_ERROR, CALLBACK_AUTHORIZATION_LOST
377
+ CALLBACK_STATUS_UNREADABLE, CALLBACK_PROTOCOL_ERROR, CALLBACK_AUTHORIZATION_LOST.
378
+ A refusal must repeat on one re-read before it stops the child (BOT-2880), and
379
+ callback_authorization_reason names the refused check, e.g. requester_host_disabled
380
+ (the requester machine's own Docker host was set to disabled: use maintenance),
381
+ lease_state:reaping, binding_changed:descriptor.producer_pid
378
382
  5 CALLBACK_PORT_BUSY --callback-port is already listening on the serving host (another run or
379
383
  service), the host refused this run's -R because another run took the
380
384
  port first, or another listener shares the port; this run holds no -R —
@@ -4903,18 +4907,19 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4903
4907
  // that survived the auth refresh or a typed JSON-RPC refusal is a definitive answer.
4904
4908
  // BOT-2847: the shared classification (stack-release-retry.mjs) the cleanup release retries on.
4905
4909
  const transient = observed == null || isTransientStackRpcResult(observed);
4906
- throw Object.assign(new Error(transient ? "current callback status unreadable" : "current callback authorization refused"), transient ? { transient: true } : { code: "CALLBACK_AUTHORIZATION_LOST" });
4910
+ throw Object.assign(new Error(transient ? "current callback status unreadable" : "current callback authorization refused"), transient ? { transient: true } : { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
4907
4911
  }
4908
4912
  // BOT-2790 AC-6 (BOT-2828): a backend RPC_ERROR (e.g. a statement timeout) is an unanswered read.
4909
4913
  if (isTransientStackRpcResult(observed)) throw Object.assign(new Error("current callback status unreadable (RPC_ERROR)"), { transient: true });
4910
- if (observed.data?.success !== true) throw Object.assign(new Error("current callback authorization unavailable"), { code: "CALLBACK_AUTHORIZATION_LOST" });
4914
+ if (observed.data?.success !== true) throw Object.assign(new Error("current callback authorization unavailable"), { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
4911
4915
  return observed.data; },
4912
4916
  });
4913
4917
  nativeCallback.onExit((exit) => { if (childResult) return; callbackLost = exit; fencing = true; stopChild("SIGTERM", EXIT.LEASE_FAILED); });
4914
4918
  placement.callbackTransport = NATIVE_CALLBACK_TRANSPORT;
4915
4919
  } catch (error) {
4916
4920
  await closeTunnel(); const cleanup = await requestCleanup();
4917
- return { exitCode: cleanup.ok && !callbackCleanupError && error?.code !== "CALLBACK_CLEANUP_UNCERTAIN" ? EXIT.BACKEND : EXIT.CLEANUP_FAILED, outcome: "error", code: error?.code ?? "CALLBACK_NOT_READY", leaseId, error: String(error?.message ?? "callback transport unavailable"), cleanup };
4921
+ const openReason = boundedAuthorizationReason(error?.authorizationReason);
4922
+ return { exitCode: cleanup.ok && !callbackCleanupError && error?.code !== "CALLBACK_CLEANUP_UNCERTAIN" ? EXIT.BACKEND : EXIT.CLEANUP_FAILED, outcome: "error", code: error?.code ?? "CALLBACK_NOT_READY", leaseId, error: String(error?.message ?? "callback transport unavailable"), cleanup, ...(openReason ? { callbackAuthorizationReason: openReason } : {}) };
4918
4923
  }
4919
4924
  }
4920
4925
  placement.transport = "ssh-forward";
@@ -4999,7 +5004,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4999
5004
  if (callbackLost || nativeCallback && !nativeCallback.isLive()) {
5000
5005
  await closeTunnel(); const cleanup = await requestCleanup();
5001
5006
  const callbackCause = callbackLost?.code ?? "CALLBACK_RELAY_EXITED";
5002
- return { exitCode: cleanup.ok && !callbackCleanupError ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "error", code: "CALLBACK_TRANSPORT_LOST", callbackCause, leaseId, error: `callback transport closed before child start (${callbackCause})`, cleanup };
5007
+ const lostReason = boundedAuthorizationReason(callbackLost?.authorization_reason);
5008
+ return { exitCode: cleanup.ok && !callbackCleanupError ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "error", code: "CALLBACK_TRANSPORT_LOST", callbackCause, ...(lostReason ? { callbackAuthorizationReason: lostReason } : {}), leaseId, error: `callback transport closed before child start (${callbackCause})`, cleanup };
5003
5009
  }
5004
5010
  if (tunnelLost) {
5005
5011
  // The forwards died between being verified and the child starting.
@@ -5065,7 +5071,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
5065
5071
  if (callbackLost && !receivedSignal) {
5066
5072
  // BOT-2828: the receipt names the hop and cause that closed the route, not only the class.
5067
5073
  const callbackCause = callbackLost.code ?? (callbackLost.reason === "callback_authorization_lost" ? "CALLBACK_AUTHORIZATION_LOST" : "CALLBACK_RELAY_EXITED");
5068
- return { exitCode: cleanup.ok && !callbackCleanupError ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "failed", code: callbackLost.reason === "callback_authorization_lost" ? "CALLBACK_AUTHORIZATION_LOST" : "CALLBACK_TRANSPORT_LOST", callbackCause, leaseId, childExitCode: childResult.code, cleanup, fenced: true, error: `callback transport authorization or runtime context was lost (${callbackCause}); child stopped` };
5074
+ const authorizationLost = callbackLost.reason === "callback_authorization_lost";
5075
+ // BOT-2880: an authorization loss always names the refused check, even as "unclassified".
5076
+ const callbackAuthorizationReason = authorizationLost ? boundedAuthorizationReason(callbackLost.authorization_reason) ?? "unclassified" : null;
5077
+ return { exitCode: cleanup.ok && !callbackCleanupError ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "failed", code: authorizationLost ? "CALLBACK_AUTHORIZATION_LOST" : "CALLBACK_TRANSPORT_LOST", callbackCause, ...(callbackAuthorizationReason ? { callbackAuthorizationReason } : {}), leaseId, childExitCode: childResult.code, cleanup, fenced: true, error: `callback transport authorization or runtime context was lost (${callbackCause}${callbackAuthorizationReason ? `: ${callbackAuthorizationReason}` : ""}); child stopped` };
5069
5078
  }
5070
5079
  if (tunnelLost && !receivedSignal) {
5071
5080
  return tunnelLostResult(tunnelLost, { leaseId, cleanup, childExitCode: childResult.code, fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) });
@@ -5177,6 +5186,8 @@ export function runReceiptFields(result) {
5177
5186
  ...(result.execution?.callbackTransport ? { callback_transport: result.execution.callbackTransport } : {}),
5178
5187
  // BOT-2828: the typed cause of a native callback route close (e.g. CALLBACK_RUNTIME_DRIFT).
5179
5188
  ...(result.callbackCause ? { callback_cause: result.callbackCause } : {}),
5189
+ // BOT-2880: which check refused a CALLBACK_AUTHORIZATION_LOST (a bounded token, never a value).
5190
+ ...(result.callbackAuthorizationReason ? { callback_authorization_reason: result.callbackAuthorizationReason } : {}),
5180
5191
  // BOT-2780: the reserved requester origin generated auth links name (never a credential).
5181
5192
  ...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
5182
5193
  // BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy" or "new".
@@ -5235,6 +5246,14 @@ export async function cmdRun(opts, childArgv, {
5235
5246
  * receipt deliberately does not carry (which wait a timeout ended and the original
5236
5247
  * lease fence). Never emitted.
5237
5248
  */
5249
+ // BOT-2880: a definitive RPC refusal of the callback status read names itself by the backend's
5250
+ // typed code or its HTTP status. The shared bound drops anything that is not a plain token.
5251
+ function refusedRpcReason(observed) {
5252
+ const code = observed?.data?.code ?? observed?.code;
5253
+ const named = typeof code === "string" && code ? code.toLowerCase() : Number.isInteger(observed?.status) ? `http_${observed.status}` : null;
5254
+ return (named && boundedAuthorizationReason(`refused:${named}`)) ?? "refused";
5255
+ }
5256
+
5238
5257
  export function stackRunOutcomeFields(result) {
5239
5258
  return {
5240
5259
  ...runReceiptFields(result), lease_fenced: result?.fenced === true,