@botbuddy/cli 1.29.0 → 1.29.2
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/stack.mjs +56 -22
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { randomUUID } from "crypto";
|
|
|
30
30
|
import { callToolJson } from "./api.mjs";
|
|
31
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
32
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
33
|
+
import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
|
|
33
34
|
import { runDockerCommand, runDockerWorkflow } from "./docker-hygiene.mjs";
|
|
34
35
|
import { machineUuid } from "./machine-id.mjs";
|
|
35
36
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
@@ -352,10 +353,17 @@ export function parseSupabaseStatus(text) {
|
|
|
352
353
|
|
|
353
354
|
// ── runtime (network / process) ──────────────────────────────────────────────
|
|
354
355
|
|
|
355
|
-
// BOT-
|
|
356
|
-
//
|
|
357
|
-
//
|
|
356
|
+
// BOT-1599: a stack lease is a host-bound operation. When a session token is
|
|
357
|
+
// present it must win over the durable OAuth client credential, otherwise the
|
|
358
|
+
// lease RPC resolves the hostless/stale OAuth agent rather than the agent that
|
|
359
|
+
// registered this worktree and machine attestation. The MCP server accepts the
|
|
360
|
+
// session token through the same agent-key header used by bb-pw.
|
|
361
|
+
//
|
|
362
|
+
// Without a session token, retain the BOT-1520 Keychain-backed OAuth/MCP-key
|
|
363
|
+
// fallback for operator commands and legacy callers.
|
|
358
364
|
export async function stackAuthHeader() {
|
|
365
|
+
const sessionToken = readAgentKeyEnv();
|
|
366
|
+
if (sessionToken) return AGENT_KEY_RE.test(sessionToken) ? { "x-agent-api-key": sessionToken } : null;
|
|
359
367
|
const agentKey = await resolveAgentKey();
|
|
360
368
|
const owner = await resolveOwnerToken({ getConfig });
|
|
361
369
|
if (owner) {
|
|
@@ -369,6 +377,16 @@ export async function stackAuthHeader() {
|
|
|
369
377
|
return null;
|
|
370
378
|
}
|
|
371
379
|
|
|
380
|
+
// The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
|
|
381
|
+
// the event-stream relay requires that same token in its bearer form too. Keep
|
|
382
|
+
// the lease RPC shape unchanged and derive the relay-compatible form only at
|
|
383
|
+
// the wait boundary; never substitute an ambient OAuth credential.
|
|
384
|
+
function relayAuthHeaders(auth) {
|
|
385
|
+
const sessionToken = auth?.["x-agent-api-key"];
|
|
386
|
+
if (auth?.Authorization || !sessionToken || !AGENT_KEY_RE.test(sessionToken)) return auth;
|
|
387
|
+
return { ...auth, Authorization: `Bearer ${sessionToken}` };
|
|
388
|
+
}
|
|
389
|
+
|
|
372
390
|
function parseSseFrames(buffer) {
|
|
373
391
|
const frames = [];
|
|
374
392
|
let idx, remaining = buffer;
|
|
@@ -394,7 +412,7 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
|
|
|
394
412
|
const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
|
|
395
413
|
const res = await fetchImpl(`${eventStreamBase()}`, {
|
|
396
414
|
method: "POST",
|
|
397
|
-
headers: { ...auth, "Content-Type": "application/json" },
|
|
415
|
+
headers: { ...relayAuthHeaders(auth), "Content-Type": "application/json" },
|
|
398
416
|
body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
|
|
399
417
|
signal,
|
|
400
418
|
});
|
|
@@ -427,7 +445,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
427
445
|
const finalizerTimer = setTimeout(() => finalizer.abort(), 5_000);
|
|
428
446
|
try {
|
|
429
447
|
const response = await fetchImpl(eventStreamBase(), {
|
|
430
|
-
method: "POST", headers: { ...auth, "Content-Type": "application/json" },
|
|
448
|
+
method: "POST", headers: { ...relayAuthHeaders(auth), "Content-Type": "application/json" },
|
|
431
449
|
body: JSON.stringify({ action: "finalize", wait_session_id: waitSessionId, status, receipt: { outcome: status, lease_id: leaseId } }),
|
|
432
450
|
signal: finalizer.signal,
|
|
433
451
|
});
|
|
@@ -455,7 +473,7 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
455
473
|
url.searchParams.set("heartbeat", "1");
|
|
456
474
|
let res;
|
|
457
475
|
try {
|
|
458
|
-
res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
476
|
+
res = await fetchImpl(url, { headers: { ...relayAuthHeaders(auth), Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
459
477
|
} catch (err) {
|
|
460
478
|
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
461
479
|
return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
|
|
@@ -697,6 +715,7 @@ export async function cmdUp(opts, {
|
|
|
697
715
|
}
|
|
698
716
|
const auth = await authProvider();
|
|
699
717
|
if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
718
|
+
const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
|
|
700
719
|
|
|
701
720
|
// BOT-1585: co-location dispatches the lease to the Helper enrolled for THIS
|
|
702
721
|
// physical machine (hardware id), since a hostname is not machine-unique. The
|
|
@@ -705,7 +724,7 @@ export async function cmdUp(opts, {
|
|
|
705
724
|
if (!hardwareUuid) {
|
|
706
725
|
return emitResult(buildReceipt({ command: "up", outcome: "error", code: "MACHINE_UUID_REQUIRED", error: "could not determine this machine's hardware id (needed to dispatch the stack lease to the right machine)" }), opts, EXIT.BACKEND);
|
|
707
726
|
}
|
|
708
|
-
const req = await
|
|
727
|
+
const req = await call("request_stack_lease", {
|
|
709
728
|
slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
|
|
710
729
|
ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
|
|
711
730
|
pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
@@ -748,7 +767,7 @@ export async function cmdUp(opts, {
|
|
|
748
767
|
localPreflight = compactPreflight(checked.receipt);
|
|
749
768
|
localDockerTarget = dockerTargetFromPreflight(checked.receipt);
|
|
750
769
|
if (checked.exitCode !== 0 || !localDockerTarget) {
|
|
751
|
-
const cancelled = await
|
|
770
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
752
771
|
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
753
772
|
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
754
773
|
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
@@ -768,7 +787,7 @@ export async function cmdUp(opts, {
|
|
|
768
787
|
// Persist the validated Docker target with the reservation so that if the
|
|
769
788
|
// provisioner partially starts a stack and then fails, the fenced lease can
|
|
770
789
|
// still be torn down by `stack done --local-exec` (BOT-1421 review).
|
|
771
|
-
const reserved = await
|
|
790
|
+
const reserved = await call("reserve_stack_lease", {
|
|
772
791
|
lease_id: leaseId,
|
|
773
792
|
connection: connectionWithDockerTarget({}, localDockerTarget),
|
|
774
793
|
});
|
|
@@ -801,7 +820,7 @@ export async function cmdUp(opts, {
|
|
|
801
820
|
// Fall through to the authoritative `get_stack_lease` read below — do
|
|
802
821
|
// NOT run the local provisioner; the Helper owns this stack.
|
|
803
822
|
} else {
|
|
804
|
-
const cancelled = await
|
|
823
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
805
824
|
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
806
825
|
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
807
826
|
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
@@ -825,7 +844,7 @@ export async function cmdUp(opts, {
|
|
|
825
844
|
// review); do NOT cancel.
|
|
826
845
|
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
|
|
827
846
|
}
|
|
828
|
-
const act = await
|
|
847
|
+
const act = await call("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
829
848
|
if (!act.ok || !act.data?.success) {
|
|
830
849
|
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
831
850
|
}
|
|
@@ -840,7 +859,7 @@ export async function cmdUp(opts, {
|
|
|
840
859
|
}
|
|
841
860
|
|
|
842
861
|
// Authoritative final read (connection block, current state).
|
|
843
|
-
const got = await
|
|
862
|
+
const got = await call("get_stack_lease", { lease_id: leaseId });
|
|
844
863
|
const g = got.ok && got.data?.success ? got.data : null;
|
|
845
864
|
if (!g || g.state !== "active") {
|
|
846
865
|
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: g?.state, error: g ? `lease is ${g.state}, not active` : (got.error || "could not read lease") }), opts, g?.state && isReaped(g.state) ? EXIT.LEASE_FAILED : EXIT.BACKEND);
|
|
@@ -854,7 +873,9 @@ export async function cmdUp(opts, {
|
|
|
854
873
|
}
|
|
855
874
|
|
|
856
875
|
async function cmdStatus(leaseId, opts) {
|
|
857
|
-
const
|
|
876
|
+
const auth = await stackAuthHeader();
|
|
877
|
+
if (!auth) return emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
878
|
+
const got = await callToolJson("get_stack_lease", { lease_id: leaseId }, { auth });
|
|
858
879
|
if (!got.ok) return got.auth
|
|
859
880
|
? emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
|
|
860
881
|
: emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
@@ -868,7 +889,9 @@ async function cmdStatus(leaseId, opts) {
|
|
|
868
889
|
}
|
|
869
890
|
|
|
870
891
|
async function cmdTouch(leaseId, opts) {
|
|
871
|
-
const
|
|
892
|
+
const auth = await stackAuthHeader();
|
|
893
|
+
if (!auth) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
894
|
+
const r = await callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth });
|
|
872
895
|
if (!r.ok) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
873
896
|
if (!r.data.success) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
|
|
874
897
|
return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
|
|
@@ -876,15 +899,19 @@ async function cmdTouch(leaseId, opts) {
|
|
|
876
899
|
|
|
877
900
|
export async function cmdDone(leaseId, opts, {
|
|
878
901
|
callTool = callToolJson,
|
|
902
|
+
authProvider = stackAuthHeader,
|
|
879
903
|
runPreflight = runLocalExecTargetCheck,
|
|
880
904
|
proveLegacyTarget = proveLegacyLocalExecTarget,
|
|
881
905
|
localTeardownFn = localTeardown,
|
|
882
906
|
emitResult = emit,
|
|
883
907
|
} = {}) {
|
|
908
|
+
const auth = await authProvider();
|
|
909
|
+
if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
910
|
+
const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
|
|
884
911
|
let dockerTarget = null;
|
|
885
912
|
let legacyTargetProof = null;
|
|
886
913
|
if (opts.localExec) {
|
|
887
|
-
const current = await
|
|
914
|
+
const current = await call("get_stack_lease", { lease_id: leaseId });
|
|
888
915
|
if (!current.ok || !current.data?.success) {
|
|
889
916
|
return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
|
|
890
917
|
error: current.error || current.data?.code || "could not verify the lease Docker target" }), opts,
|
|
@@ -915,7 +942,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
915
942
|
}
|
|
916
943
|
}
|
|
917
944
|
|
|
918
|
-
const r = await
|
|
945
|
+
const r = await call("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
|
|
919
946
|
if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
920
947
|
if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
|
|
921
948
|
let state = r.data.state;
|
|
@@ -931,7 +958,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
931
958
|
error: "local `supabase stop` failed — NOT finalizing; the slot stays fenced. Tear the stack down and re-run `stack done --local-exec`, or let the reaper reconcile.",
|
|
932
959
|
}), opts, EXIT.LEASE_FAILED);
|
|
933
960
|
}
|
|
934
|
-
const fin = await
|
|
961
|
+
const fin = await call("finalize_stack_lease", { lease_id: leaseId });
|
|
935
962
|
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
936
963
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
937
964
|
}
|
|
@@ -1049,13 +1076,13 @@ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connect
|
|
|
1049
1076
|
* without Docker or a live BotBuddy service.
|
|
1050
1077
|
*/
|
|
1051
1078
|
export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
1079
|
+
const auth = adapters.auth ?? await stackAuthHeader();
|
|
1052
1080
|
const api = adapters.api ?? {
|
|
1053
|
-
request: (args) => callToolJson("request_stack_lease", args),
|
|
1054
|
-
get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }),
|
|
1055
|
-
touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
|
|
1056
|
-
release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
|
|
1081
|
+
request: (args) => callToolJson("request_stack_lease", args, { auth }),
|
|
1082
|
+
get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }, { auth }),
|
|
1083
|
+
touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }, { auth }),
|
|
1084
|
+
release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { auth, signal }),
|
|
1057
1085
|
};
|
|
1058
|
-
const auth = adapters.auth ?? await stackAuthHeader();
|
|
1059
1086
|
const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
|
|
1060
1087
|
const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
|
|
1061
1088
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
|
|
@@ -1263,6 +1290,13 @@ export async function cmdStack(argv) {
|
|
|
1263
1290
|
process.exitCode = code;
|
|
1264
1291
|
return code;
|
|
1265
1292
|
}
|
|
1293
|
+
const sessionToken = readAgentKeyEnv();
|
|
1294
|
+
if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
|
|
1295
|
+
process.stderr.write(`${yellow("⚠")} stack: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; re-register and export a fresh session token.\n`);
|
|
1296
|
+
const code = emit(buildReceipt({ command, outcome: "error", error: "invalid_session_token" }), opts, EXIT.INVALID);
|
|
1297
|
+
process.exitCode = code;
|
|
1298
|
+
return code;
|
|
1299
|
+
}
|
|
1266
1300
|
let code;
|
|
1267
1301
|
try {
|
|
1268
1302
|
switch (command) {
|