@botbuddy/cli 1.64.24 → 1.64.26

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.64.26
4
+
5
+ - `bb stack run --remote` (and auto runs placed remotely) never binds a local port in the leased serving band 61000–64999 any more. SSH forwards whose remote number falls in that band are remapped to a port outside it, remapped and probe ports are picked from explicit out-of-band candidates instead of the kernel's ephemeral range, and the requester origin and native callback gateway are reserved outside the band too. A `--callback-port` inside the band is refused with `CALLBACK_PORT_RESERVED` before anything is probed or spawned. This stops a remote lease's tunnel from taking ports a co-located stack on the same machine was just given.
6
+
7
+ ## 1.64.25
8
+
9
+ - `bb pw` staging-pool takes now durably queue a secret-free, server-attributed telemetry receipt before a browser receives the single-use session. The receipt never includes the state file, path, credential, or caller-selected tenant/session/ticket/PR; a successor session reconciles an undelivered receipt under its verified source-session attribution.
10
+
3
11
  ## 1.64.20
4
12
 
5
13
  - Session checkpoint writes are deprecated. Keep delivery and acceptance evidence on the Linear ticket; the compatibility MCP tool returns a deprecation notice without writing a checkpoint.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.24",
3
- "source_commit": "7bfc909b46cc2bf6f7d45264be7e52ca16e69e13",
4
- "source_sha256": "680b44125605dea4db83af948177743740b848b08f26619329e949e5a2e6253a",
2
+ "release_version": "1.64.26",
3
+ "source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
4
+ "source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
5
5
  "target": "darwin-arm64",
6
- "sha256": "554e01cb52c8966c4946b467858873733afadc9f645e7add1598098bc7ec7b98"
6
+ "sha256": "1c61bc84457da670166622fe9ef8fc7295a1f2286591e7cb8850aed78023858b"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.24",
3
- "source_commit": "7bfc909b46cc2bf6f7d45264be7e52ca16e69e13",
4
- "source_sha256": "680b44125605dea4db83af948177743740b848b08f26619329e949e5a2e6253a",
2
+ "release_version": "1.64.26",
3
+ "source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
4
+ "source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
5
5
  "target": "linux-x64",
