@botbuddy/cli 1.63.13 → 1.63.14

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.13",
3
- "source_commit": "36f37695e469a42ec5b85ae0d3db8fefa4c4d5bc",
4
- "source_sha256": "ed337e0fa12ed74a93c4eeef069f30a6733a48cd6b3e2de85dc6e1e3f6b1c2c6",
2
+ "release_version": "1.63.14",
3
+ "source_commit": "8ec06459dcd6e698ce14b725eb4b6691083ec445",
4
+ "source_sha256": "b72415fa0cd4b8b19a2613c78573c958809c453191717b9e53fabb23afa6f7ea",
5
5
  "target": "darwin-arm64",
6
- "sha256": "bd4b5271deee0be522d4d4ff112b18a48999d7a7fbeccd1e59c001b3e54ab3cb"
6
+ "sha256": "eb7aba5ff1ca0c551f02b1e0ce7e3d136e000443577d4494160da94e3a8ecb68"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.13",
3
- "source_commit": "36f37695e469a42ec5b85ae0d3db8fefa4c4d5bc",
4
- "source_sha256": "ed337e0fa12ed74a93c4eeef069f30a6733a48cd6b3e2de85dc6e1e3f6b1c2c6",
2
+ "release_version": "1.63.14",
3
+ "source_commit": "8ec06459dcd6e698ce14b725eb4b6691083ec445",
4
+ "source_sha256": "b72415fa0cd4b8b19a2613c78573c958809c453191717b9e53fabb23afa6f7ea",
5
5
  "target": "linux-x64",
