@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.
package/src/stack.mjs CHANGED
@@ -33,7 +33,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from "fs";
33
33
  import { open, unlink, readFile } from "fs/promises";
34
34
  import { homedir, tmpdir } from "os";
35
35
  import { basename, dirname, join, relative, isAbsolute } from "path";
36
- import { randomUUID } from "crypto";
36
+ import { randomUUID, createHash } from "crypto";
37
37
  import { fileURLToPath } from "url";
38
38
  import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
39
39
  import { deepLinkNotice, safeWebUrl } from "./deep-link.mjs";
@@ -43,7 +43,10 @@ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
43
43
  import { resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
44
44
  import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
45
45
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
46
- import { LEASED_STACK_IDLE_RETENTION_SECS, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
46
+ import { LEASED_STACK_IDLE_RETENTION_SECS, assertFreshRunStackAvailable, recordAdmissionRequest, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
47
+ import { reserveRequesterOrigin } from "./stack-requester-origin.mjs";
48
+ import { identifiedAdmissionAck, identifiedAdmissionDelivery, identifiedAdmissionRecovery } from "./stack-identified-admission.mjs";
49
+ import { patchRequesterAuthOrigin } from "./stack-requester-origin-config.mjs";
47
50
  import { measureStackUsage } from "./stack-usage.mjs";
48
51
  import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
49
52
  import { machineUuid } from "./machine-id.mjs";
@@ -57,6 +60,20 @@ import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
57
60
 
58
61
  export const STACK_SCHEMA_VERSION = 1;
59
62
 
63
+ /**
64
+ * BOT-2780: only `stack run --remote --execution-ring beta --reap` reserves a requester
65
+ * origin and allocates through BOT-2499's identified admission. The origin cannot outlive
66
+ * its CLI, so the invocation must own cleanup; stable/auto/local and idle runs are unchanged.
67
+ */
68
+ export const requesterOriginEnabled = (opts) => opts.execution === "remote" && opts.executionRing === "beta" && opts.reap === true;
69
+ // Exact replays of one identified request after an uncertain answer (lost response, 5xx,
70
+ // ADMISSION_UNCERTAIN, pending delivery). The same request_id can never create a second lease.
71
+ const ADMISSION_REPLAY_DELAYS_MS = [1000, 2000, 4000];
72
+ const ADMISSION_REPLAYABLE = new Set(["ADMISSION_UNCERTAIN", "RETRY_SAME_REQUEST", "HOST_ELIGIBILITY_BUSY"]);
73
+ // The key already has a ledger row (or had one): never replay it blindly or treat it as unallocated.
74
+ const ADMISSION_COMMITTED_UNCERTAIN = new Set(["REQUEST_IDENTITY_MISMATCH", "REQUEST_CLOSED", "ORIGINAL_LEASE_UNAVAILABLE"]);
75
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
76
+
60
77
  /**
61
78
  * Codex #1077 P1 / BOT-2184: an sshd with `GatewayPorts yes` binds a `-R` forward on every
62
79
  * interface. A remote lease with --callback-port therefore runs the pre-forward GatewayPorts
@@ -119,6 +136,8 @@ export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch
119
136
 
120
137
  ${bold("USAGE")}
121
138
  botbuddy stack up [options] Request a lease; park if the host is full; hold it active
139
+ botbuddy stack status --admission-request-id <uuid>
140
+ Read an identified admission after a lost response (BOT-2780)
122
141
  botbuddy stack status <lease_id> Show a lease's state + connection; within a live remote run,
123
142
  include the owner's attested local/remote forwards
124
143
  botbuddy stack touch <lease_id> Bump the idle clock (and report measured use) so the reaper doesn't stop it
@@ -233,6 +252,12 @@ ${bold("run OPTIONS")}
233
252
  else a remote host with capacity; the bundle digest is sent up front.
234
253
  --execution-ring R Capacity rollout ring: stable (default) excludes beta daemon hosts.
235
254
  beta is explicit opt-in and requires \`stack run --remote\`.
255
+ With --remote --execution-ring beta --reap (BOT-2780), run reserves a
256
+ loopback requester origin first and writes it as the private stack copy's
257
+ [auth] external_url, so generated auth links (/auth/v1/verify…) reach the
258
+ stack unchanged even when the SSH API forward is remapped; the child gets
259
+ it as connection.requester_origin. The lease is requested through the
260
+ identified admission (exact replay after a lost response, no fallback).
236
261
  --callback-port N The caller runs a listener the stack calls back through
237
262
  host.docker.internal:N (e.g. 4600): sent as callback_port. On a REMOTE
238
263
  lease run first probes the host with a throwaway reverse forward and opens
@@ -421,6 +446,7 @@ export function parseStackArgs(argv) {
421
446
  for (let i = 0; i < rest.length; i++) {
422
447
  const a = rest[i];
423
448
  switch (a) {
449
+ case "--admission-request-id": if (need(a, rest[i + 1])) opts.admissionRequestId = String(rest[++i]).toLowerCase(); break;
424
450
  case "--slot": if (need(a, rest[i + 1])) opts.slot = rest[++i]; break;
425
451
  case "--host": if (need(a, rest[i + 1])) opts.host = rest[++i]; break;
426
452
  case "--repo": if (need(a, rest[i + 1])) opts.repo = rest[++i]; break;
@@ -588,7 +614,11 @@ export function parseStackArgs(argv) {
588
614
  errors.push("--stack-path must be a non-empty relative path without '..'");
589
615
  }
590
616
  const leaseId = positionals[0] ?? null;
591
- if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
617
+ if (opts.admissionRequestId != null && (!UUID_RE.test(opts.admissionRequestId) ||
618
+ !((command === "status" && !leaseId) || (command === "done" && leaseId && opts.reap && opts.releasePortClaim)))) {
619
+ errors.push("--admission-request-id takes a request UUID with `status` (no <lease_id>) or `done <lease_id> --reap --release-port-claim`");
620
+ }
621
+ if (command && ["status", "touch", "done"].includes(command) && !leaseId && !(command === "status" && opts.admissionRequestId)) {
592
622
  errors.push(`${command} needs a <lease_id>`);
593
623
  }
594
624
  if (["up", "done"].includes(command) && opts.localExec && Boolean(opts.dockerContext) === Boolean(opts.dockerEndpoint)) {
@@ -865,6 +895,16 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
865
895
  }
866
896
  return execution;
867
897
  };
898
+ // BOT-2780: the requester origin is only for a fresh CLI-managed stack context. A
899
+ // prepared path, pinned host or fixed nonce could resume a stack whose generated links
900
+ // already name another invocation's listener.
901
+ const originRun = requesterOriginEnabled(opts);
902
+ if (originRun && (opts.host != null || opts.stackNonce != null || (opts.stackPath && opts.stackPath !== ".") ||
903
+ !UUID_RE.test(opts.admissionRequestId ?? "") || !opts.requesterMachineId)) {
904
+ const error = new Error("beta requester origins require a fresh CLI-managed stack context (no --host, --stack-path or fixed nonce)");
905
+ error.code = "REQUESTER_ORIGIN_CONTEXT_REFUSED";
906
+ throw error;
907
+ }
868
908
  // An explicitly prepared directory is the caller's to manage, but it is NOT
869
909
  // exempt from the isolation contract (BOT-1798, Codex #928 R8 P1): a copied root
870
910
  // config — same project_id, or merely the same ports — would make the Helper
@@ -930,15 +970,30 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
930
970
  const nonce = opts.stackNonce ?? (stableReusable(worktreeRoot, stableDir) ? null : randomUUID());
931
971
  const { projectId } = identity({ worktreeRoot, slot: opts.slot, ticket: opts.ticket, nonce });
932
972
  const held = await claimProjectLock(projectId, lock, `this batch's isolated stack (${projectId})`);
973
+ let requesterOrigin = null;
974
+ let originContext = null;
933
975
  try {
976
+ let configText;
977
+ if (originRun) {
978
+ // BOT-2780: refuse a retained slot context, then reserve the listener BEFORE the
979
+ // private config (and so the bundle digest) is derived. The source config is never
980
+ // changed; only the private materialized copy names the reserved origin.
981
+ assertFreshRunStackAvailable(worktreeRoot, opts.slot);
982
+ originContext = Object.freeze({ request_id: opts.admissionRequestId, worktree: worktreeRoot, slot: opts.slot, machine: opts.requesterMachineId });
983
+ requesterOrigin = await reserveRequesterOrigin({ context: originContext });
984
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- canonical realpath of the current worktree; fixed own source config, never a request-supplied path.
985
+ configText = await patchRequesterAuthOrigin(readFileSync(join(worktreeRoot, "supabase", "config.toml"), "utf8"), requesterOrigin.origin);
986
+ }
934
987
  const execution = refuseRoot(resolveStackPath(cwd, materialize(
935
988
  cwd,
936
989
  { slot: opts.slot, repo: opts.repo, ticket: opts.ticket, nonce, idleTtlSecs: opts.idleTtl ?? undefined },
937
990
  // BOT-2403: a functions directory another writer replaced under this stack is reported.
938
- { ...(portRegistryDir ? { portRegistryDir } : {}), warn },
991
+ { ...(portRegistryDir ? { portRegistryDir } : {}), warn, ...(configText !== undefined ? { configText } : {}) },
939
992
  )));
940
993
  return {
941
994
  ...execution,
995
+ ...(requesterOrigin ? { requesterOrigin, originContext, admissionRequestId: opts.admissionRequestId,
996
+ recordAdmission: (requestId) => recordAdmissionRequest(stackDirFor(execution), requestId, portRegistryDir ? { registryDir: portRegistryDir } : undefined) } : {}),
942
997
  bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
943
998
  // Release is called once the batch's signed reap is done, so hand the host
944
999
  // its port block back immediately rather than holding it for the retention
@@ -951,6 +1006,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
951
1006
  // BOT-2237 (Codex #1168 P2): returns the VERIFIED disposition — whether this stack's
952
1007
  // claim is gone afterwards — so a failed unlink is never reported as released.
953
1008
  release: ({ reaped = false } = {}) => {
1009
+ void requesterOrigin?.close();
954
1010
  const registry = portRegistryDir ? { registryDir: portRegistryDir } : undefined;
955
1011
  let released = false;
956
1012
  if (reaped) {
@@ -964,6 +1020,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
964
1020
  },
965
1021
  };
966
1022
  } catch (error) {
1023
+ await requesterOrigin?.close();
967
1024
  held.release();
968
1025
  throw error;
969
1026
  }
@@ -2999,6 +3056,21 @@ export async function cmdStatus(leaseId, opts, {
2999
3056
  } = {}) {
3000
3057
  const auth = await rpc.init();
3001
3058
  if (!auth) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
3059
+ if (opts.admissionRequestId) {
3060
+ // BOT-2780: the owner-scoped identified-admission read. It never returns a connection or
3061
+ // capability, and an unknown key is not proof that nothing was admitted.
3062
+ const read = await rpc.call("get_stack_lease_admission", { request_id: opts.admissionRequestId });
3063
+ const base = { command: "status", request_id: opts.admissionRequestId };
3064
+ if (!read.ok) return emitResult(buildReceipt({ ...base, outcome: "error", error: read.error }), opts, read.auth ? EXIT.AUTH : EXIT.BACKEND);
3065
+ const a = read.data ?? {};
3066
+ if (a.success !== true) return emitResult(buildReceipt({ ...base, outcome: "error", code: a.code ?? null, error: a.code ?? "admission unreadable" }), opts, EXIT.BACKEND);
3067
+ return emitResult(buildReceipt({
3068
+ ...base, outcome: a.lease_status ?? "unknown", lease_id: a.lease_id ?? null, admission_kind: a.admission_kind ?? null,
3069
+ job_binding: a.job_binding ?? null, provision_job_id: a.provision_job_id ?? null, lease_status: a.lease_status ?? null,
3070
+ closed: a.closed === true, superseded: a.superseded === true, close_reason: a.close_reason ?? null,
3071
+ cleanup_status: a.cleanup_status ?? null, cleanup_evidence: a.cleanup_evidence ?? null,
3072
+ }), opts, 0);
3073
+ }
3002
3074
  const got = await rpc.call("get_stack_lease", { lease_id: leaseId });
3003
3075
  if (!got.ok) return got.auth
3004
3076
  ? emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
@@ -3345,9 +3417,19 @@ export async function cmdDone(leaseId, opts, {
3345
3417
  // provable (cleanup_verified strictly false), ends at once; a raw frame keeps waiting.
3346
3418
  (s, p) => (s === "reap_failed" && p?.cleanup_verified === true) || (s === "reaped" && p?.cleanup_verified === false),
3347
3419
  { timeoutSec: opts.reapTimeout ?? 300, auth: rpc.auth });
3348
- if (verified.woke && verified.cleanupEvidence) {
3420
+ // BOT-2780: a claim bound only to an identified admission request is released only when
3421
+ // the owner-scoped admission read proves that request named this exact lease.
3422
+ let requestBinding = null;
3423
+ if (opts.admissionRequestId) {
3424
+ const admission = await call("get_stack_lease_admission", { request_id: opts.admissionRequestId });
3425
+ requestBinding = admission?.ok && admission.data?.success === true && admission.data.superseded !== true && admission.data.lease_id === leaseId
3426
+ ? { admission_request_id: opts.admissionRequestId } : false;
3427
+ }
3428
+ if (requestBinding === false) {
3429
+ portBlock = { released: false, reason: `admission ${opts.admissionRequestId} does not name lease ${leaseId}; local claim retained` };
3430
+ } else if (verified.woke && verified.cleanupEvidence) {
3349
3431
  state = "reaped";
3350
- portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, verified.cleanupEvidence);
3432
+ portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, { ...verified.cleanupEvidence, ...(requestBinding ?? {}) });
3351
3433
  } else portBlock = { released: false, reason: "accepted cleanup proof is unavailable; local claim retained" };
3352
3434
  }
3353
3435
  } catch (error) {
@@ -3583,6 +3665,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3583
3665
  incidentBox.outcomeChannel = { get auth() { return rpc.auth; }, callTool: adapters.callTool ?? callToolJson };
3584
3666
  const api = adapters.api ?? {
3585
3667
  request: (args) => rpc.call("request_stack_lease", args),
3668
+ // BOT-2780 (beta requester origin): BOT-2499's identified admission, never a fallback.
3669
+ requestIdentified: (args) => rpc.call("request_stack_lease_identified", args),
3670
+ getAdmission: (args) => rpc.call("get_stack_lease_admission", args),
3586
3671
  get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
3587
3672
  // BOT-1803: a heartbeat carries the measured client use when there is one.
3588
3673
  touch: (leaseId, usage) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}) }),
@@ -3675,6 +3760,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3675
3760
  let tunnel = null;
3676
3761
  let forwardAttestation = null;
3677
3762
  let tunnelLost = null;
3763
+ // BOT-2780: the requester origin closed under a live run (SSH loss or forward failure).
3764
+ let originLost = null;
3678
3765
  const placement = { mode: null, hostKey: null, requesterHost: null, transport: "none", forwards: null, bundle: null, placementReason: null, considered: null };
3679
3766
  incidentBox.execution = placement;
3680
3767
  // BOT-1803: idle-lease bookkeeping for the receipt.
@@ -3685,7 +3772,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3685
3772
  let retainBlock = false;
3686
3773
  // BOT-1803: a heartbeat touch found the lease preempted under the running child.
3687
3774
  let preempted = null;
3775
+ // BOT-2780: the requester listener never outlives the run, its tunnel or a reap request.
3776
+ const closeOrigin = async () => { try { await execution?.requesterOrigin?.close(); } catch { /* closed either way */ } };
3688
3777
  const closeTunnel = async () => {
3778
+ await closeOrigin();
3689
3779
  try {
3690
3780
  if (forwardAttestation) {
3691
3781
  const owner = forwardAttestation;
@@ -3711,6 +3801,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3711
3801
  if (!leaseId) return Promise.resolve({ ok: true, state: null });
3712
3802
  if (cleanupPromise) return cleanupPromise;
3713
3803
  cleanupPromise = (async () => {
3804
+ await closeOrigin();
3714
3805
  if (!leaseId) return cleanupResult = { ok: true, state: null };
3715
3806
  const cleanupAbort = new AbortController();
3716
3807
  const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
@@ -3799,6 +3890,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3799
3890
  };
3800
3891
 
3801
3892
  const stopChild = (signal = "SIGTERM", forcedCode = null) => {
3893
+ void closeOrigin();
3802
3894
  if (!child || childResult) return;
3803
3895
  forcedStopCode ??= forcedCode;
3804
3896
  childGroupSignal(child, signal);
@@ -3834,14 +3926,21 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3834
3926
  const slot = deriveSlot(opts);
3835
3927
  // BOT-1798: never "." — materialize/validate an ISOLATED stack dir first.
3836
3928
  try {
3837
- execution = await resolveExecution({ ...opts, slot });
3929
+ execution = await resolveExecution({ ...opts, slot,
3930
+ // BOT-2780: one identified admission key per invocation, held through replays.
3931
+ ...(requesterOriginEnabled(opts) ? { admissionRequestId: randomUUID(), requesterMachineId: hardwareUuid } : {}),
3932
+ });
3933
+ if (requesterOriginEnabled(opts) && (!execution.requesterOrigin || !execution.admissionRequestId || !execution.originContext)) {
3934
+ await closeOrigin();
3935
+ const error = new Error("requester origin context unavailable"); error.code = "REQUESTER_ORIGIN_CONTEXT_REFUSED"; throw error;
3936
+ }
3838
3937
  } catch (error) {
3839
3938
  // A slot whose stack another live batch owns is a coordination refusal (the
3840
3939
  // same class as the server's duplicate-lease rejection), not bad arguments.
3841
3940
  const exitCode = error?.code === "stack_slot_busy" ? EXIT.BACKEND : EXIT.INVALID;
3842
3941
  // BOT-2490: retain listener/cooldown refusals in the existing public code field;
3843
3942
  // keep the exit mapping and unrelated local-error semantics unchanged.
3844
- const code = ["stack_port_census_failed", "stack_port_cooldown_failed"].includes(error?.code) ? error.code : null;
3943
+ const code = ["stack_port_census_failed", "stack_port_cooldown_failed"].includes(error?.code) || String(error?.code ?? "").startsWith("REQUESTER_ORIGIN_") ? error.code : null;
3845
3944
  return { exitCode, outcome: "error", ...(code ? { code } : {}), error: String(error?.message ?? error) };
3846
3945
  }
3847
3946
  // BOT-1798 (Codex #928 R13 P2): the queue and provisioning waits can outlast the
@@ -3936,6 +4035,83 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3936
4035
  ...(bundle?.included?.length ? { bundle_included: bundle.included } : {}),
3937
4036
  ...(opts.callbackPort != null ? { callback_port: opts.callbackPort } : {}),
3938
4037
  };
4038
+ // BOT-2780: the identified admission key and a digest of the immutable origin context.
4039
+ // The intent (including the bundle digest that names the reserved origin) is fingerprinted
4040
+ // server-side; replays of this exact request can never create a second lease.
4041
+ let identifiedVerdict = null;
4042
+ let requestIdentified = null;
4043
+ if (execution.admissionRequestId) {
4044
+ if (!stackDigest || !bundle?.sha256) {
4045
+ return { exitCode: EXIT.INTERNAL, outcome: "error", code: "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE", error: "the requester-origin bundle digest could not be computed; no lease was requested" };
4046
+ }
4047
+ leaseArgs.request_id = execution.admissionRequestId;
4048
+ // Durable request-to-context association before any request can commit (recovery key).
4049
+ try { execution.recordAdmission?.(leaseArgs.request_id); } catch (error) {
4050
+ return { exitCode: EXIT.INTERNAL, outcome: "error", code: "REQUESTER_ORIGIN_CONTEXT_REFUSED", error: `could not record the admission request on this stack's claim (${error?.message ?? error}); no lease was requested` };
4051
+ }
4052
+ leaseArgs.origin_identity_sha256 = createHash("sha256")
4053
+ .update(JSON.stringify({ context: execution.originContext, origin: execution.requesterOrigin.origin })).digest("hex");
4054
+ const replayDelays = adapters.admissionReplayDelaysMs ?? ADMISSION_REPLAY_DELAYS_MS;
4055
+ let replays = 0;
4056
+ let mayHaveCommitted = false;
4057
+ let ownedAck = null;
4058
+ const settle = (code) => {
4059
+ identifiedVerdict = ownedAck ? { kind: "owned", code, ack: ownedAck } : { kind: "uncertain", code, ack: null };
4060
+ };
4061
+ // Returns a legacy-shaped answer: the decorated delivery for a ready ACK, a definite
4062
+ // refusal, or (with identifiedVerdict set) the original answer for the caller to retain.
4063
+ requestIdentified = async () => {
4064
+ for (;;) {
4065
+ let res;
4066
+ try { res = await api.requestIdentified(leaseArgs); } catch (error) { res = { ok: false, status: null, error: String(error?.message ?? error) }; }
4067
+ let replayCode = null;
4068
+ if (!res?.ok) {
4069
+ // A rejected credential, or a readable JSON-RPC protocol error over HTTP 200, was refused
4070
+ // before the tool handler ran: a definite refusal on a first answer. An unreadable body
4071
+ // (`invalid_json`) may follow a commit, so it is replayed like a lost response.
4072
+ if (!mayHaveCommitted && (res?.auth || (res?.status === 200 && res.error !== "invalid_json"))) return res;
4073
+ replayCode = "ADMISSION_UNCERTAIN";
4074
+ } else if (!mayHaveCommitted && typeof res.data?.raw === "string" && /^Unknown tool: request_stack_lease_identified\b/.test(res.data.raw)) {
4075
+ // An older backend without BOT-2499's tool: nothing was admitted, and no fallback.
4076
+ identifiedVerdict = { kind: "unsupported" };
4077
+ return res;
4078
+ } else if (res.data?.success !== true) {
4079
+ const code = typeof res.data?.code === "string" ? res.data.code : "ADMISSION_UNCERTAIN";
4080
+ if (ADMISSION_COMMITTED_UNCERTAIN.has(code)) { settle(code); return res; }
4081
+ if (ADMISSION_REPLAYABLE.has(code)) replayCode = code;
4082
+ // After an answer that may have committed, a later refusal is not proof of absence.
4083
+ else if (mayHaveCommitted) { settle("ADMISSION_UNCERTAIN"); return res; }
4084
+ // A definite, effect-free refusal never carries cleanup authority.
4085
+ else return { ...res, data: { success: false, code, ...(res.data?.message ? { message: res.data.message } : {}),
4086
+ ...(res.data?.phase ? { phase: res.data.phase } : {}), ...(res.data?.wait ? { wait: res.data.wait } : {}) } };
4087
+ } else {
4088
+ const ack = identifiedAdmissionAck(res.data, leaseArgs.request_id);
4089
+ // A malformed or foreign ACK is untrusted: never cleanup authority, never adopted.
4090
+ if (!ack || ack.admission_kind !== "created" || (ownedAck && ack.lease_id !== ownedAck.lease_id)) {
4091
+ mayHaveCommitted = true;
4092
+ if (ack && ownedAck && ack.lease_id !== ownedAck.lease_id) ownedAck = null; // two leases for one key: trust neither
4093
+ settle(!ack || ack.admission_kind === "created" ? "ADMISSION_UNCERTAIN" : "ADMISSION_READOPTION_UNSUPPORTED");
4094
+ return res;
4095
+ }
4096
+ const delivery = identifiedAdmissionDelivery(res.data, ack);
4097
+ if (delivery) return { ...res, data: delivery };
4098
+ if (ack.delivery_status === "ready") { mayHaveCommitted = true; settle("ADMISSION_UNCERTAIN"); return res; }
4099
+ ownedAck = ack;
4100
+ replayCode = "ADMISSION_DELIVERY_PENDING";
4101
+ }
4102
+ mayHaveCommitted = mayHaveCommitted || replayCode !== "HOST_ELIGIBILITY_BUSY";
4103
+ if (replays >= replayDelays.length || receivedSignal) {
4104
+ // Only effect-free busy answers so far: still a definite refusal, nothing to retain.
4105
+ if (!mayHaveCommitted) return { ...res, data: { success: false, code: replayCode } };
4106
+ settle(replayCode);
4107
+ return res;
4108
+ }
4109
+ const delay = replayDelays[replays++];
4110
+ stderrLine(`stack: identified admission ${leaseArgs.request_id} is ${replayCode} — replaying the same request in ${delay}ms`);
4111
+ if (delay > 0) await new Promise((resolve) => { clock.setTimeout(resolve, delay); });
4112
+ }
4113
+ };
4114
+ }
3939
4115
  // The retrying caller (api → rpc.call) transparently renews+retries the
3940
4116
  // FIRST auth rejection among any of request/get/touch/release.
3941
4117
  // BOT-2024: a stale-capacity refusal prints phase-aware guidance; with the opt-in
@@ -3948,11 +4124,51 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3948
4124
  let idleRetried = false;
3949
4125
  for (let attempt = 0; ; attempt++) {
3950
4126
  activeWaitAbort = new AbortController();
3951
- const admission = await requestWithCapacityWait(() => api.request(leaseArgs), opts, {
4127
+ const admission = await requestWithCapacityWait(() => requestIdentified ? requestIdentified() : api.request(leaseArgs), opts, {
3952
4128
  waitCapacity: adapters.waitCapacity ?? defaultWaitCapacity, log: stderrLine, signal: activeWaitAbort.signal,
3953
4129
  });
3954
4130
  activeWaitAbort = null;
3955
4131
  request = admission.req;
4132
+ if (identifiedVerdict) {
4133
+ const verdict = identifiedVerdict;
4134
+ if (verdict.kind === "unsupported") {
4135
+ return { exitCode: EXIT.BACKEND, outcome: "error", code: "IDENTIFIED_ADMISSION_UNSUPPORTED",
4136
+ error: "this backend has no identified stack-lease admission (request_stack_lease_identified); a beta requester-origin run never falls back to the legacy allocator" };
4137
+ }
4138
+ if (verdict.kind === "owned") {
4139
+ // The exact ACK proves this lease is ours, but it never became deliverable: reap it.
4140
+ leaseId = verdict.ack.lease_id;
4141
+ leaseProvisioned = true;
4142
+ execution.bindLease?.(leaseId);
4143
+ const cleanup = await requestCleanup();
4144
+ return { exitCode: receivedSignal ? SIGNAL_EXIT[receivedSignal] : cleanup.ok ? EXIT.BACKEND : EXIT.CLEANUP_FAILED,
4145
+ outcome: receivedSignal ? "interrupted" : "error", code: verdict.code, leaseId, cleanup,
4146
+ error: `identified admission ${leaseArgs.request_id} committed lease ${leaseId} but its delivery never became ready; it was released` };
4147
+ }
4148
+ // Possibly committed with no trustworthy ACK. One owner-scoped admission read can still
4149
+ // prove which lease this exact request created: then it is ours to reap normally.
4150
+ let observed = null;
4151
+ try { observed = await api.getAdmission?.({ request_id: leaseArgs.request_id }); } catch { observed = null; }
4152
+ const named = observed?.ok && observed.data?.success === true && observed.data.superseded !== true &&
4153
+ typeof observed.data.lease_id === "string" && UUID_RE.test(observed.data.lease_id) ? observed.data.lease_id : null;
4154
+ if (named) {
4155
+ leaseId = named;
4156
+ leaseProvisioned = true;
4157
+ execution.bindLease?.(leaseId);
4158
+ const cleanup = await requestCleanup();
4159
+ return { exitCode: receivedSignal ? SIGNAL_EXIT[receivedSignal] : cleanup.ok ? EXIT.BACKEND : EXIT.CLEANUP_FAILED,
4160
+ outcome: receivedSignal ? "interrupted" : "error", code: verdict.code, leaseId, cleanup,
4161
+ error: `identified admission ${leaseArgs.request_id} was uncertain; its owner-scoped read names lease ${leaseId}, which was released` };
4162
+ }
4163
+ // Still unknown: keep the port claim (bound to this request) and hand back the key.
4164
+ retainBlock = true;
4165
+ leaseProvisioned = true;
4166
+ return { exitCode: receivedSignal ? SIGNAL_EXIT[receivedSignal] : EXIT.BACKEND, outcome: receivedSignal ? "interrupted" : "error", code: verdict.code,
4167
+ allocationRecovery: identifiedAdmissionRecovery(leaseArgs, null, verdict.ack),
4168
+ error: `identified admission ${leaseArgs.request_id} may have committed; its port claim is retained. Read it with ` +
4169
+ `\`bb stack status --admission-request-id ${leaseArgs.request_id}\`, then release any lease it names with ` +
4170
+ `\`bb stack done <lease-id> --reap --release-port-claim --admission-request-id ${leaseArgs.request_id}\`` };
4171
+ }
3956
4172
 
3957
4173
  // Interrupted DURING the capacity park: the refused request created no lease, so
3958
4174
  // there is nothing to release. (A signal during a request that DID admit falls
@@ -4267,7 +4483,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4267
4483
  // BOT-2400 (item 1): the park deadline never gives the queue place up. Hold the lease
4268
4484
  // for the owner's next run (which re-attaches to it) and exit PARK_DEADLINE; only
4269
4485
  // `stack cancel` / `stack done` or the owner's death releases it.
4270
- const queueHold = holdPlace ? await holdPlace(leaseId, HOLD_ON_TIMEOUT_MAX_SEC) : null;
4486
+ // BOT-2780: a requester-origin run cannot be resumed by a later invocation (its stack
4487
+ // names this CLI's listener, which closes now), so it releases instead of holding a place.
4488
+ const queueHold = holdPlace && !execution.requesterOrigin ? await holdPlace(leaseId, HOLD_ON_TIMEOUT_MAX_SEC) : null;
4271
4489
  if (queueHold) {
4272
4490
  // A refused/lost hold reply is not an owner cancellation: it may have
4273
4491
  // committed before the reply was lost, or the queued lease may have
@@ -4425,6 +4643,28 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4425
4643
  leaseId, sshTarget, forwards: placement.forwards,
4426
4644
  isLive: () => Boolean(tunnel && !tunnelLost && !receivedSignal && tunnel.isLive()),
4427
4645
  });
4646
+ if (execution.requesterOrigin) {
4647
+ // BOT-2780: bind the reserved listener to exactly this lease's API forward. It is not
4648
+ // another SSH forward: connection.api_url/ports/attestation stay physical-only.
4649
+ try {
4650
+ execution.requesterOrigin.onLost((loss) => { originLost = loss; if (!receivedSignal && !childResult) stopChild("SIGTERM", EXIT.LEASE_FAILED); });
4651
+ execution.requesterOrigin.activate({
4652
+ context: execution.originContext, leaseId, projectId: current.data.connection.project_id, sshTarget,
4653
+ remoteConnection: current.data.connection, forwardedConnection: tunnel.connection, forwards: placement.forwards,
4654
+ isLive: () => Boolean(tunnel && forwardAttestation && !tunnelLost && !receivedSignal && tunnel.isLive()),
4655
+ watchLoss: (callback) => tunnel.onExit(callback),
4656
+ });
4657
+ } catch (error) {
4658
+ await closeTunnel();
4659
+ const cleanup = await requestCleanup();
4660
+ return { exitCode: cleanup.ok ? EXIT.BACKEND : EXIT.CLEANUP_FAILED, outcome: "error", code: error?.code ?? "REQUESTER_ORIGIN_FORWARD_REFUSED", leaseId, error: String(error?.message ?? error), cleanup };
4661
+ }
4662
+ placement.requesterOrigin = execution.requesterOrigin.origin;
4663
+ stderrLine(`stack: requester origin ${execution.requesterOrigin.origin} → ${placement.hostKey ?? "remote"} API (generated auth links use it unchanged)`);
4664
+ childConnection = { ...childConnection, requester_origin: execution.requesterOrigin.origin };
4665
+ }
4666
+ } else if (execution.requesterOrigin) {
4667
+ await closeOrigin();
4428
4668
  }
4429
4669
  connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), childConnection);
4430
4670
  if (receivedSignal) {
@@ -4461,6 +4701,12 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4461
4701
  // there would fail on a missing package.json AFTER the stack was provisioned.
4462
4702
  // The child therefore always runs in the (canonicalised) worktree root.
4463
4703
  const childCwd = execution.worktreeRoot;
4704
+ if (!tunnelLost && execution.requesterOrigin && servedRemote && (originLost || !execution.requesterOrigin.isLive())) {
4705
+ await closeTunnel();
4706
+ const cleanup = await requestCleanup();
4707
+ return { exitCode: cleanup.ok ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "error", code: originLost?.code ?? "REQUESTER_ORIGIN_TUNNEL_LOST", leaseId, cleanup,
4708
+ error: "the requester origin closed before the child started; the lease was released" };
4709
+ }
4464
4710
  if (tunnelLost) {
4465
4711
  // The forwards died between being verified and the child starting.
4466
4712
  await closeTunnel();
@@ -4521,6 +4767,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4521
4767
  try { execution.renew?.({ idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
4522
4768
  stderrLine(`stack: lease ${leaseId} is idle${cleanup.idle_since ? ` since ${cleanup.idle_since}` : ""} — its stack keeps running for the next run on this slot (\`bb stack done ${leaseId} --reap\` tears it down)`);
4523
4769
  }
4770
+ if (originLost && !tunnelLost && !receivedSignal) {
4771
+ return { exitCode: cleanup.ok ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "failed", code: originLost.code, leaseId, childExitCode: childResult.code, cleanup, fenced: true,
4772
+ error: "the requester origin closed while the child was running, so the child was stopped and the lease released" };
4773
+ }
4524
4774
  if (tunnelLost && !receivedSignal) {
4525
4775
  return tunnelLostResult(tunnelLost, { leaseId, cleanup, childExitCode: childResult.code, fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) });
4526
4776
  }
@@ -4617,6 +4867,9 @@ export function runReceiptFields(result) {
4617
4867
  execution_host_key: result.execution?.hostKey ?? null,
4618
4868
  requester_host: result.execution?.requesterHost ?? null,
4619
4869
  transport: result.execution?.transport ?? "none",
4870
+ // BOT-2780: the reserved requester origin generated auth links name (never a credential).
4871
+ ...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
4872
+ ...(result.allocationRecovery ? { allocation_recovery: result.allocationRecovery } : {}),
4620
4873
  ...(result.execution?.forwards ? { forwards: result.execution.forwards } : {}),
4621
4874
  ...(result.execution?.bundle ? { bundle: result.execution.bundle } : {}),
4622
4875
  // BOT-2269: the opted-in files that shipped in that bundle — paths, sha256, key names.
@@ -0,0 +1,24 @@
1
+ Copyright (c) Squirrel Chat et al., All rights reserved.
2
+
3
+ Redistribution and use in source and binary forms, with or without
4
+ modification, are permitted provided that the following conditions are met:
5
+
6
+ 1. Redistributions of source code must retain the above copyright notice, this
7
+ list of conditions and the following disclaimer.
8
+ 2. Redistributions in binary form must reproduce the above copyright notice,
9
+ this list of conditions and the following disclaimer in the
10
+ documentation and/or other materials provided with the distribution.
11
+ 3. Neither the name of the copyright holder nor the names of its contributors
12
+ may be used to endorse or promote products derived from this software without
13
+ specific prior written permission.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,156 @@
1
+ /*!
2
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
3
+ * SPDX-License-Identifier: BSD-3-Clause
4
+ *
5
+ * Redistribution and use in source and binary forms, with or without
6
+ * modification, are permitted provided that the following conditions are met:
7
+ *
8
+ * 1. Redistributions of source code must retain the above copyright notice, this
9
+ * list of conditions and the following disclaimer.
10
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ * this list of conditions and the following disclaimer in the
12
+ * documentation and/or other materials provided with the distribution.
13
+ * 3. Neither the name of the copyright holder nor the names of its contributors
14
+ * may be used to endorse or promote products derived from this software without
15
+ * specific prior written permission.
16
+ *
17
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
+ */
28
+ let DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})?[Tt ]?(?:(\d{2}):\d{2}(?::\d{2}(?:\.\d+)?)?)?(Z|z|[-+]\d{2}:\d{2})?$/i;
29
+ export class TomlDate extends Date {
30
+ #hasDate = false;
31
+ #hasTime = false;
32
+ #offset = null;
33
+ constructor(date, fasttype, unsafeDelim) {
34
+ let hasDate = true;
35
+ let hasTime = true;
36
+ let offset = 'Z';
37
+ let c;
38
+ if (typeof date === 'string') {
39
+ if (fasttype)
40
+ prep: {
41
+ // Date-time
42
+ if (fasttype < 3) {
43
+ if (+date.slice(11, 13) > 23) {
44
+ date = '';
45
+ break prep;
46
+ }
47
+ // Local
48
+ if (fasttype === 2) {
49
+ offset = null;
50
+ date += 'Z';
51
+ }
52
+ // Offset; keep track of offset if not Z
53
+ else if ((c = date.charCodeAt(date.length - 1)) !== 0x5a /* Z */ && c !== 0x7a /* z */) {
54
+ offset = date.slice(date.length - 6);
55
+ }
56
+ if (unsafeDelim)
57
+ date = date.slice(0, 10) + 'T' + date.slice(11);
58
+ }
59
+ // Time
60
+ else if (fasttype === 4) {
61
+ date = +date.slice(0, 2) > 23 ? '' : `0000-01-01T${date}Z`;
62
+ }
63
+ hasDate = fasttype !== 4;
64
+ hasTime = fasttype !== 3;
65
+ }
66
+ else {
67
+ let match = date.match(DATE_TIME_RE);
68
+ if (match) {
69
+ if (!match[1]) {
70
+ hasDate = false;
71
+ date = `0000-01-01T${date}`;
72
+ }
73
+ hasTime = !!match[2];
74
+ // Make sure to use T instead of a space. Breaks in case of extreme values otherwise.
75
+ hasTime && date[10] === ' ' && (date = date.replace(' ', 'T'));
76
+ // Do not allow rollover hours.
77
+ if (match[2] && +match[2] > 23) {
78
+ date = '';
79
+ }
80
+ else {
81
+ offset = match[3] || null;
82
+ if (!offset && hasTime)
83
+ date += 'Z';
84
+ }
85
+ }
86
+ else {
87
+ date = '';
88
+ }
89
+ }
90
+ }
91
+ super(date);
92
+ if (!isNaN(this.getTime())) {
93
+ this.#hasDate = hasDate;
94
+ this.#hasTime = hasTime;
95
+ this.#offset = offset;
96
+ }
97
+ }
98
+ isDateTime() {
99
+ return this.#hasDate && this.#hasTime;
100
+ }
101
+ isLocal() {
102
+ return !this.#hasDate || !this.#hasTime || !this.#offset;
103
+ }
104
+ isDate() {
105
+ return this.#hasDate && !this.#hasTime;
106
+ }
107
+ isTime() {
108
+ return this.#hasTime && !this.#hasDate;
109
+ }
110
+ isValid() {
111
+ return this.#hasDate || this.#hasTime;
112
+ }
113
+ toISOString() {
114
+ let iso = super.toISOString();
115
+ // Local Date
116
+ if (this.isDate())
117
+ return iso.slice(0, 10);
118
+ // Local Time
119
+ if (this.isTime())
120
+ return iso.slice(11, 23);
121
+ // Local DateTime
122
+ if (this.#offset === null)
123
+ return iso.slice(0, -1);
124
+ // Offset DateTime
125
+ if (this.#offset === 'Z' || this.#offset === 'z')
126
+ return iso;
127
+ // This part is quite annoying: JS strips the original timezone from the ISO string representation
128
+ // Instead of using a "modified" date and "Z", we restore the representation "as authored"
129
+ let offset = (+(this.#offset.slice(1, 3)) * 60) + +(this.#offset.slice(4, 6));
130
+ offset = this.#offset[0] === '-' ? offset : -offset;
131
+ let offsetDate = new Date(this.getTime() - (offset * 60e3));
132
+ return offsetDate.toISOString().slice(0, -1) + this.#offset;
133
+ }
134
+ static wrapAsOffsetDateTime(jsDate, offset = 'Z') {
135
+ let date = new TomlDate(jsDate);
136
+ date.#offset = offset;
137
+ return date;
138
+ }
139
+ static wrapAsLocalDateTime(jsDate) {
140
+ let date = new TomlDate(jsDate);
141
+ date.#offset = null;
142
+ return date;
143
+ }
144
+ static wrapAsLocalDate(jsDate) {
145
+ let date = new TomlDate(jsDate);
146
+ date.#hasTime = false;
147
+ date.#offset = null;
148
+ return date;
149
+ }
150
+ static wrapAsLocalTime(jsDate) {
151
+ let date = new TomlDate(jsDate);
152
+ date.#hasDate = false;
153
+ date.#offset = null;
154
+ return date;
155
+ }
156
+ }