@botbuddy/cli 1.29.4 → 1.30.1
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/agent-doctor.mjs +93 -0
- package/src/agent-key.mjs +14 -9
- package/src/agent-session.mjs +0 -0
- package/src/agent-state.mjs +166 -0
- package/src/commands.mjs +151 -12
- package/src/credential-kinds.mjs +4 -11
- package/src/docker-hygiene.mjs +114 -16
- package/src/pw/coordinator.mjs +7 -1
- package/src/pw/run.mjs +37 -11
- package/src/run.mjs +59 -12
- package/src/setup-block.mjs +16 -27
- package/src/stack.mjs +50 -14
- package/src/test-lane.mjs +45 -14
- package/src/wait.mjs +245 -168
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,
|
package/src/test-lane.mjs
CHANGED
|
@@ -21,7 +21,8 @@ import { execFileSync } from "node:child_process";
|
|
|
21
21
|
|
|
22
22
|
import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
|
|
23
23
|
import { callToolJson } from "./api.mjs";
|
|
24
|
-
import {
|
|
24
|
+
import { readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
25
|
+
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
25
26
|
|
|
26
27
|
// EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
|
|
27
28
|
export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
|
|
@@ -38,9 +39,9 @@ export function parseTestArgs(argv, { env = process.env } = {}) {
|
|
|
38
39
|
const opts = {
|
|
39
40
|
sessionId: env.BOTBUDDY_SESSION_ID ?? null,
|
|
40
41
|
// BOT-1572/1582 (AC-8): a session token stands in for --session-id; it flows to
|
|
41
|
-
// the sub-invoked `run`/`wait` which derive the session from it.
|
|
42
|
-
// is
|
|
43
|
-
sessionToken:
|
|
42
|
+
// the sub-invoked `run`/`wait` which derive the session from it.
|
|
43
|
+
// $BOTBUDDY_AGENT_SESSION_TOKEN is canonical; former names remain aliases.
|
|
44
|
+
sessionToken: readAgentSessionTokenEnv(env),
|
|
44
45
|
environment: "local",
|
|
45
46
|
ticket: null, pr: null, repo: null,
|
|
46
47
|
laneKind: null,
|
|
@@ -57,6 +58,8 @@ export function parseTestArgs(argv, { env = process.env } = {}) {
|
|
|
57
58
|
const flag = flags[i];
|
|
58
59
|
switch (flag) {
|
|
59
60
|
case "--session-id": opts.sessionId = value(flag, i); i++; break;
|
|
61
|
+
case "--agent-session-token":
|
|
62
|
+
case "--agent-key":
|
|
60
63
|
case "--session-token": opts.sessionToken = value(flag, i); i++; break;
|
|
61
64
|
case "--environment": opts.environment = value(flag, i); i++; break;
|
|
62
65
|
case "--ticket": opts.ticket = value(flag, i); i++; break;
|
|
@@ -139,11 +142,11 @@ export function defaultGitInfo({ cwd = process.cwd(), ticket = null, pr = null,
|
|
|
139
142
|
// worker, carrying the telemetry env into its detached child.
|
|
140
143
|
async function defaultLaunchLane({ command, sessionId, sessionToken, environment, expectedDurationSeconds, childEnv, testRun, cwd, call }) {
|
|
141
144
|
// BOT-1572/1582: pass --session-id only when a plain id is used; a token-only lane
|
|
142
|
-
// relies on the inherited $
|
|
145
|
+
// relies on the inherited $BOTBUDDY_AGENT_SESSION_TOKEN (and an explicit --agent-session-token
|
|
143
146
|
// so the child never falls back to a machine credential).
|
|
144
147
|
const argv = [
|
|
145
148
|
...(sessionId ? ["--session-id", sessionId] : []),
|
|
146
|
-
...(sessionToken ? ["--session-token", sessionToken] : []),
|
|
149
|
+
...(sessionToken ? ["--agent-session-token", sessionToken] : []),
|
|
147
150
|
"--environment", environment, "--category", "validation", "--kind", "full_suite",
|
|
148
151
|
"--expected-duration", String(expectedDurationSeconds ?? 0), "--", ...command,
|
|
149
152
|
];
|
|
@@ -152,7 +155,7 @@ async function defaultLaunchLane({ command, sessionId, sessionToken, environment
|
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
function waitCommand(testRunId, sessionId, sessionToken) {
|
|
155
|
-
// BOT-
|
|
158
|
+
// BOT-1649: a token-armed session runs the wait with only the canonical session token.
|
|
156
159
|
const idFlag = sessionId && !sessionToken ? ` --session-id ${sessionId}` : "";
|
|
157
160
|
return `botbuddy wait 'test-run:id=${testRunId}'${idFlag} --heartbeat`;
|
|
158
161
|
}
|
|
@@ -162,6 +165,8 @@ export async function launchTestLane(argv, {
|
|
|
162
165
|
env = process.env,
|
|
163
166
|
call = callToolJson,
|
|
164
167
|
launchLane = defaultLaunchLane,
|
|
168
|
+
resolveCredential = null,
|
|
169
|
+
clearState = clearAgentState,
|
|
165
170
|
gitInfo = defaultGitInfo,
|
|
166
171
|
eventsDir = join(homedir(), ".botbuddy", "test-lanes"),
|
|
167
172
|
} = {}) {
|
|
@@ -170,9 +175,24 @@ export async function launchTestLane(argv, {
|
|
|
170
175
|
for (const e of errors) process.stderr.write(`botbuddy test: ${e}\n`);
|
|
171
176
|
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors }) };
|
|
172
177
|
}
|
|
178
|
+
let managedCredential = null;
|
|
173
179
|
if (!opts.sessionId && !opts.sessionToken) {
|
|
174
|
-
|
|
175
|
-
|
|
180
|
+
// Test doubles intentionally do not self-mint. The shipped command does,
|
|
181
|
+
// using exactly the same cache/re-adoption resolver as `bb wait` and `run`.
|
|
182
|
+
if (!resolveCredential && call !== callToolJson) {
|
|
183
|
+
process.stderr.write("botbuddy test: --session-id is required (or set BOTBUDDY_AGENT_SESSION_TOKEN)\n");
|
|
184
|
+
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
|
|
188
|
+
managedCredential = credential;
|
|
189
|
+
opts.sessionToken = credential.token;
|
|
190
|
+
opts.sessionId = credential.sessionId ?? null;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const code = error?.code ?? "agent_session_unavailable";
|
|
193
|
+
process.stderr.write(`botbuddy test: ${error?.message ?? code}\n`);
|
|
194
|
+
return { exitCode: 3, line: JSON.stringify({ outcome: "rejected", error: code, recovery: code === "client_key_required" ? "run `bb login` then retry" : "run `bb doctor --fix` then retry" }) };
|
|
195
|
+
}
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
// BOT-1572 (Codex P1): a token-only session has no owner/profile credential, so
|
|
@@ -180,9 +200,20 @@ export async function launchTestLane(argv, {
|
|
|
180
200
|
// otherwise both fail and the run launches with test_run_id=null, silently
|
|
181
201
|
// dropping the test-run record and case telemetry. Wrap the call so the token
|
|
182
202
|
// is pinned; an injected test `call` that ignores the 3rd arg is unaffected.
|
|
183
|
-
const apiCall = opts.sessionToken
|
|
184
|
-
?
|
|
185
|
-
: call;
|
|
203
|
+
const apiCall = (name, args) => opts.sessionToken
|
|
204
|
+
? call(name, args, { auth: { "x-agent-api-key": opts.sessionToken } })
|
|
205
|
+
: call(name, args);
|
|
206
|
+
const callWithSessionRetry = async (name, args) => {
|
|
207
|
+
let result = await apiCall(name, args);
|
|
208
|
+
if (!isRejectedCachedMcpSession(result, managedCredential?.source, 0)) return result;
|
|
209
|
+
await clearState(cwd);
|
|
210
|
+
const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
|
|
211
|
+
managedCredential = credential;
|
|
212
|
+
opts.sessionToken = credential.token;
|
|
213
|
+
opts.sessionId = credential.sessionId ?? opts.sessionId;
|
|
214
|
+
result = await apiCall(name, args);
|
|
215
|
+
return result;
|
|
216
|
+
};
|
|
186
217
|
|
|
187
218
|
const resolved = resolveLane(laneName, { cwd });
|
|
188
219
|
if (!resolved.ok) {
|
|
@@ -218,12 +249,12 @@ export async function launchTestLane(argv, {
|
|
|
218
249
|
// BOT-1549: stamp the resolved lane kind on the run; absent ⇒ omitted (NULL).
|
|
219
250
|
lane_kind: laneKind ?? undefined,
|
|
220
251
|
};
|
|
221
|
-
const created = await
|
|
252
|
+
const created = await callWithSessionRetry("create_test_run", createArgs);
|
|
222
253
|
const testRunId = created?.ok && !created.isError ? created.data?.id ?? null : null;
|
|
223
254
|
|
|
224
255
|
if (testRunId) {
|
|
225
256
|
// Step 2: attach the ticket + promote to active in one update.
|
|
226
|
-
await
|
|
257
|
+
await callWithSessionRetry("update_test_run", {
|
|
227
258
|
test_run_id: testRunId,
|
|
228
259
|
ticket_id: git.ticket ?? undefined,
|
|
229
260
|
ticket_url: git.ticketUrl ?? undefined,
|