6
- "sha256": "83d29dc25d53a99c882f1df99f6738d175beed460b12f01fef8a8d9dca1185b2"
6
+ "sha256": "fe9452d4139a7db72844293109e0494605853e8cd3f9603043207b6cd892e990"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.13",
4
- "source_commit": "36f37695e469a42ec5b85ae0d3db8fefa4c4d5bc",
5
- "source_sha256": "ed337e0fa12ed74a93c4eeef069f30a6733a48cd6b3e2de85dc6e1e3f6b1c2c6",
3
+ "release_version": "1.63.14",
4
+ "source_commit": "8ec06459dcd6e698ce14b725eb4b6691083ec445",
5
+ "source_sha256": "b72415fa0cd4b8b19a2613c78573c958809c453191717b9e53fabb23afa6f7ea",
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": "bd4b5271deee0be522d4d4ff112b18a48999d7a7fbeccd1e59c001b3e54ab3cb",
11
+ "sha256": "eb7aba5ff1ca0c551f02b1e0ce7e3d136e000443577d4494160da94e3a8ecb68",
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": "83d29dc25d53a99c882f1df99f6738d175beed460b12f01fef8a8d9dca1185b2",
18
+ "sha256": "fe9452d4139a7db72844293109e0494605853e8cd3f9603043207b6cd892e990",
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.13",
3
+ "version": "1.63.14",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,17 @@
1
+ // BOT-2780 consumer of BOT-2499's identified admission ACK (not a second allocator).
2
+ // A durable ACK is recovery identity; pending delivery is never dispatch authority.
3
+ const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
4
+ export function identifiedAdmissionAck(value, requestId) {
5
+ if(value?.success!==true||!['ADMITTED','EXACT_REPLAY'].includes(value.code)||!uuid(requestId)||!uuid(value.request_id)||value.request_id.toLowerCase()!==requestId.toLowerCase()||!uuid(value.lease_id)||!['created','readopted'].includes(value.admission_kind)||!['queued','provisioning','active'].includes(value.state)||!['pending','bound','none'].includes(value.job_binding)||!(value.provision_job_id===null||uuid(value.provision_job_id))||((value.job_binding==='bound')!==uuid(value.provision_job_id))||(value.admission_kind==='created'&&value.job_binding==='none')||(value.admission_kind==='readopted'&&value.job_binding!=='none')||!['ready','pending'].includes(value.delivery_status))return null;
6
+ return Object.freeze(Object.fromEntries(['code','request_id','lease_id','admission_kind','state','job_binding','provision_job_id','delivery_status'].map(key=>[key,value[key]])));
7
+ }
8
+ export function identifiedAdmissionDelivery(value, ack) {
9
+ const delivery=value?.delivery;
10
+ if(!ack||ack.delivery_status!=='ready'||!delivery||typeof delivery!=='object'||Array.isArray(delivery)||delivery.success===false||delivery.lease_id!=null&&delivery.lease_id!==ack.lease_id||delivery.state!=null&&delivery.state!==ack.state)return null;
11
+ return {...delivery,success:true,lease_id:ack.lease_id,state:ack.state};
12
+ }
13
+ export function identifiedAdmissionRecovery(request, contextFile, ack=null) {
14
+ return {request_id:request.request_id,context_file:contextFile,
15
+ read_tool:'get_stack_lease_admission',read_args:{request_id:request.request_id},
16
+ ...(ack?{committed_ack:ack}:{})};
17
+ }
@@ -786,6 +786,39 @@ export function stableRunStackReusable(worktreeRoot, dirName, { read = readFileS
786
786
  return marker.idle_retained === true || marker.released === true;
787
787
  }
788
788
 
789
+ /**
790
+ * BOT-2780: a beta requester-origin run never resumes a retained slot context. Its
791
+ * generated links name the listener of the invocation that started that stack, so
792
+ * refuse any unreleased marker for this slot before deriving a new bundle or ports.
793
+ */
794
+ export function assertFreshRunStackAvailable(worktreeRoot, slot, { read = readFileSync, readdir = readdirSync, lstat = lstatSync } = {}) {
795
+ const refuse = () => {
796
+ const error = new Error("requester origin cannot resume a retained slot context; inspect its owning lease with `bb stack status <lease-id>` and use `bb stack done <lease-id> --reap` only after that owner finishes, then confirm reaped before a fresh run");
797
+ error.code = "REQUESTER_ORIGIN_RETAINED_CONTEXT";
798
+ throw error;
799
+ };
800
+ const stacks = join(worktreeRoot, LEASED_STACKS_DIR);
801
+ let entries;
802
+ try { entries = readdir(stacks); } catch (error) { if (error.code === "ENOENT") return; refuse(); }
803
+ if (entries.length > 4096) refuse();
804
+ for (const entry of entries) {
805
+ const path = join(stacks, entry, LEASED_STACK_MARKER);
806
+ let info;
807
+ try { info = lstat(path); } catch (error) { if (error.code === "ENOENT" || error.code === "ENOTDIR") continue; refuse(); }
808
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 65536) refuse();
809
+ let marker;
810
+ try { marker = JSON.parse(read(path, "utf8")); } catch { refuse(); }
811
+ if (!marker || typeof marker.slot !== "string") refuse();
812
+ if (marker.slot !== slot || marker.released === true) continue;
813
+ // An expired context whose owner is gone is what materialization GC collects anyway; the
814
+ // control plane still refuses a new remote lease while any lease on the slot is unreaped.
815
+ const ownerAlive = Number.isInteger(marker.owner_pid) && marker.owner_pid > 0 &&
816
+ (!marker.host || marker.host === hostname()) && pidIsAlive(marker.owner_pid);
817
+ const expired = marker.idle_retained !== true && Number.isFinite(marker.retain_until) && Date.now() >= marker.retain_until;
818
+ if (!expired || ownerAlive) refuse();
819
+ }
820
+ }
821
+
789
822
  /** BOT-1803: record a PROVEN reap on the stack's marker, so the next run may reuse the directory. */
790
823
  export function markLeasedStackReleased(stackDir, { read = readFileSync, write = writeFileSync, rename = renameSync } = {}) {
791
824
  const marker = readStackMarker(stackDir, read);
@@ -1246,6 +1279,22 @@ export function bindLeasedStackClaim(stackDir, leaseId, { registryDir = defaultP
1246
1279
  });
1247
1280
  }
1248
1281
 
1282
+ /**
1283
+ * BOT-2780: associate this invocation's claims and marker with its identified admission
1284
+ * request BEFORE the request leaves, so a possibly committed but never acknowledged lease
1285
+ * can later release them through matching accepted cleanup (recoverLeasedStackClaim).
1286
+ */
1287
+ export function recordAdmissionRequest(stackDir, requestId, { registryDir = defaultPortRegistryDir() } = {}) {
1288
+ const marker = readStackMarker(stackDir, readFileSync);
1289
+ const token = marker?.invocation_token;
1290
+ if (!token || typeof requestId !== "string" || !requestId) throw new Error("local port claim has no invocation identity");
1291
+ return mutateBoundClaims(stackDir, registryDir, (claims) => {
1292
+ if (claims.some(({ claim }) => claim.invocation_token !== token || claim.lease_id != null)) throw new Error("local port claim was replaced or already bound");
1293
+ for (const { path, claim } of claims) atomicWrite(path, JSON.stringify({ ...claim, admission_request_id: requestId }), { write: writeFileSync, rename: renameSync });
1294
+ atomicWrite(join(stackDir, LEASED_STACK_MARKER), JSON.stringify({ ...marker, admission_request_id: requestId }), { write: writeFileSync, rename: renameSync });
1295
+ });
1296
+ }
1297
+
1249
1298
  /** Allocation and recovery serialize the inspect/delete against replacement claims. */
