@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 +1 -1
- package/src/docker-hygiene.mjs +114 -16
- package/src/stack.mjs +50 -14
package/package.json
CHANGED
package/src/docker-hygiene.mjs
CHANGED
|
@@ -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
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
700
|
-
|
|
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:
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
79
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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] || "
|
|
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
|
-
|
|
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
|
|
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,
|