6
- "sha256": "01533501078482e1ac720ba05cd763c6f4196831ad55c6c15dcf5e8dda30d459"
6
+ "sha256": "718ad319db010d21e1ac51da283f7d64d2cd9c9313abbc16912d1e560426be2e"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.24",
4
- "source_commit": "7bfc909b46cc2bf6f7d45264be7e52ca16e69e13",
5
- "source_sha256": "680b44125605dea4db83af948177743740b848b08f26619329e949e5a2e6253a",
3
+ "release_version": "1.64.26",
4
+ "source_commit": "3c0c0a5b3043740906a1d230d2e52bfb040af538",
5
+ "source_sha256": "37ab46e615c285d102088320e3106cbc968d5c42f345e48051980173eaeffba2",
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": "554e01cb52c8966c4946b467858873733afadc9f645e7add1598098bc7ec7b98",
11
+ "sha256": "1c61bc84457da670166622fe9ef8fc7295a1f2286591e7cb8850aed78023858b",
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": "01533501078482e1ac720ba05cd763c6f4196831ad55c6c15dcf5e8dda30d459",
18
+ "sha256": "718ad319db010d21e1ac51da283f7d64d2cd9c9313abbc16912d1e560426be2e",
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.64.24",
3
+ "version": "1.64.26",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -297,7 +297,7 @@ async function requestPoolRefill({ ctxPool, persona, deps, env }) {
297
297
  * lock-gated lane held by this exact agent, and a staging (or untracked) browser
298
298
  * session. Returns the process exit code.
299
299
  */
300
- export async function runStagingAuth({ plan, host, env, deps, socketRun, resolveSession, stdout, stderr, holder = null }) {
300
+ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolveSession, stdout, stderr, holder = null, credential = null }) {
301
301
  const io = { stdout, stderr };
302
302
  const poolModule = deps.stagingPoolModule ?? await loadPool();
303
303
  const poolOptions = deps.stagingPool ?? {};
@@ -342,7 +342,13 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
342
342
  dropGone(ctx, generation);
343
343
  let claim;
344
344
  try {
345
- claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, poolId: ctxPool.poolId, tenantHint, ...poolOptions });
345
+ // The outbox writer is a child `bb call`; carry the gate-verified session
346
+ // credential into that child so it cannot select a cached/durable identity.
347
+ const sessionId = credential?.sessionId ?? env.BOTBUDDY_SESSION_ID;
348
+ const deliveryEnv = credential?.token
349
+ ? { ...env, BOTBUDDY_AGENT_SESSION_TOKEN: credential.token, BOTBUDDY_SESSION_ID: sessionId }
350
+ : env;
351
+ claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, poolId: ctxPool.poolId, tenantHint, ...poolOptions, env: deliveryEnv, sessionId });
346
352
  } catch (error) {
347
353
  if (error?.code === "pool_empty") {
348
354
  const refill = await requestPoolRefill({ ctxPool, persona: persona.email, deps, env });
@@ -3,11 +3,14 @@
3
3
  import {createServer, request} from 'node:http';
4
4
  import {createHash,createHmac, timingSafeEqual} from 'node:crypto';
5
5
  import {Transform} from 'node:stream';
6
+ import {TUNNEL_PORT_PICKS,admissibleTunnelPort,randomTunnelPort} from './stack-tunnel.mjs';
6
7
  export const CALLBACK_CAP_HEADER='x-botbuddy-callback-capability';
7
8
  const digest=value=>createHash('sha256').update(value).digest();
8
9
  export const CALLBACK_MAX_STREAM_BYTES=32*1024*1024;
9
10
  const sameBinding=(a,b)=>b&&typeof b==='object'&&Object.keys(a).length===Object.keys(b).length&&Object.keys(a).every(key=>a[key]===b[key]);
10
- export async function openCallbackGateway({callbackPort,binding,capability,lifetimeMs=30_000,now=Date.now,activate=true,readinessNonce=null}){
11
+ // One listen attempt; both one-shot listeners are removed whichever fires, so retries never pile up.
12
+ const listenOnce=(server,port)=>new Promise((resolve,reject)=>{const onError=error=>{server.off('listening',onListening);reject(error)};const onListening=()=>{server.off('error',onError);resolve()};server.once('error',onError);server.once('listening',onListening);server.listen(port,'127.0.0.1');});
13
+ export async function openCallbackGateway({callbackPort,binding,capability,lifetimeMs=30_000,now=Date.now,activate=true,readinessNonce=null,pickPort=randomTunnelPort}){
11
14
  if(!Number.isInteger(callbackPort)||callbackPort<1024||callbackPort>65535||!binding||typeof binding!=='object'||!Object.isFrozen(binding)||typeof capability!=='string'||!/^[0-9a-f]{64}$/.test(capability)||lifetimeMs<1||lifetimeMs>30_000)throw Error('callback gateway admission refused');
12
15
  if(readinessNonce!==null&&!/^[0-9a-f]{32}$/.test(readinessNonce))throw Error('callback readiness identity refused');
13
16
  const bootstrapDeadline=now()+30_000,readinessPath=readinessNonce?'/.botbuddy/callback-ready/'+readinessNonce:null;
@@ -38,7 +41,13 @@ export async function openCallbackGateway({callbackPort,binding,capability,lifet
38
41
  const denySocket=(_req,socket)=>socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');server.on('connect',denySocket);server.on('upgrade',denySocket);
39
42
  let closePromise;const close=()=>{if(closePromise)return closePromise;closed=true;clearTimeout(timer);for(const controller of active)controller.abort();active.clear();server.closeAllConnections();closePromise=new Promise(resolve=>server.close(resolve));for(const listener of closeListeners)listener();return closePromise;};
40
43
  const refresh=(current,validForMs=lifetimeMs)=>{let matches=false;try{matches=sameBinding(binding,current)}catch{}if(closed||!matches||!Number.isFinite(validForMs)||validForMs<=0||validForMs>lifetimeMs){close();throw Error('callback binding changed');}deadline=now()+validForMs;clearTimeout(timer);timer=setTimeout(close,validForMs);timer.unref();};
41
- await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
44
+ // BOT-2503: an explicit port outside the leased serving band, never listen(0) (its kernel range covers it).
45
+ let bound=false;
46
+ for(let tries=0;!bound&&tries<TUNNEL_PORT_PICKS;tries++){
47
+ const port=pickPort();if(!admissibleTunnelPort(port)||port===callbackPort)continue;
48
+ try{await listenOnce(server,port);bound=true}catch(error){if(error.code!=='EADDRINUSE'){close();throw error}}
49
+ }
50
+ if(!bound){close();throw Error('callback gateway could not bind a port outside the leased serving band');}
42
51
  if(activate)refresh(binding);else{timer=setTimeout(close,30_000);timer.unref();}
43
52
  return {port:server.address().port,refresh,close,onClose(listener){closeListeners.add(listener);if(closed)listener()},isLive:()=>!closed&&now()<deadline};
44
53
  }
@@ -2,6 +2,7 @@
2
2
  // The CLI later binds it to exactly one API port on its owned SSH tunnel. This
3
3
  // listener is not itself an SSH forward and never supplies forward attestation.
4
4
  import {createServer,connect} from 'node:net';
5
+ import {TUNNEL_PORT_PICKS,admissibleTunnelPort,inServingBand,randomTunnelPort} from './stack-tunnel.mjs';
5
6
 
6
7
  const fail=(code,message)=>{const error=Error(message);error.code=code;throw error};
7
8
  const validPort=value=>Number.isInteger(value)&&value>=1024&&value<=65535;
@@ -16,8 +17,13 @@ function apiPort(value){
16
17
  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
18
  return Number(url.port);
18
19
  }
19
- export async function reserveRequesterOrigin({context,port=0}={}){
20
+ // BOT-2503: port 0 means "pick one": explicit candidates outside the leased serving band,
21
+ // never listen(0), whose kernel range covers it. An explicit serving-band port is refused
22
+ // with REQUESTER_ORIGIN_PORT_RESERVED before anything binds.
23
+ // avoidPorts: local numbers a caller's own service will bind later (the callback port), never picked.
24
+ export async function reserveRequesterOrigin({context,port=0,pickPort=randomTunnelPort,avoidPorts=[]}={}){
20
25
  const snapshot=contextSnapshot(context);if(port!==0&&!validPort(port))fail('REQUESTER_ORIGIN_PORT_REFUSED','requester origin port refused');
26
+ if(port!==0&&inServingBand(port))fail('REQUESTER_ORIGIN_PORT_RESERVED','requester origin port is in the leased serving band');
21
27
  const sockets=new Set(),lossListeners=new Set();let route,closed=false,lostCode,closePromise;
22
28
  const live=()=>{try{return !closed&&route?.isLive()===true}catch{return false}};
23
29
  const close=code=>{
@@ -41,7 +47,15 @@ export async function reserveRequesterOrigin({context,port=0}={}){
41
47
  });
42
48
  });
43
49
  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')}
50
+ const listenOn=candidate=>new Promise((resolve,reject)=>{const onError=error=>{server.off('listening',onListening);reject(error)};const onListening=()=>{server.off('error',onError);resolve()};server.once('error',onError);server.once('listening',onListening);server.listen({host:'127.0.0.1',port:candidate,exclusive:true})});
51
+ const avoid=new Set(avoidPorts.map(Number));
52
+ let bound=false;
53
+ for(let tries=0;!bound&&tries<(port!==0?1:TUNNEL_PORT_PICKS);tries++){
54
+ const candidate=port!==0?port:pickPort();
55
+ if(!admissibleTunnelPort(candidate)||!validPort(candidate)||(port===0&&avoid.has(candidate)))continue;
56
+ try{await listenOn(candidate);bound=true}catch(error){if(port!==0||error.code!=='EADDRINUSE'){await close();fail(error.code==='EADDRINUSE'?'REQUESTER_ORIGIN_PORT_BUSY':'REQUESTER_ORIGIN_BIND_FAILED','requester origin could not be reserved')}}
57
+ }
58
+ if(!bound){await close();fail('REQUESTER_ORIGIN_PORT_BUSY','requester origin could not be reserved outside the leased serving band')}
45
59
  const boundPort=server.address().port;server.on('error',()=>{void close('REQUESTER_ORIGIN_BIND_FAILED')});
46
60
  return{
47
61
  origin:'http://127.0.0.1:'+boundPort,
@@ -11,8 +11,10 @@
11
11
  //
12
12
  // Contract:
13
13
  // * every forward binds 127.0.0.1 on BOTH ends; nothing leaves loopback;
14
- // * a free local port keeps the remote number, a busy one gets an ephemeral
15
- // port (the remap table is returned and lands on the receipt);
14
+ // * a free local port keeps the remote number, a busy one gets a fresh port
15
+ // (the remap table is returned and lands on the receipt) — except that no
16
+ // tunnel listener, local or on the host, ever lands in the leased serving band
17
+ // (BOT-2503), so a serving-band remote number is always remapped;
16
18
  // * `ExitOnForwardFailure=yes` makes a lost bind race a clean exit, retried on
17
19
  // remapped ports; `BatchMode=yes` means ssh never prompts (no agent hang);
18
20
  // * `ServerAlive*` turns a dead link (caller asleep, BOT-1980) into an exit the
@@ -24,6 +26,42 @@
24
26
  import { spawn, spawnSync } from "node:child_process";
25
27
  import { connect, createServer, isIP } from "node:net";
26
28
  import { canonicalStackPorts, stackServicesForForwards } from "./stack-service-discovery.mjs";
29
+ import { LEASED_PORT_BASE, LEASED_PORT_SLOTS, LEASED_PORT_STRIDE } from "./stack-isolation.mjs";
30
+
31
+ /**
32
+ * BOT-2503: the band the CLI's local block chooser hands to co-located leased stacks
33
+ * (stack-isolation.mjs). A block is reserved before its containers listen, so a listener this
34
+ * module creates inside it — a forward keeping its remote number, a kernel-picked ephemeral
35
+ * port, a probe port — could take a port the co-located `supabase start` then fails to bind.
36
+ * Nothing the tunnel binds, locally or on the host, may fall in [START, END).
37
+ */
38
+ export const SERVING_BAND_START = LEASED_PORT_BASE;
39
+ export const SERVING_BAND_END = LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE;
40
+ export const inServingBand = (port) => Number(port) >= SERVING_BAND_START && Number(port) < SERVING_BAND_END;
41
+
42
+ /**
43
+ * Where the tunnel picks ports itself (remapped forwards, the probe's host port and its held local
44
+ * port): explicit candidates the kernel never chooses for us, so nothing is filtered after a bind.
45
+ * Below the band, above Supply Guard's per-ticket blocks (56321 + n×10, n ≤ 399 → ≤ 60330); above
46
+ * it, the top of the port space. Other bands are listed in stack-isolation.mjs.
47
+ */
48
+ export const TUNNEL_PORT_RANGES = Object.freeze([Object.freeze([60340, 60999]), Object.freeze([65000, 65535])]);
49
+ /** Candidate selection is bounded: at most this many picks per port the tunnel needs. */
50
+ export const TUNNEL_PORT_PICKS = 20;
51
+
52
+ /** A port the tunnel may bind: a valid integer TCP port outside the serving band. */
53
+ export const admissibleTunnelPort = (port) => Number.isInteger(port) && port > 0 && port <= 65535 && !inServingBand(port);
54
+
55
+ /** A uniformly random port from TUNNEL_PORT_RANGES. */
56
+ export function randomTunnelPort(random = Math.random) {
57
+ const total = TUNNEL_PORT_RANGES.reduce((sum, [lo, hi]) => sum + hi - lo + 1, 0);
58
+ let offset = Math.min(Math.floor(random() * total), total - 1);
59
+ for (const [lo, hi] of TUNNEL_PORT_RANGES) {
60
+ if (offset <= hi - lo) return lo + offset;
61
+ offset -= hi - lo + 1;
62
+ }
63
+ return TUNNEL_PORT_RANGES[0][0];
64
+ }
27
65
 
28
66
  export const SSH_FORWARD_OPTIONS = Object.freeze([
29
67
  "-N", "-T",
@@ -91,11 +129,23 @@ function validPort(value) {
91
129
  return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
92
130
  }
93
131
 
132
+ /** BOT-2503: `-R` binds the callback number on the host and the caller's service holds it here,
133
+ * so a serving-band callback port is refused — never silently renumbered under its service. */
134
+ function assertCallbackPortAdmissible(callbackPort) {
135
+ if (validPort(callbackPort) != null && inServingBand(callbackPort)) {
136
+ throw tunnelError("CALLBACK_PORT_RESERVED",
137
+ `--callback-port ${callbackPort} is inside the leased serving band ${SERVING_BAND_START}-${SERVING_BAND_END - 1}, where co-located stacks bind. ` +
138
+ `Serve the callback on a port outside that band (for example ${TUNNEL_PORT_RANGES[0][0]}-${TUNNEL_PORT_RANGES[0][1]}) and pass that port.`);
139
+ }
140
+ }
141
+
94
142
  /** Every stack port the child may touch: api + db always, then `connection.ports[]`. */
95
143
  export function remotePortsFromConnection(connection) { return canonicalStackPorts(connection); }
96
144
 
97
- /** True when 127.0.0.1:<port> can be bound right now. */
145
+ /** True when 127.0.0.1:<port> can be bound right now. A serving-band or invalid port is never
146
+ * free to the tunnel, and is not bound to find out (BOT-2503). */
98
147
  export function isLocalPortFree(port) {
148
+ if (!admissibleTunnelPort(port)) return Promise.resolve(false);
99
149
  return new Promise((resolve) => {
100
150
  const server = createServer();
101
151
  server.once("error", () => resolve(false));
@@ -103,31 +153,27 @@ export function isLocalPortFree(port) {
103
153
  });
104
154
  }
105
155
 
106
- /** An ephemeral port the kernel considers free on 127.0.0.1. */
107
- export function pickFreeLocalPort() {
108
- return new Promise((resolve, reject) => {
109
- const server = createServer();
110
- server.once("error", reject);
111
- server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
112
- const { port } = server.address();
113
- server.close(() => resolve(port));
114
- });
115
- });
156
+ /** One explicit out-of-band candidate, returned when 127.0.0.1 can bind it right now, else null.
157
+ * Never listen(0): the kernel's ephemeral range covers the serving band (BOT-2503). */
158
+ export async function pickFreeLocalPort({ random = Math.random, isFree = isLocalPortFree } = {}) {
159
+ const candidate = randomTunnelPort(random);
160
+ return await isFree(candidate) ? candidate : null;
116
161
  }
117
162
 
118
163
  /**
119
- * Decide each forward's local port: the remote number when it is free (and not in
120
- * `avoid`), otherwise a fresh ephemeral port. Returns the remap table.
164
+ * Decide each forward's local port: the remote number when it is outside the serving band, free
165
+ * and not in `avoid`, otherwise a fresh out-of-band port (at most TUNNEL_PORT_PICKS picks; an
166
+ * invalid, in-band, used or avoided pick is discarded). Returns the remap table.
121
167
  */
122
168
  export async function planForwards(remotePorts, { isFree = isLocalPortFree, pickFree = pickFreeLocalPort, avoid = new Set() } = {}) {
123
169
  const used = new Set();
124
170
  const plan = [];
125
171
  for (const { name, port } of remotePorts) {
126
172
  let local = null;
127
- if (!avoid.has(port) && !used.has(port) && await isFree(port)) local = port;
128
- for (let tries = 0; local == null && tries < 20; tries++) {
173
+ if (admissibleTunnelPort(port) && !avoid.has(port) && !used.has(port) && await isFree(port)) local = port;
174
+ for (let tries = 0; local == null && tries < TUNNEL_PORT_PICKS; tries++) {
129
175
  const candidate = await pickFree();
130
- if (!used.has(candidate) && !avoid.has(candidate)) local = candidate;
176
+ if (admissibleTunnelPort(candidate) && !used.has(candidate) && !avoid.has(candidate)) local = candidate;
131
177
  }
132
178
  if (local == null) throw tunnelError("REMOTE_UNREACHABLE", `could not find a free local port to forward ${name} (${port})`);
133
179
  used.add(local);
@@ -140,6 +186,16 @@ export async function planForwards(remotePorts, { isFree = isLocalPortFree, pick
140
186
  export function buildSshForwardArgv({ sshTarget, forwards, callbackPort = null }) {
141
187
  // `-v` only with a callback forward: OpenSSH then logs whether the host GRANTED this session's
142
188
  // `-R` (see openSshForwards `confirmRemoteForward`).
189
+ // BOT-2503: the last line before ssh binds anything — a caller that skipped planForwards still
190
+ // cannot bind into the serving band. Remote destinations are the stack's own ports, unchanged.
191
+ if (callbackPort != null) assertCallbackPortAdmissible(callbackPort);
192
+ for (const f of forwards) {
193
+ if (!admissibleTunnelPort(f.local) || validPort(f.remote) == null) {
194
+ throw tunnelError("REMOTE_UNREACHABLE",
195
+ `refusing to forward ${f.name ?? "a stack port"} from local port ${f.local}: a tunnel never binds an invalid port or one in the ` +
196
+ `leased serving band ${SERVING_BAND_START}-${SERVING_BAND_END - 1}`);
197
+ }
198
+ }
143
199
  const argv = [...(callbackPort != null ? ["-v"] : []), ...SSH_FORWARD_OPTIONS];
144
200
  for (const f of forwards) argv.push("-L", `127.0.0.1:${f.local}:127.0.0.1:${f.remote}`);
145
201
  if (callbackPort != null) argv.push("-R", `${REVERSE_BIND}:${callbackPort}:127.0.0.1:${callbackPort}`);
@@ -336,9 +392,10 @@ export function verifyCallbackLoopback({ sshBin, sshTarget, port, env = process.
336
392
  }
337
393
 
338
394
 
339
- /** A random throwaway port in the dynamic range (49152–65535) for the GatewayPorts probe. */
395
+ /** A random throwaway host port for the GatewayPorts probe, outside the serving band (BOT-2503:
396
+ * the serving host runs leased stacks in that band too). */
340
397
  export function randomProbePort(random = Math.random) {
341
- return 49152 + Math.floor(random() * (65535 - 49152 + 1));
398
+ return randomTunnelPort(random);
342
399
  }
343
400
 
344
401
  const REMOTE_FORWARD_BUSY = /remote port forwarding failed|address already in use|cannot listen/i;
@@ -349,11 +406,27 @@ export const DEFAULT_PROBE_READY_MS = 10_000;
349
406
  * a listener that closes every connection without a byte — so no other local service can bind it
350
407
  * while a (possibly wildcard) probe listener on the host forwards there. Resolves { port, close }.
351
408
  */
352
- export function holdSilentLocalPort() {
409
+ export async function holdSilentLocalPort({ pickPort = randomTunnelPort, listen = listenSilent } = {}) {
410
+ // BOT-2503: an explicit out-of-band port, never listen(0); a lost bind race picks again.
411
+ for (let tries = 0; tries < TUNNEL_PORT_PICKS; tries++) {
412
+ const port = pickPort();
413
+ if (!admissibleTunnelPort(port)) continue;
414
+ try {
415
+ return await listen(port);
416
+ } catch (error) {
417
+ if (error?.code !== "EADDRINUSE") throw error;
418
+ }
419
+ }
420
+ throw tunnelError("CALLBACK_NOT_LOOPBACK",
421
+ `the GatewayPorts probe could not hold a local port outside the serving band after ${TUNNEL_PORT_PICKS} picks, so no -R for ` +
422
+ "--callback-port was opened.");
423
+ }
424
+
425
+ function listenSilent(port) {
353
426
  return new Promise((resolve, reject) => {
354
427
  const server = createServer((socket) => socket.destroy());
355
428
  server.once("error", reject);
356
- server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
429
+ server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
357
430
  resolve({ port: server.address().port, close: () => new Promise((done) => server.close(() => done())) });
358
431
  });
359
432
  });
@@ -380,8 +453,13 @@ export async function probeGatewayPorts({
380
453
  const tried = new Set();
381
454
  let busyTail = "";
382
455
  for (let attempt = 1; attempt <= attempts; attempt++) {
383
- let port = pickProbePort();
384
- for (let i = 0; tried.has(port) && i < 20; i++) port = pickProbePort();
456
+ // BOT-2503: an invalid or serving-band pick never reaches ssh; at most TUNNEL_PORT_PICKS picks.
457
+ let port = null;
458
+ for (let i = 0; port == null && i < TUNNEL_PORT_PICKS; i++) {
459
+ const pick = pickProbePort();
460
+ if (admissibleTunnelPort(pick) && !tried.has(pick)) port = pick;
461
+ }
462
+ if (port == null) { busyTail = `no admissible probe port outside the serving band in ${TUNNEL_PORT_PICKS} picks`; continue; }
385
463
  tried.add(port);
386
464
  // P must be free on the host BEFORE the probe, or a listener seen afterwards could be someone
387
465
  // else's (a loopback one would pass a host that would expose ours). Taken → another port.
@@ -474,6 +552,11 @@ export async function establishSshTunnel({
474
552
  runRemote = spawnSync, probe = probeGatewayPorts, probeReadyTimeoutMs = DEFAULT_PROBE_READY_MS,
475
553
  }) {
476
554
  const target = assertSshTarget(sshTarget, hostKey);
555
+ // BOT-2503: before any probe, availability check or spawn.
556
+ if (callbackPort != null) assertCallbackPortAdmissible(callbackPort);
557
+ // The native callback route (BOT-2775) passes the caller's callback port here instead: its
558
+ // service holds that number on this machine, so it is refused in the band the same way.
559
+ for (const port of reserveLocalPorts) assertCallbackPortAdmissible(port);
477
560
  const remotePorts = remotePortsFromConnection(connection);
478
561
  const bin = sshBin || env.BOTBUDDY_STACK_SSH_BIN || "ssh";
479
562
  // BOT-2184: prove the host keeps -R listeners on loopback BEFORE the real callback forward
package/src/stack.mjs CHANGED
@@ -271,7 +271,8 @@ ${bold("run OPTIONS")}
271
271
  stack. run uploads the materialised stack as a digest-pinned bundle, waits
272
272
  for active, forwards every stack port over one \`ssh -N -L\` to the host's
273
273
  ssh_target (transport ssh-forward), and gives the child a connection file
274
- on the LOCAL ports (same numbers when free, else remapped; see "forwards").
274
+ on the LOCAL ports (same numbers when free and outside the leased
275
+ serving band 61000-64999, else remapped; see "forwards").
275
276
  --local-only Require a co-located host (execution "local"); no bundle is built.
276
277
  Default (neither flag): execution "auto" — co-located if it admits now,
277
278
  else a remote host with capacity; the bundle digest is sent up front.
@@ -1079,18 +1080,24 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
1079
1080
  // same. A busy port is never retargeted: a new origin makes a new variant, which the
1080
1081
  // control plane reprovisions through its canonical reap instead of re-adopting.
1081
1082
  previousPort = nonce == null ? idleOriginPort(worktreeRoot, stableDir) : null;
1083
+ // BOT-2503: the caller's callback service binds --callback-port later; an origin never takes it.
1084
+ const originAvoid = opts.callbackPort != null ? [opts.callbackPort] : [];
1082
1085
  if (previousPort != null) {
1083
1086
  try {
1084
- requesterOrigin = await reserveOrigin({ context: originContext, port: previousPort });
1087
+ requesterOrigin = await reserveOrigin({ context: originContext, port: previousPort, avoidPorts: originAvoid });
1085
1088
  originContinuation = "previous";
1086
1089
  } catch (error) {
1087
- if (error?.code !== "REQUESTER_ORIGIN_PORT_BUSY") throw error;
1088
- originContinuation = "previous_port_busy";
1089
- warn(`stack: the idle stack's requester origin 127.0.0.1:${previousPort} is in use on this machine; reserving a new origin (the idle stack is reprovisioned, never retargeted)`);
1090
+ // BOT-2503: an origin left in the leased serving band (by a CLI before this guard) is
1091
+ // treated like a busy one: a new origin, and the idle stack is reprovisioned.
1092
+ if (error?.code !== "REQUESTER_ORIGIN_PORT_BUSY" && error?.code !== "REQUESTER_ORIGIN_PORT_RESERVED") throw error;
1093
+ originContinuation = error.code === "REQUESTER_ORIGIN_PORT_RESERVED" ? "previous_port_reserved" : "previous_port_busy";
1094
+ warn(error.code === "REQUESTER_ORIGIN_PORT_RESERVED"
1095
+ ? `stack: the idle stack's requester origin 127.0.0.1:${previousPort} is in the leased serving band; reserving a new origin (the idle stack is reprovisioned, never retargeted)`
1096
+ : `stack: the idle stack's requester origin 127.0.0.1:${previousPort} is in use on this machine; reserving a new origin (the idle stack is reprovisioned, never retargeted)`);
1090
1097
  }
1091
1098
  }
1092
1099
  if (!requesterOrigin) {
1093
- requesterOrigin = await reserveOrigin({ context: originContext });
1100
+ requesterOrigin = await reserveOrigin({ context: originContext, avoidPorts: originAvoid });
1094
1101
  originContinuation ??= "new";
1095
1102
  }
1096
1103
  // Validate the source config before anything is materialized: an explicit conflicting
@@ -5330,7 +5337,7 @@ export function runReceiptFields(result) {
5330
5337
  ...(result.callbackOutage ? { callback_outage: result.callbackOutage } : {}),
5331
5338
  // BOT-2780: the reserved requester origin generated auth links name (never a credential).
5332
5339
  ...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
5333
- // BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy" or "new".
5340
+ // BOT-2788: "previous" (an idle stack's origin re-reserved), "previous_port_busy", "previous_port_reserved" (BOT-2503: in the serving band) or "new".
5334
5341
  ...(result.execution?.requesterOriginContinuation ? { requester_origin_continuation: result.execution.requesterOriginContinuation } : {}),
5335
5342
  ...(result.allocationRecovery ? { allocation_recovery: result.allocationRecovery } : {}),
5336
5343
  ...(result.execution?.forwards ? { forwards: result.execution.forwards } : {}),
@@ -20,6 +20,7 @@
20
20
  // runs that copy).
21
21
 
22
22
  import { execFile } from "node:child_process";
23
+ import { createHash } from "node:crypto";
23
24
  import * as nodeFs from "node:fs/promises";
24
25
  import { readFileSync, readdirSync, realpathSync } from "node:fs";
25
26
  import { homedir, userInfo } from "node:os";
@@ -41,6 +42,7 @@ import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, installLine, isKnownCode
41
42
  import { RegistryError, choosePool, fetchRegisteredPools, whoamiTenant } from "./registry.mjs";
42
43
  import { STAGING_POOL_HELP } from "./help.mjs";
43
44
  import { refreshExistingRuntime } from "./refresh-runtime.mjs";
45
+ import { enqueueStagingPoolTake, flushStagingPoolTakeOutbox, STAGING_POOL_TAKE_OUTBOX_MAX } from "./take-outbox.mjs";
44
46
 
45
47
  export const BUNDLE_FILES = Object.freeze([
46
48
  "binding.mjs",
@@ -58,6 +60,7 @@ export const BUNDLE_FILES = Object.freeze([
58
60
  "refresh-runtime.mjs",
59
61
  "signin-gotrue-totp.mjs",
60
62
  "state-file.mjs",
63
+ "take-outbox.mjs",
61
64
  "totp-botbuddy.mjs",
62
65
  ]);
63
66
 
@@ -263,6 +266,8 @@ export async function takeForLane({
263
266
  fetchImpl = globalThis.fetch,
264
267
  exec = promisify(execFile),
265
268
  uid = userInfo().uid,
269
+ env = process.env,
270
+ sessionId = env.BOTBUDDY_SESSION_ID,
266
271
  logger = () => {},
267
272
  } = {}) {
268
273
  const ctx = resolveInstalledPool({ home, poolId, tenantHint });
@@ -271,8 +276,44 @@ export async function takeForLane({
271
276
  if (!entry) assertPersonaAllowed(target, persona);
272
277
  const intervalSeconds = ctx.installed && !ctx.installed.error ? ctx.installed.intervalSeconds : undefined;
273
278
  const pool = buildPool({ ctx, target, now, logger, intervalSeconds, fetchImpl });
279
+ // Re-deliver earlier durable receipts before admitting another take. Otherwise
280
+ // a temporary MCP outage could fill the bounded outbox permanently even after
281
+ // the control plane recovers.
282
+ const priorDelivery = await flushStagingPoolTakeOutbox({
283
+ poolDir: ctx.paths.pool,
284
+ botbuddyBin: ctx.installed.botbuddyBin,
285
+ exec,
286
+ env,
287
+ sessionId,
288
+ });
289
+ if (priorDelivery.pending >= STAGING_POOL_TAKE_OUTBOX_MAX) {
290
+ throw Object.assign(new Error("The staging-pool take telemetry outbox is full; no session was handed over."), { code: "take_outbox_full" });
291
+ }
274
292
  try {
275
293
  const taken = await pool.take({ persona: entry.email, owner });
294
+ try {
295
+ // Persist before handing the browser its state. An unavailable control
296
+ // plane leaves a replayable intent; it never loses the take event.
297
+ await enqueueStagingPoolTake({
298
+ poolDir: ctx.paths.pool,
299
+ poolId: ctx.poolId,
300
+ personaAlias: entry.alias,
301
+ // Local slot names contain a persona-derived prefix and timestamp. The
302
+ // server needs replay correlation only, so persist a one-way opaque id.
303
+ slotId: createHash("sha256").update(taken.slotId).digest("hex"),
304
+ sessionId,
305
+ });
306
+ await flushStagingPoolTakeOutbox({
307
+ poolDir: ctx.paths.pool,
308
+ botbuddyBin: ctx.installed.botbuddyBin,
309
+ exec,
310
+ env,
311
+ sessionId,
312
+ });
313
+ } catch (telemetryError) {
314
+ await pool.discardTaken(taken.slotId).catch(() => {});
315
+ throw telemetryError;
316
+ }
276
317
  await requestRefill({ ctx, exec, uid });
277
318
  return { taken, target, binding: ctx.poolBinding, persona: entry, discard: () => pool.discardTaken(taken.slotId) };
278
319
  } catch (error) {
@@ -129,6 +129,26 @@ export const ERROR_CODES = Object.freeze({
129
129
  "Taking a session from the pool failed unexpectedly (see detail).",
130
130
  "A filesystem or pool error outside the known cases.",
131
131
  `${AUTH} # once; if it repeats, report this code and detail`, "retry_once"),
132
+ take_outbox_unreadable: entry("human", "repair",
133
+ "The durable staging-pool take receipt cannot be read, so no browser session was handed over.",
134
+ "The installed pool's receipt journal is unreadable or was edited outside the CLI.",
135
+ "bb staging-pool uninstall && " + INSTALL),
136
+ take_outbox_invalid: entry("human", "repair",
137
+ "The durable staging-pool take receipt is invalid, so no browser session was handed over.",
138
+ "The installed pool's receipt journal has an invalid pool, persona, or opaque slot record.",
139
+ "bb staging-pool uninstall && " + INSTALL),
140
+ take_outbox_full: entry("agent", "take",
141
+ "The durable staging-pool take receipt journal is full, so no browser session was handed over.",
142
+ "The control plane has not acknowledged prior secret-free take receipts.",
143
+ "bb staging-pool status --check # report this code; do not retry in a loop", "report_blocker"),
144
+ take_outbox_locked: entry("agent", "take",
145
+ "Another staging-pool take is recording its durable receipt, so no browser session was handed over.",
146
+ "A concurrent take still owns the short receipt-journal mutation lock.",
147
+ `${AUTH} # retry once; if it repeats, report this code`, "retry_once"),
148
+ take_outbox_session_required: entry("agent", "take",
149
+ "The staging-pool take has no registered work-session identity, so no browser session was handed over.",
150
+ "The agent session was not registered or its work-session environment was lost before the take began.",
151
+ "bb setup # restore this worktree's registered session, then retry once", "retry_once"),
132
152
 
133
153
  // ── the pool's health (human repairs; agents report once and stop)
134
154
  // BOT-2836: a healthy pool emptied between ticks is a wait, not a repair. The take
@@ -0,0 +1,151 @@
1
+ // BOT-2934: the local take is not handed to a browser until its secret-free
2
+ // telemetry intent is atomically durable. Delivery is replayable by take_id;
3
+ // the MCP server, never this file, derives tenant/agent/work-session context.
4
+ import * as fs from "node:fs/promises";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { join } from "node:path";
7
+
8
+ const FILE = "take-outbox.json";
9
+ const LOCK_FILE = "take-outbox.lock";
10
+ export const STAGING_POOL_TAKE_OUTBOX_MAX = 64;
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const SESSION_RE = UUID_RE;
13
+ const LOCK_ATTEMPTS = 50;
14
+ const LOCK_RETRY_MS = 20;
15
+ const LOCK_STALE_MS = 30_000;
16
+
17
+ function valid(event) {
18
+ return event && typeof event === "object" &&
19
+ UUID_RE.test(event.take_id ?? "") &&
20
+ /^[a-z0-9][a-z0-9-]{0,63}$/.test(event.pool_id ?? "") &&
21
+ /^[a-z0-9][a-z0-9-]{0,39}$/.test(event.persona_alias ?? "") &&
22
+ /^[a-f0-9]{12,64}$/.test(event.slot_id ?? "") &&
23
+ SESSION_RE.test(event.origin_session_id ?? "") &&
24
+ /^[a-f0-9]{64}$/.test(event.origin_session_hash ?? "");
25
+ }
26
+
27
+ function sessionHash(sessionId) {
28
+ if (typeof sessionId !== "string" || !SESSION_RE.test(sessionId)) {
29
+ throw Object.assign(new Error("A registered BotBuddy work session is required to record a staging-pool take."), { code: "take_outbox_session_required" });
30
+ }
31
+ return createHash("sha256").update(sessionId.toLowerCase()).digest("hex");
32
+ }
33
+
34
+ const pathFor = (poolDir) => join(poolDir, FILE);
35
+ const lockPathFor = (poolDir) => join(poolDir, LOCK_FILE);
36
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+
38
+ async function withOutboxLock(poolDir, action) {
39
+ await fs.mkdir(poolDir, { recursive: true, mode: 0o700 });
40
+ const lockPath = lockPathFor(poolDir);
41
+ const token = randomUUID();
42
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
43
+ let handle;
44
+ try {
45
+ handle = await fs.open(lockPath, "wx", 0o600);
46
+ await handle.writeFile(JSON.stringify({ token, created_at: new Date().toISOString() }) + "\n");
47
+ await handle.sync();
48
+ try {
49
+ return await action();
50
+ } finally {
51
+ await handle.close();
52
+ try {
53
+ const holder = JSON.parse(await fs.readFile(lockPath, "utf8"));
54
+ if (holder?.token === token) await fs.rm(lockPath, { force: true });
55
+ } catch { /* a later holder owns the path, or it is already released */ }
56
+ }
57
+ } catch (error) {
58
+ await handle?.close().catch(() => {});
59
+ if (error?.code !== "EEXIST") throw error;
60
+ try {
61
+ const stat = await fs.stat(lockPath);
62
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
63
+ const tomb = `${lockPath}.stale-${randomUUID()}`;
64
+ await fs.rename(lockPath, tomb);
65
+ await fs.rm(tomb, { force: true });
66
+ continue;
67
+ }
68
+ } catch (lockError) {
69
+ if (lockError?.code !== "ENOENT") throw lockError;
70
+ }
71
+ await pause(LOCK_RETRY_MS);
72
+ }
73
+ }
74
+ throw Object.assign(new Error("Another staging-pool take is recording its durable receipt; no session was handed over."), { code: "take_outbox_locked" });
75
+ }
76
+
77
+ async function read(poolDir) {
78
+ let source;
79
+ try {
80
+ source = await fs.readFile(pathFor(poolDir), "utf8");
81
+ } catch (error) {
82
+ if (error?.code === "ENOENT") return [];
83
+ throw Object.assign(new Error("The staging-pool take outbox is unreadable; no session was handed over."), { code: "take_outbox_unreadable" });
84
+ }
85
+ let parsed;
86
+ try { parsed = JSON.parse(source); }
87
+ catch { throw Object.assign(new Error("The staging-pool take outbox is invalid; no session was handed over."), { code: "take_outbox_invalid" }); }
88
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !Array.isArray(parsed.events) || !parsed.events.every(valid)) {
89
+ throw Object.assign(new Error("The staging-pool take outbox is invalid; no session was handed over."), { code: "take_outbox_invalid" });
90
+ }
91
+ return parsed.events;
92
+ }
93
+
94
+ async function write(poolDir, events) {
95
+ await fs.mkdir(poolDir, { recursive: true, mode: 0o700 });
96
+ const output = JSON.stringify({ version: 1, events: events.slice(-STAGING_POOL_TAKE_OUTBOX_MAX) }) + "\n";
97
+ const temporary = join(poolDir, `.${FILE}.${randomUUID()}.partial`);
98
+ const handle = await fs.open(temporary, "wx", 0o600);
99
+ try {
100
+ await handle.writeFile(output);
101
+ await handle.sync();
102
+ } finally { await handle.close(); }
103
+ try {
104
+ await fs.rename(temporary, pathFor(poolDir));
105
+ const directory = await fs.open(poolDir, "r");
106
+ try { await directory.sync(); } finally { await directory.close(); }
107
+ } catch (error) {
108
+ await fs.rm(temporary, { force: true });
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ export async function enqueueStagingPoolTake({ poolDir, poolId, personaAlias, slotId, sessionId, takeId = randomUUID() }) {
114
+ const event = { take_id: takeId, pool_id: poolId, persona_alias: personaAlias, slot_id: slotId, origin_session_id: sessionId.toLowerCase(), origin_session_hash: sessionHash(sessionId) };
115
+ if (!valid(event)) throw Object.assign(new Error("The staging-pool take telemetry intent is invalid."), { code: "take_outbox_invalid" });
116
+ return withOutboxLock(poolDir, async () => {
117
+ const events = await read(poolDir);
118
+ if (events.length >= STAGING_POOL_TAKE_OUTBOX_MAX) throw Object.assign(new Error("The staging-pool take telemetry outbox is full; no session was handed over."), { code: "take_outbox_full" });
119
+ events.push(event);
120
+ await write(poolDir, events);
121
+ return event;
122
+ });
123
+ }
124
+
125
+ export async function flushStagingPoolTakeOutbox({ poolDir, botbuddyBin, exec, env = process.env, sessionId }) {
126
+ const pending = await withOutboxLock(poolDir, () => read(poolDir));
127
+ const originSessionHash = sessionHash(sessionId);
128
+ const delivered = new Set();
129
+ for (let index = 0; index < pending.length; index += 1) {
130
+ const sameSession = pending[index].origin_session_hash === originSessionHash;
131
+ let body;
132
+ try {
133
+ const { origin_session_hash: _originSessionHash, origin_session_id, ...payload } = pending[index];
134
+ const tool = sameSession ? "record_staging_pool_take" : "reconcile_staging_pool_take";
135
+ const bodyPayload = sameSession ? payload : { ...payload, origin_session_id };
136
+ const result = await exec(botbuddyBin, ["call", tool, "--json", JSON.stringify(bodyPayload)], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
137
+ body = JSON.parse(String(result?.stdout ?? ""));
138
+ } catch { continue; }
139
+ if (body?.success === true) delivered.add(pending[index].take_id);
140
+ }
141
+ if (!delivered.size) return { delivered: 0, pending: pending.length };
142
+ const remaining = await withOutboxLock(poolDir, async () => {
143
+ const current = await read(poolDir);
144
+ const next = current.filter((event) => !delivered.has(event.take_id));
145
+ if (next.length !== current.length) await write(poolDir, next);
146
+ return next.length;
147
+ });
148
+ return { delivered: delivered.size, pending: remaining };
149
+ }
150
+
151
+ export const stagingPoolTakeOutboxPath = pathFor;
package/src/wait-core.mjs CHANGED
@@ -140,6 +140,31 @@ function parseParams(raw) {
140
140
  return params;
141
141
  }
142
142
 
143
+ function parseStagingPoolParams(raw) {
144
+ const params = {};
145
+ for (const entry of String(raw ?? "").split(",").map((part) => part.trim())) {
146
+ if (!entry) continue;
147
+ const named = /^(pool|persona)=([^=,]+)$/.exec(entry);
148
+ const readiness = /^ready>=([0-9]+)$/.exec(entry);
149
+ const key = named?.[1] ?? (readiness ? "ready" : undefined);
150
+ const value = named?.[2] ?? readiness?.[1];
151
+ if (!key || value === undefined) throw new Error(`staging-pool does not accept ${entry}`);
152
+ if (Object.prototype.hasOwnProperty.call(params, key)) throw new Error(`staging-pool does not accept duplicate ${key}`);
153
+ params[key] = value;
154
+ }
155
+ const identifier = (value, label) => {
156
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(value)) {
157
+ throw new Error(`staging-pool ${label} must be a registered identifier`);
158
+ }
159
+ return value;
160
+ };
161
+ const pool = identifier(params.pool, "pool");
162
+ const persona = identifier(params.persona, "persona");
163
+ const ready = params.ready === undefined ? 1 : Number(params.ready);
164
+ if (!Number.isSafeInteger(ready) || ready < 1) throw new Error("staging-pool ready>= must be a positive integer");
165
+ return { pool, persona, ready };
166
+ }
167
+
143
168
  function asBool(v) {
144
169
  if (v === true) return true;
145
170
  if (typeof v !== "string") return false;
@@ -225,6 +250,10 @@ const VALIDATORS = {
225
250
  if (!CANONICAL_UUID_RE.test(jobId)) throw new Error("staging-pool-refill id must be a canonical UUID");
226
251
  return { jobId };
227
252
  },
253
+ // BOT-2934 — server-side arm authorization resolves the caller's installed
254
+ // instance. The CLI grammar names only its registered pool/persona, never a
255
+ // host, so it cannot turn a different machine's inventory into readiness.
256
+ "staging-pool": (_p, raw) => parseStagingPoolParams(raw),
228
257
  // BOT-2888: a reporter waits for a revision after the last consumed update.
229
258
  // The server binds the report to the registering agent and tenant.
230
259
  feedback: (p, raw) => {
@@ -1320,6 +1349,19 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1320
1349
  signal.subject_key === `staging-pool-refill:${params.jobId}` &&
1321
1350
  signal.payload?.job_id === params.jobId &&
1322
1351
  ["completed", "failed"].includes(signal.payload?.status);
1352
+ case "staging-pool": {
1353
+ const payload = signal.payload;
1354
+ return signal.signal_type === "staging_pool" &&
1355
+ payload?.schema_version === 1 &&
1356
+ typeof payload.instance_id === "string" && payload.instance_id.length > 0 &&
1357
+ signal.subject_key === `staging-pool:${payload.instance_id}:${payload.persona_alias}` &&
1358
+ payload.pool_id === params.pool &&
1359
+ payload.persona_alias === params.persona &&
1360
+ Number.isSafeInteger(payload.epoch) && payload.epoch >= 1 &&
1361
+ Number.isSafeInteger(payload.revision) && payload.revision >= 1 &&
1362
+ Number.isSafeInteger(payload.ready_count) && payload.ready_count >= params.ready &&
1363
+ payload.availability === "ready" && payload.freshness_phase === "fresh";
1364
+ }
1323
1365
  case "feedback":
1324
1366
  return signal.signal_type === "feedback_update" &&
1325
1367
  signal.subject_key === `feedback:${params.feedbackId}` &&
package/src/wait.mjs CHANGED
@@ -175,6 +175,10 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
175
175
  completed or failed. This is the only condition of its
176
176
  wait and is available to each tenant agent that requested
177
177
  the deduped job; its terminal receipt is safe to display.
178
+ staging-pool:pool=<id>,persona=<alias>[,ready>=N]
179
+ Wait for N (default 1) fresh, takeable sessions on the
180
+ caller's authorized installed pool instance. This does not
181
+ reserve a session: after a match, run the auth command once.
178
182
  feedback:id=<feedback UUID>,after=<revision>
179
183
  Wait for a later update to your own feedback; use the
180
184
  revision you last consumed and read/acknowledge updates.
@@ -663,6 +667,8 @@ async function registerWait(opts, conditions, deadlineIso) {
663
667
  "command_run_single_condition_required",
664
668
  "staging_pool_refill_unavailable",
665
669
  "staging_pool_refill_single_condition_required",
670
+ "staging_pool_unavailable",
671
+ "staging_pool_single_condition_required",
666
672
  "feedback_wait_unavailable",
667
673
  ]);
668
674
  if (INVALID_CONDITION_CODES.has(body.error)) {