@botbuddy/cli 1.63.42 → 1.63.44

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.44",
3
+ "source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
4
+ "source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
5
5
  "target": "darwin-arm64",
6
- "sha256": "3c5f90061df675f1e06408ce5e1f3c9b6350e5a237216b0dbc11d71d656a1b35"
6
+ "sha256": "1740585f4d9e5ce03d798353c1c473b6b818191f83d3a6e63b8e94f6e5d975bb"
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.44",
3
+ "source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
4
+ "source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
5
5
  "target": "linux-x64",
6
- "sha256": "a5a474195eca0a8b57ddf8c5d50cfddb88f083a57bc99e80dc4f1a7d10e99535"
6
+ "sha256": "93c1837e1aad2e8cb048399e57d2d4504db08d5edda38a92369558ceb4c4e8c4"
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.44",
4
+ "source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
5
+ "source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
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": "1740585f4d9e5ce03d798353c1c473b6b818191f83d3a6e63b8e94f6e5d975bb",
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": "93c1837e1aad2e8cb048399e57d2d4504db08d5edda38a92369558ceb4c4e8c4",
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.44",
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;}
@@ -13,7 +13,10 @@
13
13
  // path, hostname, env value, argv or command output can reach the server. The
14
14
  // server re-validates the same allowlist (supabase/functions/_shared/lib/stackRunOutcome.ts).
15
15
 
16
+ import { spawnSync } from "node:child_process";
16
17
  import { randomUUID } from "node:crypto";
18
+ import { lstatSync } from "node:fs";
19
+ import { basename, isAbsolute } from "node:path";
17
20
  import { VERSION } from "./version.mjs";
18
21
  import { validStackLeaseId } from "./stack-lease-correlation.mjs";
19
22
 
@@ -23,7 +26,15 @@ export const STACK_RUN_OUTCOME_TOOL = "record_stack_run_outcome";
23
26
  export const STACK_RUN_OUTCOME_TIMEOUT_MS = 2000;
24
27
  /** `=off` disables the record (the CLI test suites set it; nothing else should). */
25
28
  export const STACK_RUN_OUTCOME_OPT_OUT_ENV = "BOTBUDDY_STACK_RUN_OUTCOME";
26
- export const STACK_RUN_OUTCOME_CATEGORIES = Object.freeze(["platform", "script", "interrupted", "invocation"]);
29
+ export const STACK_RUN_OUTCOME_CATEGORIES = Object.freeze(["platform", "script", "interrupted", "invocation", "released"]);
30
+ /**
31
+ * BOT-2881: a signal carries no sender, so a hold's owner says so first. The hold sets this to an
32
+ * absolute path when it starts `bb stack run`, and its stop command creates that file before
33
+ * sending SIGTERM. Without it, every signal stays `interrupted`.
34
+ */
35
+ export const STACK_RELEASE_INTENT_ENV = "BOTBUDDY_STACK_RELEASE_INTENT_FILE";
36
+ export const STACK_SIGNAL_SOURCE_OWNER_RELEASE = "owner_release";
37
+ export const STACK_RELEASE_INTENT_MTIME_SLACK_MS = 2000;
27
38
 
28
39
  const CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
29
40
  const TOKEN_RE = /^[a-z0-9_-]{1,64}$/;
@@ -31,6 +42,8 @@ const LANE_RE = /^[A-Za-z0-9:_.-]{1,128}$/;
31
42
  const TICKET_RE = /^[A-Za-z][A-Za-z0-9]*-\d{1,9}$/;
32
43
  const REPO_RE = /^[A-Za-z0-9._/-]{1,128}$/;
33
44
  const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
45
+ const SIGNAL_RE = /^SIG[A-Z]{1,12}$/;
46
+ const AUTHORIZATION_REASON_RE = /^[a-z0-9_.:-]{1,64}$/;
34
47
 
35
48
  // Mirrors stack.mjs EXIT (kept local so this module stays dependency-free for tests).
36
49
  const EXIT_TIMEOUT = 2;
