@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.
- 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 +41 -11
- package/src/stack-run-outcome.mjs +83 -1
- package/src/stack.mjs +44 -10
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.63.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.44",
|
|
3
|
+
"source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
|
|
4
|
+
"source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "1740585f4d9e5ce03d798353c1c473b6b818191f83d3a6e63b8e94f6e5d975bb"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.63.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.63.44",
|
|
3
|
+
"source_commit": "6fb9ea556869f4b6bb5b1851941dc348009198ff",
|
|
4
|
+
"source_sha256": "175730f9512d5a1fa81e4800f0be93420e5fd91f1968bba4cd264611b8ae0f82",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "93c1837e1aad2e8cb048399e57d2d4504db08d5edda38a92369558ceb4c4e8c4"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.63.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
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": "
|
|
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": "
|
|
18
|
+
"sha256": "93c1837e1aad2e8cb048399e57d2d4504db08d5edda38a92369558ceb4c4e8c4",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
|
@@ -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)
|
|
35
|
-
if(current.
|
|
36
|
-
|
|
37
|
-
if(current.
|
|
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,
|
|
115
|
-
try{
|
|
116
|
-
|
|
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())
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|