@botbuddy/cli 1.63.41 → 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.41",
3
- "source_commit": "89653c3c1d5a7144abd8bdab27a9496900cdb1d0",
4
- "source_sha256": "d524b98618977c153c57ae009f9cbab3da72f0255fb9a0b01e840a7c78550dc3",
2
+ "release_version": "1.63.43",
3
+ "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
4
+ "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
5
5
  "target": "darwin-arm64",
6
- "sha256": "ffee8ba3b0a0b849075f06f5186ca2aa8f14fca124c1460a396f410b95a655d7"
6
+ "sha256": "ec91fd06a1a4da1a271c5343619f66f18ea16c2d676247fbd22e5bfaa9a9a976"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.41",
3
- "source_commit": "89653c3c1d5a7144abd8bdab27a9496900cdb1d0",
4
- "source_sha256": "d524b98618977c153c57ae009f9cbab3da72f0255fb9a0b01e840a7c78550dc3",
2
+ "release_version": "1.63.43",
3
+ "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
4
+ "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
5
5
  "target": "linux-x64",
6
- "sha256": "594d96944fbea3354a6d26186d92e989470ee75734fd7fd53f5eb681eab1f43d"
6
+ "sha256": "8437df29241846a8dddfad4ce6c5a77237c0dc38bf8006512e4c22fe911fb380"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.41",
4
- "source_commit": "89653c3c1d5a7144abd8bdab27a9496900cdb1d0",
5
- "source_sha256": "d524b98618977c153c57ae009f9cbab3da72f0255fb9a0b01e840a7c78550dc3",
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": "ffee8ba3b0a0b849075f06f5186ca2aa8f14fca124c1460a396f410b95a655d7",
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": "594d96944fbea3354a6d26186d92e989470ee75734fd7fd53f5eb681eab1f43d",
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.41",
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 —
@@ -1356,7 +1360,7 @@ export function leaseRefusalExit(code) {
1356
1360
  // server refusing the credential — never BACKEND, which a gate reads as "down".
1357
1361
  if (CALL_AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
1358
1362
  // BOT-1926: a reused lease requested in the other execution mode is fixable by the caller.
1359
- return code === STACK_PATH_ROOT_REFUSED_CODE || code === "STACK_EXECUTION_MODE_MISMATCH" ? EXIT.INVALID : EXIT.BACKEND;
1363
+ return code === STACK_PATH_ROOT_REFUSED_CODE || code === "STACK_EXECUTION_MODE_MISMATCH" || code === "AGENT_DEEPLINK_REQUIRED" ? EXIT.INVALID : EXIT.BACKEND;
1360
1364
  }
1361
1365
 
1362
1366
  /**
@@ -1386,6 +1390,8 @@ export const REMOTE_REFUSAL_GUIDANCE = Object.freeze({
1386
1390
  "re-run (the bundle is built automatically unless it failed — see the warning above) or use --local-only.",
1387
1391
  });
1388
1392
 
1393
+ const AGENT_DEEPLINK_REQUIRED_GUIDANCE = "the control plane refused this stack because its owning agent has no canonical profile or native session link. Reconnect with `bb register`, then retry. No lease was created.";
1394
+
1389
1395
  /** The refusal text for a receipt: the server's message, else the CLI's guidance, else the code. */
1390
1396
  export function leaseRefusalError(data) {
1391
1397
  if (data?.lease_id && ["STACK_CONTEXT_BIND_FAILED", "STACK_CONTEXT_ALREADY_BOUND"].includes(data?.code)) {
@@ -1398,7 +1404,7 @@ export function leaseRefusalError(data) {
1398
1404
  return `the server's identified admission failed in the database${data.sqlstate ? ` (SQLSTATE ${data.sqlstate})` : ""} and rolled back, ` +
1399
1405
  "so no lease was created and this stack's port claim is released. This is a BotBuddy server fault: report it with this run's admission request id.";
1400
1406
  }
1401
- return data?.message || REMOTE_REFUSAL_GUIDANCE[data?.code] || data?.code || "lease request refused";
1407
+ return data?.message || (data?.code === "AGENT_DEEPLINK_REQUIRED" ? AGENT_DEEPLINK_REQUIRED_GUIDANCE : null) || REMOTE_REFUSAL_GUIDANCE[data?.code] || data?.code || "lease request refused";
1402
1408
  }
1403
1409
 
1404
1410
  /** One stderr line naming a remote-lease refusal (null for every other code). */
@@ -4901,18 +4907,19 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4901
4907
  // that survived the auth refresh or a typed JSON-RPC refusal is a definitive answer.
4902
4908
  // BOT-2847: the shared classification (stack-release-retry.mjs) the cleanup release retries on.
4903
4909
  const transient = observed == null || isTransientStackRpcResult(observed);
4904
- 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) });
4905
4911
  }