1250
1299
  function mutateBoundClaims(stackDir, registryDir, mutate) {
1251
1300
  const config = readFileSync(join(stackDir, "supabase/config.toml"), "utf8");
@@ -1286,8 +1335,12 @@ export function recoverLeasedStackClaim(stackDir, leaseId, proof, { registryDir
1286
1335
  if (!claims.length) return { released: true, reason: "no local claim remains for this stack" };
1287
1336
  const token = claims[0].claim.invocation_token;
1288
1337
  const project = claims[0].claim.project_id;
1338
+ // BOT-2780: an unbound claim recorded for an identified admission request is this lease's
1339
+ // only when the caller proved (owner-scoped admission read) that request named the lease.
1340
+ const requestBound = (entry) => entry?.lease_id == null && typeof proof.admission_request_id === "string" &&
1341
+ proof.admission_request_id.length > 0 && entry?.admission_request_id === proof.admission_request_id;
1289
1342
  if (typeof token !== "string" || !token || typeof project !== "string" || !/^[A-Za-z0-9_-]+$/.test(project) || claims.some(({ claim }) =>
1290
- claim.lease_id !== leaseId || claim.invocation_token !== token || claim.project_id !== project || claim.host !== hostname() ||
1343
+ (claim.lease_id !== leaseId && !requestBound(claim)) || claim.invocation_token !== token || claim.project_id !== project || claim.host !== hostname() ||
1291
1344
  !Number.isInteger(claim.owner_pid) || claim.owner_pid <= 0 || pidIsAlive(claim.owner_pid))) {
1292
1345
  return { released: false, reason: "local claim is live, replaced, or lacks this lease binding" };
1293
1346
  }
@@ -1302,7 +1355,7 @@ export function recoverLeasedStackClaim(stackDir, leaseId, proof, { registryDir
1302
1355
  if (error?.code !== "ENOENT") return { released: false, reason: "stack marker is unreadable or corrupt; local claim retained" };
1303
1356
  marker = null;
1304
1357
  }
1305
- if (marker && (marker.invocation_token !== token || marker.lease_id !== leaseId || marker.project_id !== project)) {
1358
+ if (marker && (marker.invocation_token !== token || (marker.lease_id !== leaseId && !requestBound(marker)) || marker.project_id !== project)) {
1306
1359
  return { released: false, reason: "stack marker was replaced" };
1307
1360
  }
1308
1361
  for (const { path } of claims) rmSync(path);
@@ -1687,7 +1740,8 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1687
1740
  const configPath = join(sourceDir, "config.toml");
1688
1741
  let rootConfig;
1689
1742
  try {
1690
- rootConfig = read(configPath, "utf8");
1743
+ // BOT-2780: a beta requester-origin run supplies the already patched private copy.
1744
+ rootConfig = io.configText ?? read(configPath, "utf8");
1691
1745
  } catch (error) {
1692
1746
  throw new Error(
1693
1747
  `cannot materialize an isolated leased stack: ${configPath} is missing or unreadable (${error?.code ?? error?.message ?? error}). ` +
@@ -0,0 +1,21 @@
1
+ // BOT-2780: parse and roundtrip the private derived config before bundling.
2
+ // The vendored, hash-pinned smol-toml 1.9.1 parser (src/vendor/smol-toml) is loaded only
3
+ // for the beta requester-origin feature; help and legacy paths never import it.
4
+ import {isDeepStrictEqual} from 'node:util';
5
+ const fail=(code,message)=>{const error=Error(message);error.code=code;throw error};
6
+ let parser;
7
+ export async function patchRequesterAuthOrigin(configText,origin){
8
+ let url;try{url=new URL(origin)}catch{fail('REQUESTER_ORIGIN_URL_REFUSED','requester origin refused')}
9
+ if(url.protocol!=='http:'||url.hostname!=='127.0.0.1'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||origin!==url.origin||!Number.isInteger(Number(url.port))||Number(url.port)<1024||Number(url.port)>65535)fail('REQUESTER_ORIGIN_URL_REFUSED','requester origin must be its reserved loopback HTTP listener');
10
+ if(typeof configText!=='string'||Buffer.byteLength(configText)>1024*1024)fail('REQUESTER_ORIGIN_TOML_REFUSED','private stack config is unavailable or exceeds its bound');
11
+ try{parser??=import('./vendor/smol-toml/index.js');const {parse,stringify}=await parser;let document;try{document=parse(configText,{integersAsBigInt:true,unsafeKeyBehaviour:'throw'})}catch{fail('REQUESTER_ORIGIN_TOML_REFUSED','private stack config is not supported valid TOML')}
12
+ if(!Object.hasOwn(document,'auth'))Object.defineProperty(document,'auth',{value:{},writable:true,enumerable:true,configurable:true});
13
+ const auth=document.auth;if(!auth||typeof auth!=='object'||Array.isArray(auth))fail('REQUESTER_ORIGIN_AUTH_REFUSED','private auth configuration is ambiguous');
14
+ const target=origin+'/auth/v1';
15
+ if(Object.hasOwn(auth,'external_url')&&(typeof auth.external_url!=='string'||auth.external_url!==''&&auth.external_url!==target))fail('REQUESTER_ORIGIN_EXPLICIT_CONFLICT','explicit auth external URL conflicts with the owned requester origin');
16
+ Object.defineProperty(auth,'external_url',{value:target,writable:true,enumerable:true,configurable:true});
17
+ let text,roundtrip;try{text=stringify(document,{numbersAsFloat:true,strictTemporal:true});roundtrip=parse(text,{integersAsBigInt:true,unsafeKeyBehaviour:'throw'})}catch{fail('REQUESTER_ORIGIN_TOML_REFUSED','private stack config could not roundtrip safely')}
18
+ if(!isDeepStrictEqual(roundtrip,document))fail('REQUESTER_ORIGIN_TOML_REFUSED','private stack config changed during TOML roundtrip');
19
+ return text;
20
+ }catch(error){if(error.code?.startsWith('REQUESTER_ORIGIN_'))throw error;fail('REQUESTER_ORIGIN_PARSER_UNAVAILABLE','vendored requester-origin TOML parser unavailable')}
21
+ }
@@ -0,0 +1,62 @@
1
+ // BOT-2780: one requester listener, reserved before a service generates URLs.
2
+ // The CLI later binds it to exactly one API port on its owned SSH tunnel. This
3
+ // listener is not itself an SSH forward and never supplies forward attestation.
4
+ import {createServer,connect} from 'node:net';
5
+
6
+ const fail=(code,message)=>{const error=Error(message);error.code=code;throw error};
7
+ const validPort=value=>Number.isInteger(value)&&value>=1024&&value<=65535;
8
+ const identity=value=>typeof value==='string'&&value.length>0&&value.length<=4096&&!/[\x00-\x1f\x7f]/.test(value);
9
+ function contextSnapshot(value){
10
+ if(!value||typeof value!=='object'||Array.isArray(value)||!Object.isFrozen(value))fail('REQUESTER_ORIGIN_CONTEXT_REFUSED','immutable requester context required');
11
+ const keys=Object.keys(value).sort();if(!keys.length||keys.length>16||keys.some(key=>!identity(key)||!identity(value[key])))fail('REQUESTER_ORIGIN_CONTEXT_REFUSED','requester context refused');
12
+ return JSON.stringify(keys.map(key=>[key,value[key]]));
13
+ }
14
+ function apiPort(value){
15
+ let url;try{url=new URL(value)}catch{fail('REQUESTER_ORIGIN_FORWARD_REFUSED','owned API URL unavailable')}
16
+ if(url.protocol!=='http:'||url.hostname!=='127.0.0.1'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||!validPort(Number(url.port)))fail('REQUESTER_ORIGIN_FORWARD_REFUSED','API target must be the owned loopback HTTP endpoint');
17
+ return Number(url.port);
18
+ }
19
+ export async function reserveRequesterOrigin({context,port=0}={}){
20
+ const snapshot=contextSnapshot(context);if(port!==0&&!validPort(port))fail('REQUESTER_ORIGIN_PORT_REFUSED','requester origin port refused');
21
+ const sockets=new Set(),lossListeners=new Set();let route,closed=false,lostCode,closePromise;
22
+ const live=()=>{try{return !closed&&route?.isLive()===true}catch{return false}};
23
+ const close=code=>{
24
+ if(closePromise)return closePromise;closed=true;lostCode=code??lostCode;
25
+ for(const socket of sockets)socket.destroy();sockets.clear();
26
+ closePromise=new Promise(resolve=>server.close(()=>resolve()));
27
+ if(code)for(const listener of lossListeners){try{listener({code})}catch{}}
28
+ return closePromise;
29
+ };
30
+ const server=createServer({pauseOnConnect:true},socket=>{
31
+ if(!route){socket.destroy();return}
32
+ if(!live()){socket.destroy();void close('REQUESTER_ORIGIN_TUNNEL_LOST');return}
33
+ sockets.add(socket);const upstream=connect({host:'127.0.0.1',port:route.targetPort});sockets.add(upstream);
34
+ socket.once('error',()=>upstream.destroy());socket.once('close',()=>{sockets.delete(socket);upstream.destroy()});upstream.once('close',()=>sockets.delete(upstream));
35
+ upstream.once('error',()=>{socket.destroy();void close('REQUESTER_ORIGIN_FORWARD_LOST')});
36
+ upstream.once('connect',()=>{
37
+ if(!live()){socket.destroy();upstream.destroy();void close('REQUESTER_ORIGIN_TUNNEL_LOST');return}
38
+ // Pipe only after rechecking custody at connect time. Request paths, auth,
39
+ // bodies and WebSocket upgrade bytes are transparent; there is no URL target.
40
+ socket.pipe(upstream);upstream.pipe(socket);socket.resume();
41
+ });
42
+ });
43
+ server.maxConnections=64;
44
+ try{await new Promise((resolve,reject)=>{server.once('error',reject);server.listen({host:'127.0.0.1',port,exclusive:true},resolve)})}catch(error){await close();fail(error.code==='EADDRINUSE'?'REQUESTER_ORIGIN_PORT_BUSY':'REQUESTER_ORIGIN_BIND_FAILED','requester origin could not be reserved')}
45
+ const boundPort=server.address().port;server.on('error',()=>{void close('REQUESTER_ORIGIN_BIND_FAILED')});
46
+ return{
47
+ origin:'http://127.0.0.1:'+boundPort,
48
+ activate({context:current,leaseId,projectId,sshTarget,remoteConnection,forwardedConnection,forwards,isLive,watchLoss}={}){
49
+ if(closed||route)fail('REQUESTER_ORIGIN_STATE_REFUSED','requester origin cannot be rebound in place');
50
+ if(contextSnapshot(current)!==snapshot||![leaseId,projectId,sshTarget].every(identity)||typeof isLive!=='function'||typeof watchLoss!=='function')fail('REQUESTER_ORIGIN_CONTEXT_REFUSED','owned current lease/forward context required');
51
+ const remote=apiPort(remoteConnection?.api_url),local=apiPort(forwardedConnection?.api_url);
52
+ if(!Array.isArray(forwards)||forwards.length<1||forwards.length>32||forwards.some(row=>!identity(row?.name)||!validPort(row.remote)||!validPort(row.local))||['name','remote','local'].some(key=>new Set(forwards.map(row=>row[key])).size!==forwards.length))fail('REQUESTER_ORIGIN_FORWARD_REFUSED','SSH forward table refused');
53
+ const api=forwards.find(row=>row.remote===remote);if(!api||api.local!==local||local===boundPort)fail('REQUESTER_ORIGIN_FORWARD_REFUSED','API URL differs from actual SSH forward');
54
+ if(isLive()!==true)fail('REQUESTER_ORIGIN_TUNNEL_LOST','owned SSH tunnel is not live');
55
+ route=Object.freeze({leaseId,projectId,sshTarget,targetPort:local,isLive});
56
+ try{watchLoss(()=>{void close('REQUESTER_ORIGIN_TUNNEL_LOST')})}catch{void close('REQUESTER_ORIGIN_TUNNEL_LOST');fail('REQUESTER_ORIGIN_TUNNEL_LOST','owned SSH loss subscription unavailable')}
57
+ if(!live()){void close('REQUESTER_ORIGIN_TUNNEL_LOST');fail('REQUESTER_ORIGIN_TUNNEL_LOST','owned SSH tunnel ended during activation')}
58
+ },
59
+ close:()=>close(),isLive:live,
60
+ onLost(listener){lossListeners.add(listener);if(lostCode)listener({code:lostCode})},
61
+ };
62
+ }