@botbuddy/cli 1.29.4 → 1.30.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.29.4",
3
+ "version": "1.30.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,56 @@ export const SCHEMA_VERSION = 1;
15
15
  export const DEFAULT_PROJECTED_ENDPOINTS = 10;
16
16
  export const DEFAULT_WARN_PRESSURE = 192;
17
17
  export const DEFAULT_FAIL_PRESSURE = 224;
18
+
19
+ // BOT-1681 / BOT-1630 Step 3 — explicit Docker-engine allowlist. The org cut the
20
+ // dev fleet back from OrbStack to Docker Desktop (OrbStack Helper heap runaway,
21
+ // SG ENT-4356 F4), so preflight must admit Docker Desktop deliberately instead of
22
+ // hard-requiring OrbStack. Each entry maps `docker info`.OperatingSystem to a
23
+ // stable `validation` label persisted on the lease and matched at teardown, plus
24
+ // an optional secondary identity guard. Anything not listed fails closed. The
25
+ // `orbstack_unavailable` fault label is deliberately NOT an admitted engine.
26
+ export const DOCKER_ENGINE_ALLOWLIST = Object.freeze([
27
+ Object.freeze({ operating_system: "OrbStack", validation: "orbstack", identity: /orbstack/i }),
28
+ Object.freeze({ operating_system: "Docker Desktop", validation: "docker-desktop", identity: null }),
29
+ ]);
30
+
31
+ // `hygiene` DELETES containers, so it keeps the original OrbStack-only fence
32
+ // (BOT-1630 Must-NOT-1). Only the read-only `preflight` widens to the full
33
+ // allowlist; that is what stack leases gate on.
34
+ const HYGIENE_ENGINE_ALLOWLIST = Object.freeze(
35
+ DOCKER_ENGINE_ALLOWLIST.filter((entry) => entry.validation === "orbstack"),
36
+ );
37
+
38
+ // Validation labels the stack-lease target identity/fence accepts, shared with
39
+ // stack.mjs so provision and teardown agree on what a valid engine looks like.
40
+ export const ADMITTED_DOCKER_VALIDATIONS = Object.freeze(
41
+ new Set(DOCKER_ENGINE_ALLOWLIST.map((entry) => entry.validation)),
42
+ );
43
+
44
+ // Optional fleet override: `BOTBUDDY_DOCKER_ENGINES` (comma-separated validation
45
+ // labels, e.g. "orbstack") narrows admission for machines that must stay on a
46
+ // single engine. Unset/blank → the org-default full allowlist. Because this is a
47
+ // SAFETY control that RESTRICTS admission, a non-empty value that names no known
48
+ // engine label (a typo like "orbstak", or only empties) fails CLOSED with an
49
+ // error rather than silently widening back to the default — otherwise a
50
+ // misspelled restriction would admit exactly the engine it meant to block
51
+ // (Codex P2). Known-but-inapplicable labels (e.g. "docker-desktop" for the
52
+ // OrbStack-only hygiene command) narrow to nothing and fall back to the command's
53
+ // inherent base, which is the same or stricter — never wider.
54
+ export function resolveEngineAllowlist(command, env = process.env) {
55
+ const base = command === "hygiene" ? HYGIENE_ENGINE_ALLOWLIST : DOCKER_ENGINE_ALLOWLIST;
56
+ const raw = env?.BOTBUDDY_DOCKER_ENGINES;
57
+ if (typeof raw !== "string" || !raw.trim()) return base;
58
+ const requested = raw.split(",").map((label) => label.trim().toLowerCase()).filter(Boolean);
59
+ const unknown = requested.filter((label) => !ADMITTED_DOCKER_VALIDATIONS.has(label));
60
+ if (requested.length === 0 || unknown.length > 0) {
61
+ throw new Error(`engine_override_invalid: BOTBUDDY_DOCKER_ENGINES="${raw}" names no usable engine label${unknown.length ? ` (unknown: ${unknown.join(", ")})` : ""}; known labels: ${[...ADMITTED_DOCKER_VALIDATIONS].join(", ")}`);
62
+ }
63
+ const wanted = new Set(requested);
64
+ const narrowed = base.filter((entry) => wanted.has(entry.validation));
65
+ return narrowed.length > 0 ? narrowed : base;
66
+ }
67
+
18
68
  const INSPECT_BATCH_SIZE = 10;