@@ -56,12 +69,16 @@ function boundedCode(value) {
56
69
  * (`--hard-ttl` stop → code HARD_TTL)
57
70
  * platform — the stack, its transport, the credential or the backend failed
58
71
  * interrupted — a signal ended the run (operator Ctrl-C / parent kill)
72
+ * released — the owner of a hold stopped it on purpose (BOT-2881), and its lease and cleanup held
59
73
  * invocation — exit 4: bad arguments or a refused request before any lease
60
74
  */
61
75
  export function classifyStackRunOutcome(fields = {}, exitCode) {
62
76
  const code = boundedCode(fields.code);
63
77
  const child = Number.isInteger(fields.child_exit_code) ? fields.child_exit_code : null;
64
78
  const outcome = typeof fields.outcome === "string" ? fields.outcome : null;
79
+ // BOT-2881: the owner's stop never hides an unproven teardown (exit 143 would otherwise read as a signal).
80
+ if (fields.stop_signal?.source === STACK_SIGNAL_SOURCE_OWNER_RELEASE && fields.cleanup === false) return { category: "platform", code: "CLEANUP_FAILED" };
81
+ if (outcome === "released") return { category: "released", code };
65
82
  if (outcome === "interrupted") return { category: "interrupted", code };
66
83
  if (code && PLATFORM_CODES.has(code)) return { category: "platform", code };
67
84
  // The stdout fence also covers unresolved cleanup. The lifecycle supplies its
@@ -91,6 +108,61 @@ export function classifyStackRunOutcome(fields = {}, exitCode) {
91
108
  }
92
109
  }
93
110
 
111
+ /**
112
+ * BOT-2881: whether a lifecycle result is an owner's release that held: the signal decided the
113
+ * exit, not fenced, not a typed platform loss, and cleanup not refused. Anything else keeps its
114
+ * own outcome and category.
115
+ */
116
+ export function ownerReleaseHeld(result = {}) {
117
+ return result.stopSignal?.source === STACK_SIGNAL_SOURCE_OWNER_RELEASE && SIGNAL_EXITS.has(result.exitCode)
118
+ // Any typed code (a platform loss, CALLBACK_CLEANUP_UNCERTAIN, …) keeps the run's own outcome.
119
+ && result.fenced !== true && result.cleanup?.ok !== false && result.code == null;
120
+ }
121
+
122
+ /** BOT-2881: the declared intent exists, is a regular file and was written after this run began. */
123
+ export function stackReleaseIntentDeclared({ env = process.env, startedAtMs } = {}) {
124
+ const file = env?.[STACK_RELEASE_INTENT_ENV];
125
+ if (typeof file !== "string" || !isAbsolute(file) || !Number.isFinite(startedAtMs)) return false;
126
+ try {
127
+ const entry = lstatSync(file);
128
+ // A filesystem clock may trail Date.now() by a few ms (coarse Linux mtimes); an earlier run's
129
+ // file is minutes or hours old, so a small slack never admits it.
130
+ return entry.isFile() && entry.mtimeMs >= startedAtMs - STACK_RELEASE_INTENT_MTIME_SLACK_MS;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** BOT-2881: the parent's process name as a bounded token, or null. A name only: never its argv. */
137
+ export function readParentProcessName(ppid, { run = spawnSync } = {}) {
138
+ if (!Number.isInteger(ppid) || ppid <= 1) return null;
139
+ try {
140
+ const ps = run("ps", ["-o", "comm=", "-p", String(ppid)], { encoding: "utf8", timeout: 300 });
141
+ if (ps?.status !== 0 || typeof ps.stdout !== "string") return null;
142
+ const name = basename(ps.stdout.trim()).replace(/^-/, "").toLowerCase();
143
+ return TOKEN_RE.test(name) ? name : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * BOT-2881: what this process can know about the signal that is ending it. Node exposes no sender,
151
+ * so `source` is one of: the owner's declared release, a terminal (SIGINT/SIGHUP with a TTY), a
152
+ * parent that had already gone, or unknown, with the parent that could have sent it named.
153
+ * `lossFirst`: the lease or its transport was already lost, so this stop did not end the run.
154
+ */
155
+ export function observeStackRunSignal(signal, {
156
+ env = process.env, startedAtMs, startPpid = null, ppid = process.ppid, stdinIsTty = process.stdin?.isTTY === true,
157
+ lossFirst = false, intentDeclared = stackReleaseIntentDeclared, parentName = readParentProcessName,
158
+ } = {}) {
159
+ if (signal === "SIGTERM" && !lossFirst && intentDeclared({ env, startedAtMs })) {
160
+ return { signal, source: STACK_SIGNAL_SOURCE_OWNER_RELEASE, parent: null };
161
+ }
162
+ if (startPpid != null && ppid !== startPpid) return { signal, source: "parent_exited", parent: null };
163
+ return { signal, source: stdinIsTty && signal !== "SIGTERM" ? "terminal" : "unknown", parent: parentName(ppid) };
164
+ }
165
+
94
166
  /** The lane id from `$BOTBUDDY_LANE_SOURCE_RUN_ID` (`lefthook:<invocation>:<lane>`), or null. */
95
167
  export function laneFromSourceRunId(value) {
96
168
  if (typeof value !== "string" || !value.startsWith("lefthook:")) return null;
@@ -109,6 +181,10 @@ const exitInt = (value) => (Number.isInteger(value) && value >= 0 && value <= 25
109
181
  */
110
182
  export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = randomUUID(), opts = {}, env = process.env, durationMs = null }) {
111
183
  const { category, code } = classifyStackRunOutcome(fields, exitCode);
184
+ // BOT-2881: present only when a signal ended the run, so an unsignalled run's payload is unchanged.
185
+ const signal = pick(fields.stop_signal?.signal, SIGNAL_RE);
186
+ // BOT-2880: sent only when present, because an older server refuses an unknown key.
187
+ const callbackAuthorizationReason = pick(fields.callback_authorization_reason, AUTHORIZATION_REASON_RE);
112
188
  return {
113
189
  run_id: runId,
114
190
  lease_id: validStackLeaseId(fields.lease_id),
@@ -126,6 +202,12 @@ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = ran
126
202
  repo: pick(opts.repo, REPO_RE),
127
203
  duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? Math.round(durationMs) : null,
128
204
  cli_version: pick(VERSION, VERSION_RE) ?? "unknown",
205
+ ...(signal ? {
206
+ signal,
207
+ signal_source: pick(fields.stop_signal.source, TOKEN_RE),
208
+ signal_parent: pick(fields.stop_signal.parent, TOKEN_RE),
209
+ } : {}),
210
+ ...(callbackAuthorizationReason ? { callback_authorization_reason: callbackAuthorizationReason } : {}),
129
211
  };
130
212
  }
131
213
 
package/src/stack.mjs CHANGED
@@ -53,12 +53,12 @@ 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";
60
60
  import { bold, dim, yellow } from "./utils.mjs";
61
- import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
61
+ import { STACK_RELEASE_INTENT_ENV, observeStackRunSignal, ownerReleaseHeld, reportStackRunOutcome } from "./stack-run-outcome.mjs";
62
62
 
63
63
  export const STACK_SCHEMA_VERSION = 1;
64
64
 
@@ -253,6 +253,9 @@ ${bold("run OPTIONS")}
253
253
  goes IDLE (no reap wait; receipt cleanup {idle, idle_since}) and the stack
254
254
  directory + port block stay reserved (24 h, renewed on re-adoption).
255
255
  Interrupts, --hard-ttl, a fenced heartbeat or a lost tunnel always reap.
256
+ A hold that outlives its starter sets ${STACK_RELEASE_INTENT_ENV}
257
+ to an absolute path and creates that file just before its stop sends SIGTERM:
258
+ the run is then recorded as released, not interrupted (BOT-2881).
256
259
  --connection-file P Optional empty file path for the mode-0600 connection JSON.
257
260
  --hard-ttl N Bound the child runtime; timeout requests normal cleanup.
258
261
  --local-exec Rejected: stack run never starts Docker directly.
@@ -374,7 +377,11 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
374
377
  CALLBACK_VALIDATION_UNAVAILABLE (Docker unanswered 20 s), CALLBACK_PRODUCER_ENDED,
375
378
  CALLBACK_GENERATION_EXPIRED, CALLBACK_RENEWAL_REFUSED; requester CALLBACK_SSH_LOST,
376
379
  CALLBACK_RELAY_EXITED, CALLBACK_GATEWAY_EXPIRED, CALLBACK_RENEWAL_ACK_TIMEOUT,
377
- CALLBACK_STATUS_UNREADABLE, CALLBACK_PROTOCOL_ERROR, CALLBACK_AUTHORIZATION_LOST
380
+ CALLBACK_STATUS_UNREADABLE, CALLBACK_PROTOCOL_ERROR, CALLBACK_AUTHORIZATION_LOST.
381
+ A refusal must repeat on one re-read before it stops the child (BOT-2880), and
382
+ callback_authorization_reason names the refused check, e.g. requester_host_disabled
383
+ (the requester machine's own Docker host was set to disabled: use maintenance),
384
+ lease_state:reaping, binding_changed:descriptor.producer_pid
378
385
  5 CALLBACK_PORT_BUSY --callback-port is already listening on the serving host (another run or
379
386
  service), the host refused this run's -R because another run took the
380
387
  port first, or another listener shares the port; this run holds no -R —
@@ -3748,7 +3755,8 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3748
3755
  const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
3749
3756
  const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
3750
3757
  const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
3751
- const final = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3758
+ const withWebUrl = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3759
+ const final = incidentBox.stopSignal && withWebUrl ? { ...withWebUrl, stopSignal: incidentBox.stopSignal } : withWebUrl;
3752
3760
  // BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
3753
3761
  if (final && typeof final === "object" && incidentBox.outcomeChannel) {
3754
3762
  Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
@@ -3871,6 +3879,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3871
3879
  let execution = null;
3872
3880
  let leaseProvisioned = false;
3873
3881
  let receivedSignal = null;
3882
+ // BOT-2881: an intent counts only when written after this run began; a parent change is an orphan.
3883
+ const observeSignal = adapters.observeSignal ?? observeStackRunSignal;
3884
+ const runStartedAtMs = Date.now();
3885
+ const startPpid = process.ppid;
3874
3886
  let forcedStopCode = null;
3875
3887
  let fencing = false;
3876
3888
  let activeWaitAbort = null;
@@ -4076,6 +4088,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4076
4088
  const onSignal = (signal) => {
4077
4089
  if (receivedSignal) return;
4078
4090
  receivedSignal = signal;
4091
+ try {
4092
+ incidentBox.stopSignal = observeSignal(signal, { startedAtMs: runStartedAtMs, startPpid, lossFirst: fencing || forcedStopCode != null });
4093
+ } catch {
4094
+ incidentBox.stopSignal = { signal, source: "unknown", parent: null };
4095
+ }
4079
4096
  activeWaitAbort?.abort("interrupted");
4080
4097
  stopChild(signal);
4081
4098
  // If capacity is still queued/provisioning there is no child to hold open;
@@ -4903,18 +4920,19 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4903
4920
  // that survived the auth refresh or a typed JSON-RPC refusal is a definitive answer.
4904
4921
  // BOT-2847: the shared classification (stack-release-retry.mjs) the cleanup release retries on.
4905
4922
  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" });
4923
+ 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
4924
  }
4908
4925
  // BOT-2790 AC-6 (BOT-2828): a backend RPC_ERROR (e.g. a statement timeout) is an unanswered read.
4909
4926
  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" });
4927
+ if (observed.data?.success !== true) throw Object.assign(new Error("current callback authorization unavailable"), { code: "CALLBACK_AUTHORIZATION_LOST", authorizationReason: refusedRpcReason(observed) });
4911
4928
  return observed.data; },
4912
4929
  });
4913
4930
  nativeCallback.onExit((exit) => { if (childResult) return; callbackLost = exit; fencing = true; stopChild("SIGTERM", EXIT.LEASE_FAILED); });
4914
4931
  placement.callbackTransport = NATIVE_CALLBACK_TRANSPORT;
4915
4932
  } catch (error) {
4916
4933
  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 };
4934
+ const openReason = boundedAuthorizationReason(error?.authorizationReason);
4935
+ 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
4936
  }
4919
4937
  }
4920
4938
  placement.transport = "ssh-forward";
@@ -4999,7 +5017,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4999
5017
  if (callbackLost || nativeCallback && !nativeCallback.isLive()) {
5000
5018
  await closeTunnel(); const cleanup = await requestCleanup();
5001
5019
  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 };
5020
+ const lostReason = boundedAuthorizationReason(callbackLost?.authorization_reason);
5021
+ 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
5022
  }