4906
4912
  // BOT-2790 AC-6 (BOT-2828): a backend RPC_ERROR (e.g. a statement timeout) is an unanswered read.
4907
4913
  if (isTransientStackRpcResult(observed)) throw Object.assign(new Error("current callback status unreadable (RPC_ERROR)"), { transient: true });
4908
- 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) });
4909
4915
  return observed.data; },
4910
4916
  });
4911
4917
  nativeCallback.onExit((exit) => { if (childResult) return; callbackLost = exit; fencing = true; stopChild("SIGTERM", EXIT.LEASE_FAILED); });
4912
4918
  placement.callbackTransport = NATIVE_CALLBACK_TRANSPORT;
4913
4919
  } catch (error) {
4914
4920
  await closeTunnel(); const cleanup = await requestCleanup();
4915
- 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 } : {}) };
4916
4923
  }
4917
4924
  }
4918
4925
  placement.transport = "ssh-forward";
@@ -4997,7 +5004,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4997
5004
  if (callbackLost || nativeCallback && !nativeCallback.isLive()) {
4998
5005
  await closeTunnel(); const cleanup = await requestCleanup();
4999
5006
  const callbackCause = callbackLost?.code ?? "CALLBACK_RELAY_EXITED";
5000
- 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 };
5001
5009
  }
5002
5010
  if (tunnelLost) {
5003
5011
  // The forwards died between being verified and the child starting.
@@ -5063,7 +5071,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
5063
5071
  if (callbackLost && !receivedSignal) {
5064
5072
  // BOT-2828: the receipt names the hop and cause that closed the route, not only the class.
5065
5073
  const callbackCause = callbackLost.code ?? (callbackLost.reason === "callback_authorization_lost" ? "CALLBACK_AUTHORIZATION_LOST" : "CALLBACK_RELAY_EXITED");
5066
- 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` };
5067
5078
  }
5068
5079
  if (tunnelLost && !receivedSignal) {
5069
5080
  return tunnelLostResult(tunnelLost, { leaseId, cleanup, childExitCode: childResult.code, fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) });
@@ -5175,6 +5186,8 @@ export function runReceiptFields(result) {
5175
5186
  ...(result.execution?.callbackTransport ? { callback_transport: result.execution.callbackTransport } : {}),
5176
5187
  // BOT-2828: the typed cause of a native callback route close (e.g. CALLBACK_RUNTIME_DRIFT).
5177
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 } : {}),
5178
5191
  // BOT-2780: the reserved requester origin generated auth links name (never a credential).
5179
5192
  ...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
5180
5193
  // BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy" or "new".
@@ -5233,6 +5246,14 @@ export async function cmdRun(opts, childArgv, {
5233
5246
  * receipt deliberately does not carry (which wait a timeout ended and the original
5234
5247
  * lease fence). Never emitted.
5235
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
+
5236
5257
  export function stackRunOutcomeFields(result) {
5237
5258
  return {
5238
5259
  ...runReceiptFields(result), lease_fenced: result?.fenced === true,