19
69
  const MAX_APPLY_BUDGET_MS = 5 * 60 * 1000;
20
70
  const MAX_DOCKER_COMMAND_MS = 30 * 1000;
@@ -50,8 +100,10 @@ REQUIRED SELECTOR
50
100
  --endpoint <uri> Explicit Docker endpoint, e.g.
51
101
  unix:///Users/me/.orbstack/run/docker.sock
52
102
 
53
- Exactly one selector is required. The target must identify itself as
54
- OrbStack; the ambient Docker context and DOCKER_HOST are never trusted.
103
+ Exactly one selector is required; the ambient Docker context and DOCKER_HOST
104
+ are never trusted. hygiene (which deletes) admits only OrbStack. preflight
105
+ admits an allowlisted engine — OrbStack or Docker Desktop — and refuses any
106
+ other daemon with engine_not_allowed:<OperatingSystem>.
55
107
 
56
108
  HYGIENE OPTIONS
57
109
  --ticket <BOT|ENT-N> Scope discovery/apply to the owning ticket's project.
@@ -68,6 +120,11 @@ PREFLIGHT OPTIONS
68
120
  --fail-pressure <n> Refuse threshold (default ${DEFAULT_FAIL_PRESSURE})
69
121
  --json Emit exactly one compact machine-readable receipt.
70
122
 
123
+ Engine allowlist (preflight): OrbStack and Docker Desktop are admitted by
124
+ default. Set BOTBUDDY_DOCKER_ENGINES to a comma-separated list of engine
125
+ labels (orbstack, docker-desktop) to keep a fleet on a single engine, e.g.
126
+ BOTBUDDY_DOCKER_ENGINES=orbstack.
127
+
71
128
  SAFETY
72
129
  • Apply requires a ticket that matches each candidate's Docker project label.
73
130
  • Apply must run from a Git worktree branch carrying that same ticket.
@@ -90,8 +147,10 @@ RELIABILITY MEASUREMENT
90
147
 
91
148
  LIFECYCLE
92
149
  Use one worktree and one ticket-scoped managed stack for one test/fix batch;
93
- finish or reap that stack before starting another. Run preflight before start,
94
- and ticket-scoped dry-run hygiene when pressure warns or refuses admission.
150
+ finish or reap that stack before starting another. Run preflight before start.
151
+ When pressure warns or refuses: on OrbStack, run ticket-scoped dry-run hygiene;
152
+ on Docker Desktop (where hygiene stays OrbStack-only) release your own idle stack
153
+ with 'botbuddy stack done <lease>' instead.
95
154
 
96
155
  PERSISTENT ORBSTACK GHOSTS
97
156
  If an exact ID remains listed but OrbStack cannot inspect it, inventory is
@@ -672,7 +731,7 @@ function readInventory(runDocker, selector) {
672
731
  };
673
732
  }
674
733
 