5004
5023
  if (tunnelLost) {
5005
5024
  // The forwards died between being verified and the child starting.
@@ -5065,7 +5084,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
5065
5084
  if (callbackLost && !receivedSignal) {
5066
5085
  // BOT-2828: the receipt names the hop and cause that closed the route, not only the class.
5067
5086
  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` };
5087
+ const authorizationLost = callbackLost.reason === "callback_authorization_lost";
5088
+ // BOT-2880: an authorization loss always names the refused check, even as "unclassified".
5089
+ const callbackAuthorizationReason = authorizationLost ? boundedAuthorizationReason(callbackLost.authorization_reason) ?? "unclassified" : null;
5090
+ 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
5091
  }
5070
5092
  if (tunnelLost && !receivedSignal) {
5071
5093
  return tunnelLostResult(tunnelLost, { leaseId, cleanup, childExitCode: childResult.code, fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) });
@@ -5142,7 +5164,9 @@ export function runReceiptFields(result) {
5142
5164
  return {
5143
5165
  // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
5144
5166
  // so automation can branch on it instead of matching the message text.
5145
- command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
5167
+ command: "run", outcome: ownerReleaseHeld(result) ? "released" : result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
5168
+ // BOT-2881: the signal that ended this run and what is known of its source (never on a run no signal touched).
5169
+ ...(result.stopSignal ? { stop_signal: result.stopSignal } : {}),
5146
5170
  ...(result.state ? { state: result.state } : {}),
5147
5171
  // BOT-2468: the lease's /docker?lease= deeplink (null when the server sent none).
5148
5172
  web_url: result.webUrl ?? null,
@@ -5177,6 +5201,8 @@ export function runReceiptFields(result) {
5177
5201
  ...(result.execution?.callbackTransport ? { callback_transport: result.execution.callbackTransport } : {}),
5178
5202
  // BOT-2828: the typed cause of a native callback route close (e.g. CALLBACK_RUNTIME_DRIFT).
5179
5203
  ...(result.callbackCause ? { callback_cause: result.callbackCause } : {}),
5204
+ // BOT-2880: which check refused a CALLBACK_AUTHORIZATION_LOST (a bounded token, never a value).
5205
+ ...(result.callbackAuthorizationReason ? { callback_authorization_reason: result.callbackAuthorizationReason } : {}),
5180
5206
  // BOT-2780: the reserved requester origin generated auth links name (never a credential).
5181
5207
  ...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
5182
5208
  // BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy" or "new".
@@ -5235,6 +5261,14 @@ export async function cmdRun(opts, childArgv, {
5235
5261
  * receipt deliberately does not carry (which wait a timeout ended and the original
5236
5262
  * lease fence). Never emitted.
5237
5263
  */
5264
+ // BOT-2880: a definitive RPC refusal of the callback status read names itself by the backend's
5265
+ // typed code or its HTTP status. The shared bound drops anything that is not a plain token.
5266
+ function refusedRpcReason(observed) {
5267
+ const code = observed?.data?.code ?? observed?.code;
5268
+ const named = typeof code === "string" && code ? code.toLowerCase() : Number.isInteger(observed?.status) ? `http_${observed.status}` : null;
5269
+ return (named && boundedAuthorizationReason(`refused:${named}`)) ?? "refused";
5270
+ }
5271
+
5238
5272
  export function stackRunOutcomeFields(result) {
5239
5273
  return {
5240
5274
  ...runReceiptFields(result), lease_fenced: result?.fenced === true,