675
- function validateOrbStack(runDocker, opts) {
734
+ function validateDockerEngine(runDocker, opts, allowlist = DOCKER_ENGINE_ALLOWLIST) {
676
735
  let resolvedEndpoint = opts.endpoint;
677
736
  let targetHintsOrbStack = /orbstack/i.test(String(opts.endpoint || ""));
678
737
  if (opts.context) {
@@ -694,10 +753,14 @@ function validateOrbStack(runDocker, opts) {
694
753
  }
695
754
  throw error;
696
755
  }
697
- const osIsOrbStack = info?.OperatingSystem === "OrbStack";
698
756
  const identity = `${info?.KernelVersion || ""} ${info?.Name || ""} ${info?.ServerVersion || ""}`;
699
- if (!osIsOrbStack || !/orbstack/i.test(identity)) {
700
- throw new Error(`Docker target is not OrbStack (OperatingSystem=${info?.OperatingSystem || "unknown"}, kernel=${info?.KernelVersion || "unknown"})`);
757
+ const engine = allowlist.find((entry) =>
758
+ entry.operating_system === info?.OperatingSystem &&
759
+ (!entry.identity || entry.identity.test(identity)));
760
+ if (!engine) {
761
+ // Fail closed on any daemon we do not positively recognise. The reason string
762
+ // is stable (`engine_not_allowed:<OperatingSystem>`) so wrappers can branch on it.
763
+ throw new Error(`engine_not_allowed:${info?.OperatingSystem || "unknown"} (Docker target is not an allowlisted engine; OperatingSystem=${info?.OperatingSystem || "unknown"}, kernel=${info?.KernelVersion || "unknown"}, admitted=${allowlist.map((entry) => entry.operating_system).join(", ") || "(none)"})`);
701
764
  }
702
765
 
703
766
  return {
@@ -705,7 +768,7 @@ function validateOrbStack(runDocker, opts) {
705
768
  receipt: {
706
769
  requested: opts.context ? { type: "context", value: opts.context } : { type: "endpoint", value: opts.endpoint },
707
770
  resolved_endpoint: resolvedEndpoint,
708
- validation: "orbstack",
771
+ validation: engine.validation,
709
772
  server: {
710
773
  id: info.ID || null,
711
774
  name: info.Name || null,
@@ -784,6 +847,15 @@ function baseReceipt(command, opts, now) {
784
847
 
785
848
  export function buildReliabilityTelemetry(receipt, { dedupeKey } = {}) {
786
849
  const unavailable = receipt.context?.validation === "orbstack_unavailable";
850
+ // BOT-1681: the reliability experiment (BOT-1421, `v_orbstack_*`) is OrbStack-only
851
+ // and correlates rows by host/time with no engine stratum. A newly-admitted Docker
852
+ // Desktop preflight must NOT emit an unlabeled `capacity_observed` row that would
853
+ // contaminate the OrbStack strata, so suppress telemetry for any non-OrbStack
854
+ // engine outright. (Engine-stratified reliability reporting is BOT-1543/BOT-1630.)
855
+ const validation = receipt.context?.validation;
856
+ if (validation && validation !== "orbstack" && !unavailable) {
857
+ return null;
858
+ }
787
859
  const ghostInventory = (receipt.inventory_errors?.length ?? 0) > 0;
788
860
  // A successful target check is not a capacity measurement. If discovery
789
861
  // fails before evaluatePressure produces a decision, suppress the generic
@@ -902,10 +974,22 @@ function addSkipOnce(receipt, item) {
902
974
  }
903
975
  }
904
976
 
905
- function recommendationFor(opts, apply = false, candidates = []) {
977
+ function recommendationFor(opts, apply = false, candidates = [], engine = "orbstack") {
906
978
  const selector = opts.context ? `--context ${opts.context}` : `--endpoint ${opts.endpoint}`;
907
979
  const ticket = opts.ticket ? ` --ticket ${opts.ticket}` : "";
908
- if (!apply) return `botbuddy docker hygiene ${selector}${ticket}`;
980
+ if (!apply) {
981
+ // BOT-1681: `docker hygiene` is OrbStack-only, so recommending it on a
982
+ // non-OrbStack engine (e.g. Docker Desktop) would hand back a command that
983
+ // refuses with engine_not_allowed. Give a remedy that actually works there.
984
+ if (engine !== "orbstack") {
985
+ // Only recommend the OWNERSHIP-SCOPED remedy. Never suggest broad supabase_*
986
+ // container removal here: it bypasses hygiene's ticket filter, reviewed
987
+ // candidate IDs, and file/stack locks, so on a shared host it could delete a
988
+ // stopped service belonging to another live stack (Codex round-7 P2).
989
+ return `docker hygiene is OrbStack-only; on ${engine} reduce Docker load by releasing your own idle stack leases (botbuddy stack done <lease>), then retry`;
990
+ }
991
+ return `botbuddy docker hygiene ${selector}${ticket}`;
992
+ }
909
993
  if (!opts.ticket) return `botbuddy docker hygiene ${selector} --ticket <BOT-or-ENT-ticket>`;
910
994
  const projects = [...new Set(candidates.map((item) => item.project))];
911
995
  const lockSlot = projects.length === 1 ? lockSlotForProject(projects[0], opts.ticket) : null;
@@ -923,6 +1007,7 @@ export function runDockerWorkflow(argv, {
923
1007
  monotonicNow = Date.now,
924
1008
  platform = process.platform,
925
1009
  now = () => new Date(),
1010
+ env = process.env,
926
1011
  } = {}) {
927
1012
  const parsed = parseDockerArgs(argv);
928
1013
  const receipt = baseReceipt(parsed.command || "unknown", parsed.opts, now);
@@ -970,10 +1055,10 @@ export function runDockerWorkflow(argv, {
970
1055
  let validated;
971
1056
  let inventory;
972
1057
  try {
973
- validated = validateOrbStack(runDocker, parsed.opts);
1058
+ validated = validateDockerEngine(runDocker, parsed.opts, resolveEngineAllowlist(parsed.command, env));
974
1059
  receipt.context = validated.receipt;
975
1060
  inventory = classifyInventory(readInventory(runDocker, validated.selector));
976
- receipt.recommendation = recommendationFor(parsed.opts);
1061
+ receipt.recommendation = recommendationFor(parsed.opts, false, [], validated.receipt.validation);
977
1062
  } catch (error) {
978
1063
  receipt.outcome = "error";
979
1064
  receipt.errors = [error.message];
@@ -1013,7 +1098,12 @@ export function runDockerWorkflow(argv, {
1013
1098
  for (const item of receipt.inventory_errors) {
1014
1099
  receipt.errors.push(`${item.type} ${item.id}${item.name ? ` (${item.name})` : ""} remains listed but cannot be authoritatively inspected: ${item.error}`);
1015
1100
  }
1016
- receipt.recommendation = "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup.";
1101
+ // BOT-1681: this inventory-error path is shared by preflight, which now admits
1102
+ // Docker Desktop. Keep the OrbStack remedy for OrbStack, but never tell a Docker
1103
+ // Desktop operator to "repair OrbStack" or run the OrbStack-only hygiene dry run.
1104
+ receipt.recommendation = receipt.context?.validation === "orbstack"
1105
+ ? "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup."
1106
+ : `Persistent Docker inventory blocker: restart or repair the ${receipt.context?.server?.operating_system || "Docker"} daemon, then rerun preflight with the same explicit selector; never use broad cleanup.`;
1017
1107
  return { exitCode: EXIT.DOCKER, receipt, json: parsed.opts.json };
1018
1108
  }
1019
1109
 
@@ -1025,12 +1115,20 @@ export function runDockerWorkflow(argv, {
1025
1115
  warn: parsed.opts.warnPressure,
1026
1116
  fail: parsed.opts.failPressure,
1027
1117
  });
1118
+ // BOT-1681: dry-run hygiene is the OrbStack-only remedy. On Docker Desktop it
1119
+ // refuses, so the pressure warning/refusal must not tell operators to run it;
1120
+ // point them at the engine-aware recommendation instead.
1121
+ const pressureEngine = receipt.context?.validation;
1028
1122
  if (receipt.pressure.status === "warn") {
1029
1123
  receipt.outcome = "warn";
1030
- receipt.warnings.push("Projected OrbStack network pressure is at or above the warning threshold; run dry-run hygiene before starting another stack.");
1124
+ receipt.warnings.push(pressureEngine === "orbstack"
1125
+ ? "Projected OrbStack network pressure is at or above the warning threshold; run dry-run hygiene before starting another stack."
1126
+ : "Projected Docker network pressure is at or above the warning threshold; reduce Docker load (see recommendation) before starting another stack.");
1031
1127
  } else if (receipt.pressure.status === "fail") {
1032
1128
  receipt.outcome = "refused";
1033
- receipt.errors.push("Projected OrbStack network pressure is at or above the refusal threshold; cleanup or release an owning stack before start.");
1129
+ receipt.errors.push(pressureEngine === "orbstack"
1130
+ ? "Projected OrbStack network pressure is at or above the refusal threshold; cleanup or release an owning stack before start."
1131
+ : "Projected Docker network pressure is at or above the refusal threshold; release an owning stack (see recommendation) before start.");
1034
1132
  return { exitCode: EXIT.PRESSURE, receipt, json: parsed.opts.json };
1035
1133
  }
1036
1134
  return { exitCode: EXIT.OK, receipt, json: parsed.opts.json };
package/src/stack.mjs CHANGED
@@ -31,7 +31,7 @@ import { callToolJson } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
33
  import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
34
- import { runDockerCommand, runDockerWorkflow } from "./docker-hygiene.mjs";
34
+ import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
35
35
  import { machineUuid } from "./machine-id.mjs";
36
36
  import { bold, dim, yellow } from "./utils.mjs";
37
37
 
@@ -75,15 +75,18 @@ ${bold("up OPTIONS")}
75
75
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
76
76
  --no-wait If the host is full, print the queue position and exit (don't park).
77
77
  --local-exec FALLBACK (no Helper): run 'supabase start' locally and self-activate.
78
- --docker-context <name> Explicit OrbStack Docker context for the mandatory local preflight.
79
- --docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
78
+ --docker-context <name> Explicit Docker context for the mandatory local preflight
79
+ (an allowlisted engine: OrbStack or Docker Desktop, e.g. desktop-linux).
80
+ --docker-endpoint <uri> Explicit Docker endpoint instead of --docker-context.
80
81
 
81
82
  ${bold("done OPTIONS")}
82
83
  --stop Keep volumes (cheap re-provision next batch). Default: destroy.
83
84
  --local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
84
- --docker-context <name> Explicit OrbStack context used by the matching local-exec up.
85
- --docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
86
- Pre-1.5.0 leases also require exact worktree + live connection proof.
85
+ --docker-context <name> Explicit Docker context used by the matching local-exec up
86
+ (OrbStack or Docker Desktop; must match the engine that provisioned the lease).
87
+ --docker-endpoint <uri> Explicit Docker endpoint instead of --docker-context.
88
+ Pre-1.5.0 leases have no persisted target and stay OrbStack-only; they also
89
+ require exact worktree + live connection proof.
87
90
 
88
91
  ${bold("run OPTIONS")}
89
92
  --repo <repo> Required approved repository name.
@@ -528,7 +531,15 @@ export async function runLocalExecTargetCheck(opts, runWorkflow = runDockerWorkf
528
531
  const selector = opts.dockerContext
529
532
  ? ["--context", opts.dockerContext]
530
533
  : ["--endpoint", opts.dockerEndpoint];
531
- return runWorkflow(["preflight", ...selector, "--json"]);
534
+ // BOT-1681 teardown is an IDENTITY check against the daemon already persisted
535
+ // on the lease, not a new-admission decision. The BOTBUDDY_DOCKER_ENGINES fleet
536
+ // override gates PROVISIONING only; honouring it here would reject a still-valid
537
+ // Docker Desktop lease the moment a host is pinned to orbstack, fencing the lease
538
+ // and its stack slot forever (Codex P2). Validate against the full engine
539
+ // allowlist and let dockerTargetsMatch fence the observed daemon to the lease.
540
+ const env = { ...process.env };
541
+ delete env.BOTBUDDY_DOCKER_ENGINES;
542
+ return runWorkflow(["preflight", ...selector, "--json"], { env });
532
543
  }
533
544
 
534
545
  export function dockerEnvForSelector(opts, env = process.env) {
@@ -544,9 +555,14 @@ export function dockerTargetFromPreflight(receipt) {
544
555
  const context = receipt?.context;
545
556
  const endpoint = context?.resolved_endpoint;
546
557
  const serverId = context?.server?.id;
547
- if (context?.validation !== "orbstack" || typeof endpoint !== "string" || !endpoint ||
558
+ const validation = context?.validation;
559
+ // BOT-1681 — accept any allowlisted engine (orbstack, docker-desktop), not a
560
+ // hardcoded "orbstack". Carry the real engine label so teardown fences to the
561
+ // exact engine that provisioned the lease. Fault labels (e.g. orbstack_unavailable)
562
+ // are not admitted validations, so they still return null.
563
+ if (!ADMITTED_DOCKER_VALIDATIONS.has(validation) || typeof endpoint !== "string" || !endpoint ||
548
564
  typeof serverId !== "string" || !serverId) return null;
549
- return { validation: "orbstack", resolved_endpoint: endpoint, server_id: serverId };
565
+ return { validation, resolved_endpoint: endpoint, server_id: serverId };
550
566
  }
551
567
 
552
568
  export function connectionWithDockerTarget(connection, target) {
@@ -556,8 +572,12 @@ export function connectionWithDockerTarget(connection, target) {
556
572
  return { ...(connection || {}), botbuddy_docker_target: { ...target } };
557
573
  }
558
574
 
559
- function dockerTargetsMatch(expected, actual) {
560
- return expected?.validation === "orbstack" && actual?.validation === "orbstack" &&
575
+ export function dockerTargetsMatch(expected, actual) {
576
+ // BOT-1681 the fence is still exact: provision and teardown must agree on the
577
+ // engine (validation), the resolved endpoint, AND the daemon server_id. Widening
578
+ // the allowlist must never let a cross-engine or cross-daemon target match.
579
+ return ADMITTED_DOCKER_VALIDATIONS.has(expected?.validation) &&
580
+ expected?.validation === actual?.validation &&
561
581
  expected.resolved_endpoint === actual.resolved_endpoint && expected.server_id === actual.server_id;
562
582
  }
563
583
 
@@ -705,12 +725,16 @@ export async function cmdUp(opts, {
705
725
  if (checked.exitCode !== 0 || !localDockerTarget) {
706
726
  return emitResult(buildReceipt({
707
727
  command: "up", outcome: "refused", slot,
708
- error: checked.receipt.errors?.[0] || "OrbStack preflight did not return a stable Docker server identity",
728
+ error: checked.receipt.errors?.[0] || "the local preflight did not return a stable Docker server identity",
709
729
  preflight: localPreflight,
710
730
  }), opts, EXIT.LEASE_FAILED);
711
731
  }
712
732
  if (checked.receipt.outcome === "warn") {
713
- process.stderr.write(`${yellow("⚠")} stack: OrbStack preflight warns of interface pressure; cleanup is recommended before another stack.\n`);
733
+ // BOT-1681: surface the engine-aware recommendation (dry-run hygiene is
734
+ // OrbStack-only) rather than a hardcoded OrbStack cleanup instruction.
735
+ const engine = checked.receipt.context?.validation === "orbstack" ? "OrbStack" : "Docker";
736
+ const remedy = checked.receipt.recommendation || "reduce Docker load before starting another stack";
737
+ process.stderr.write(`${yellow("⚠")} stack: ${engine} preflight warns of network pressure; ${remedy}.\n`);
714
738
  }
715
739
  }
716
740
  const auth = await authProvider();
@@ -926,11 +950,23 @@ export async function cmdDone(leaseId, opts, {
926
950
  dockerTarget = dockerTargetFromPreflight(checked.receipt);
927
951
  if (expected && !dockerTargetsMatch(expected, dockerTarget)) {
928
952
  return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
929
- error: "teardown Docker target does not exactly match the OrbStack server persisted at provisioning; lease and slot remain fenced",
953
+ error: "teardown Docker target does not exactly match the Docker daemon persisted at provisioning; lease and slot remain fenced",
930
954
  expected_docker_target: expected || null, observed_docker_target: dockerTarget,
931
955
  }), opts, EXIT.LEASE_FAILED);
932
956
  }
933
957
  if (!expected) {
958
+ // BOT-1681: legacy (pre-1.5.0) leases carry no persisted botbuddy_docker_target,
959
+ // and proveLegacyLocalExecTarget authorizes teardown from only the worktree +
960
+ // live API/DB URLs — not the engine. Those leases were created under OrbStack and
961
+ // the compatibility contract still pins them there, so keep this path OrbStack-only:
962
+ // a Docker Desktop daemon (newly admitted by the preflight allowlist for MODERN
963
+ // leases) must never authorize teardown of a targetless legacy OrbStack lease.
964
+ if (dockerTarget?.validation !== "orbstack") {
965
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
966
+ error: `pre-1.5.0 lease has no persisted Docker target; only an OrbStack daemon may authorize legacy teardown, not ${dockerTarget?.validation || "an unverified engine"}; lease and slot remain fenced`,
967
+ expected_docker_target: null, observed_docker_target: dockerTarget,
968
+ }), opts, EXIT.LEASE_FAILED);
969
+ }
934
970
  const proof = proveLegacyTarget(current.data, dockerTarget, opts);
935
971
  if (!proof?.ok) {
936
972
  return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,