@botbuddy/cli 1.21.0 → 1.22.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.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/stack.mjs CHANGED
@@ -31,6 +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 { runDockerCommand, runDockerWorkflow } from "./docker-hygiene.mjs";
34
+ import { machineUuid } from "./machine-id.mjs";
34
35
  import { bold, dim, yellow } from "./utils.mjs";
35
36
 
36
37
  export const STACK_SCHEMA_VERSION = 1;
@@ -667,6 +668,7 @@ export async function cmdUp(opts, {
667
668
  localProvisionFn = localProvision,
668
669
  waitFn = waitForLease,
669
670
  emitResult = emit,
671
+ machineUuidFn = machineUuid,
670
672
  } = {}) {
671
673
  let slot;
672
674
  try { slot = deriveSlot(opts); } catch (e) {
@@ -696,6 +698,13 @@ export async function cmdUp(opts, {
696
698
  const auth = await authProvider();
697
699
  if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
698
700
 
701
+ // BOT-1585: co-location dispatches the lease to the Helper enrolled for THIS
702
+ // physical machine (hardware id), since a hostname is not machine-unique. The
703
+ // server requires it; fail fast with an actionable message if it can't be read.
704
+ const hardwareUuid = machineUuidFn();
705
+ if (!hardwareUuid) {
706
+ 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
+ }
699
708
  const req = await callTool("request_stack_lease", {
700
709
  slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
701
710
  ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
@@ -703,6 +712,7 @@ export async function cmdUp(opts, {
703
712
  purpose: opts.purpose || undefined, idle_ttl_secs: opts.idleTtl ?? undefined,
704
713
  stack_path: execution.stackPath,
705
714
  worktree_root: execution.worktreeRoot,
715
+ machine_uuid: hardwareUuid,
706
716
  });
707
717
  if (!req.ok) {
708
718
  return req.auth
@@ -1046,6 +1056,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1046
1056
  release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
1047
1057
  };
1048
1058
  const auth = adapters.auth ?? await stackAuthHeader();
1059
+ const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
1049
1060
  const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
1050
1061
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
1051
1062
  const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
@@ -1122,6 +1133,13 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1122
1133
 
1123
1134
  try {
1124
1135
  if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
1136
+ // BOT-1585: request_stack_lease requires this machine's hardware id so the lease
1137
+ // dispatches to the machine the worktree was registered on. `stack run` builds its
1138
+ // own payload (separate from `cmdUp`), so it must send it too.
1139
+ const hardwareUuid = machineUuidFn();
1140
+ if (!hardwareUuid) {
1141
+ return { exitCode: EXIT.BACKEND, outcome: "error", error: "could not determine this machine's hardware id (needed to dispatch the stack lease to the right machine)" };
1142
+ }
1125
1143
  const slot = deriveSlot(opts);
1126
1144
  const execution = resolveStackPath(process.cwd(), opts.stackPath);
1127
1145
  const request = await api.request({
@@ -1129,6 +1147,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1129
1147
  ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
1130
1148
  purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
1131
1149
  stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
1150
+ machine_uuid: hardwareUuid,
1132
1151
  });
1133
1152
  if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
1134
1153
  if (!request.data?.success) return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
@@ -20,6 +20,19 @@ export function getAgentProfile(name) {
20
20
  return PROFILES[name] ?? null;
21
21
  }
22
22
 
23
+ // BOT-1593: when no profile is named (no --profile, no .botbuddy-agent.json) but
24
+ // EXACTLY ONE supported profile's credential env var is populated, that slot is the
25
+ // unambiguous profile. Used only by best-effort setup-error telemetry to attribute a
26
+ // `profile_required` failure that still has a usable env bearer — never for the wait
27
+ // itself. Returns the profile name, or null when zero or more than one slot is set
28
+ // (ambiguous → no attribution). Reads env only; never touches the Keychain.
29
+ export function findEnvProfile(env = process.env) {
30
+ const populated = Object.entries(PROFILES).filter(
31
+ ([, { tokenEnv }]) => typeof env[tokenEnv] === "string" && env[tokenEnv].length > 0,
32
+ );
33
+ return populated.length === 1 ? populated[0][0] : null;
34
+ }
35
+
23
36
  export async function findProfileName(cwd) {
24
37
  let dir = cwd;
25
38
  const root = parse(dir).root;
package/src/wait.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  // Run botbuddy wait --help for the condition grammar.
15
15
 
16
16
  import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings } from "./wait-core.mjs";
17
- import { resolveAgentProfile, withPrincipalReceipt, PROFILE_FILE } from "./wait-profile.mjs";
17
+ import { resolveAgentProfile, getAgentProfile, findProfileName, findEnvProfile, withPrincipalReceipt, PROFILE_FILE } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
@@ -701,6 +701,131 @@ function profileCredentialRecovery(profile) {
701
701
  return `${profileRecovery(profile)} (stores $${profile.tokenEnv} in the macOS Keychain; on a non-Keychain host export $${profile.tokenEnv} directly); ${HUMAN_PAT_NOTE}`;
702
702
  }
703
703
 
704
+ // BOT-1593: emit ONE secret-free sensor event per setup/auth/parameter failure,
705
+ // so the maintainer can see which onboarding gap agents hit most (and whether the
706
+ // BOT-1590 recovery copy is reducing repeats). Best-effort AND time-bounded — the
707
+ // same stance as docker-hygiene's reportReliability: the receipt has already been
708
+ // written to stdout by the time this runs, so a slow/failing POST must never delay
709
+ // the agent's wake or change the exit path. A 2 s AbortController caps the fetch;
710
+ // timeout / network error / non-2xx are all a silent drop (we never read the body).
711
+ //
712
+ // Never posts without a usable bearer, and never on the session-token path: the
713
+ // ingest endpoint authenticates only allowed_users PATs or agent keys
714
+ // (edgeAuth.ts) — a bb_sess_ session token 401s, so we don't spend the round-trip.
715
+ // The event carries NO secret by construction: no token (only the bearer header,
716
+ // never the body), no session id, no condition params/values, no hostnames/paths —
717
+ // only error code, exit code, condition TYPES, auth path, profile name, cli version.
718
+ const SENSOR_TELEMETRY_TIMEOUT_MS = 2000;
719
+
720
+ async function reportSetupError(opts, { error, exitCode, conditions }) {
721
+ // Never on the session-token path. A bb_agent_/bb_sess_ SESSION token cannot
722
+ // authenticate at sensor-ingest (edgeAuth resolves only PATs / agent keys), so it
723
+ // would only 401. Discriminate by the credential's SOURCE, not its shape: a
724
+ // session token arrives only via $BOTBUDDY_AGENT_KEY / --session-token
725
+ // (opts.sessionToken), whereas a resolved PROFILE credential may legitimately be a
726
+ // legacy bb_agent_ CARRIER key that ingest CAN authenticate — the two share a shape
727
+ // (BOT-1582), so a shape check would wrongly drop the carrier case (Codex P1).
728
+ if (opts.sessionToken) return;
729
+
730
+ // Time-box the WHOLE operation — the implicit-profile file lookup AND the fetch —
731
+ // against one 2 s abort, the reportReliability idiom. findProfileName walks up the
732
+ // directory tree with async readFile; on a slow/unresponsive network or FUSE mount
733
+ // that read can hang, and it runs on fail-fast paths right before process.exit, so
734
+ // starting the timer FIRST and racing the whole worker keeps a nominally fail-fast
735
+ // error inside the advertised bound (Codex P2). On expiry the race resolves and we
736
+ // return; the orphaned lookup is reaped by the imminent process.exit.
737
+ const ac = new AbortController();
738
+ let timer;
739
+ const TIMED_OUT = Symbol("sensor-telemetry-timeout");
740
+ const timeout = new Promise((resolve) => {
741
+ timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, SENSOR_TELEMETRY_TIMEOUT_MS);
742
+ });
743
+ try {
744
+ await Promise.race([sendSetupErrorEvent(opts, { error, exitCode, conditions }, ac.signal), timeout]);
745
+ } catch {
746
+ // Best-effort: an AbortError (2 s timeout) or any transport/lookup error drops.
747
+ } finally {
748
+ clearTimeout(timer);
749
+ }
750
+ }
751
+
752
+ async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal) {
753
+ // Resolve a bearer WITHOUT ever touching the Keychain. This telemetry is
754
+ // best-effort and runs on fail-fast paths right before process.exit; a Keychain
755
+ // read (readProfileCredential → `security find-generic-password`) can block/prompt
756
+ // on a locked or non--A item AND takes no abort signal, so it would leave an
757
+ // orphaned `security` child or an auth dialog behind after the wait exits (Codex
758
+ // P2). So only ever REUSE a token the main flow already resolved (opts.token) or
759
+ // read the profile's env var directly — never a fresh Keychain lookup.
760
+ //
761
+ // Discover the caller's profile — flag or .botbuddy-agent.json — INDEPENDENTLY of
762
+ // whether --token was supplied, so profile/tenant are attached for attribution and
763
+ // dual-member disambiguation even on the documented --token override (without it a
764
+ // multi-tenant bearer is rejected tenant_ambiguous and the event is lost) (Codex P2).
765
+ // The env-slot inference (findEnvProfile) applies ONLY when there is no explicit
766
+ // token — inferring a tenant for an explicit --token bearer could attach one it is
767
+ // not a member of (→ tenant_forbidden drop).
768
+ let profileName = opts.agentProfile?.name ?? opts.profile ?? null;
769
+ if (!profileName) {
770
+ try {
771
+ profileName = await findProfileName(process.cwd());
772
+ } catch {
773
+ profileName = null; // a malformed .botbuddy-agent.json → no profile
774
+ }
775
+ if (!profileName && !opts.token) profileName = findEnvProfile(process.env);
776
+ }
777
+ // Only a KNOWN profile contributes a name/tenant/env-var — an unknown/oversized/
778
+ // sensitive raw --profile value (which the token path never validates) must not
779
+ // ride the secret-free event, matching the wait path's getAgentProfile rejection
780
+ // (Codex P2).
781
+ const known = profileName ? getAgentProfile(profileName) : null;
782
+ let token = opts.token;
783
+ if (!token && known) token = process.env[known.tokenEnv] || null;
784
+ // The only unusable bearer is none at all. A credential the RELAY just rejected
785
+ // (revoked/unauthorized) will 401 at ingest too and drop silently — best-effort, by
786
+ // design; a valid-key failure (session_id_required, wait_actor_required, wrong-tenant)
787
+ // authenticates and records (Codex P1: this is an accepted limitation, see docs).
788
+ if (!token) return;
789
+ // "profile" auth path whenever a profile was discovered (flag/file/env slot), even
790
+ // with a --token override; "explicit_token" only for a bare --token, no profile.
791
+ const explicit = !!opts.token && !profileName;
792
+ const tenant = opts.agentProfile?.tenant ?? known?.tenant ?? null;
793
+ const safeProfile = known ? profileName : null;
794
+ // Omit the explicit tenant for a wrong-tenant credential: the profile's expected
795
+ // tenant is NOT one the bearer belongs to, and sensor-ingest's resolveWriteTenant
796
+ // rejects an unauthorized explicit tenant (tenant_forbidden) → the event would drop.
797
+ // Without it the server resolves the bearer's ACTUAL tenant and the row records
798
+ // there instead (Codex P1). Every other error's tenant equals the credential's own.
799
+ const includeTenant = tenant && error !== "profile_credential_wrong_tenant";
800
+ const conditionTypes = Array.isArray(conditions)
801
+ ? [...new Set(conditions.map((c) => c.type))].sort()
802
+ : [];
803
+ const event = {
804
+ ...(includeTenant ? { tenant } : {}),
805
+ source: "bb-wait",
806
+ kind: "wait_setup_error",
807
+ dedupe_key: `bb-wait:${error}:${crypto.randomUUID()}`,
808
+ occurred_at: new Date().toISOString(),
809
+ payload: {
810
+ error,
811
+ exit_code: exitCode,
812
+ condition_types: conditionTypes,
813
+ auth_path: explicit ? "explicit_token" : "profile",
814
+ profile: safeProfile,
815
+ cli_version: VERSION,
816
+ },
817
+ };
818
+ await fetch(`${opts.url.replace(/\/$/, "")}/sensor-ingest`, {
819
+ method: "POST",
820
+ headers: {
821
+ Authorization: `Bearer ${token}`,
822
+ "Content-Type": "application/json",
823
+ },
824
+ body: JSON.stringify(event),
825
+ signal,
826
+ });
827
+ }
828
+
704
829
  export async function runWait(argv) {
705
830
  const opts = parseArgv(argv);
706
831
  const emitReceipt = (receipt, options = {}) => emit(receipt, {
@@ -727,6 +852,9 @@ export async function runWait(argv) {
727
852
  for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
728
853
  process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
729
854
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
855
+ // BOT-1593: condition_types is [] on a grammar failure (the parsed set is
856
+ // partial/empty and is caller data regardless).
857
+ await reportSetupError(opts, { error: "invalid_conditions", exitCode: EXIT.INVALID, conditions: [] });
730
858
  process.exit(EXIT.INVALID);
731
859
  }
732
860
 
@@ -753,11 +881,13 @@ export async function runWait(argv) {
753
881
  if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
754
882
  process.stderr.write(`botbuddy wait: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; ${RECOVERY.sessionToken}\n`);
755
883
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
884
+ await reportSetupError(opts, { error: "invalid_session_token", exitCode: EXIT.INVALID, conditions });
756
885
  process.exit(EXIT.INVALID);
757
886
  }
758
887
  if (opts.token && opts.token !== opts.sessionToken) {
759
888
  process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_KEY; ${RECOVERY.sessionTokenConflict}\n`);
760
889
  emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
890
+ await reportSetupError(opts, { error: "session_token_conflict", exitCode: EXIT.INVALID, conditions });
761
891
  process.exit(EXIT.INVALID);
762
892
  }
763
893
  opts.useSessionToken = true;
@@ -777,11 +907,13 @@ export async function runWait(argv) {
777
907
  error: "session_id_required",
778
908
  recovery: RECOVERY.sessionId,
779
909
  });
910
+ await reportSetupError(opts, { error: "session_id_required", exitCode: EXIT.INVALID, conditions });
780
911
  process.exit(EXIT.INVALID);
781
912
  }
782
913
  if (!SESSION_UUID.test(opts.sessionId)) {
783
914
  process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}'); ${RECOVERY.sessionId}\n`);
784
915
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid", recovery: RECOVERY.sessionId });
916
+ await reportSetupError(opts, { error: "invalid_session_agent", exitCode: EXIT.INVALID, conditions });
785
917
  process.exit(EXIT.INVALID);
786
918
  }
787
919
  try {
@@ -794,6 +926,7 @@ export async function runWait(argv) {
794
926
  const recovery = profileResolutionRecovery(code);
795
927
  process.stderr.write(`bb-wait: ${err.message}; ${recovery}\n`);
796
928
  emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
929
+ await reportSetupError(opts, { error: code, exitCode: EXIT.INVALID, conditions });
797
930
  process.exit(EXIT.INVALID);
798
931
  }
799
932
  opts.token = opts.agentProfile.token;
@@ -802,6 +935,10 @@ export async function runWait(argv) {
802
935
  `botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run ${profileCredentialRecovery(opts.agentProfile)}\n`,
803
936
  );
804
937
  emitReceipt(profileErrorReceipt(opts.agentProfile, "profile_required"));
938
+ // No credential resolved (opts.token is null here), so reportSetupError has
939
+ // no bearer to authenticate with and self-skips — call it for allowlist
940
+ // symmetry; it is a no-op until a token exists.
941
+ await reportSetupError(opts, { error: "profile_required", exitCode: EXIT.AUTH, conditions });
805
942
  process.exit(EXIT.AUTH);
806
943
  }
807
944
  }
@@ -924,6 +1061,9 @@ export async function runWait(argv) {
924
1061
  error: err.errorCode || "unauthorized",
925
1062
  recovery: "register_agent → export BOTBUDDY_AGENT_KEY=<session_token>",
926
1063
  }, opts.agentProfile, { sessionTenant, agentId: registeredAgentId, sessionId: registeredSessionId }));
1064
+ // Session-token path → reportSetupError self-skips (a bb_sess_ bearer
1065
+ // ingest cannot authenticate); called for allowlist symmetry.
1066
+ await reportSetupError(opts, { error: err.errorCode || "unauthorized", exitCode: EXIT.AUTH, conditions });
927
1067
  process.exit(EXIT.AUTH);
928
1068
  }
929
1069
  // BOT-1554: these are all SESSION-IDENTITY failures — the supplied session id
@@ -951,11 +1091,13 @@ export async function runWait(argv) {
951
1091
  ...(err.carrierAgentId ? { carrier_agent_id: err.carrierAgentId } : {}),
952
1092
  recovery: "register_agent → export BOTBUDDY_SESSION_ID=<new session_id>",
953
1093
  }, opts.agentProfile, { sessionTenant: opts.agentProfile?.tenant ?? sessionTenant ?? null, agentId: null }));
1094
+ await reportSetupError(opts, { error: err.errorCode, exitCode: EXIT.AUTH, conditions });
954
1095
  process.exit(EXIT.AUTH);
955
1096
  }
956
1097
  const error = typedProfileError(err.errorCode);
957
1098
  process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run ${profileCredentialRecovery(opts.agentProfile)}\n`);
958
1099
  emitReceipt(profileErrorReceipt(opts.agentProfile, error));
1100
+ await reportSetupError(opts, { error, exitCode: EXIT.AUTH, conditions });
959
1101
  process.exit(EXIT.AUTH);
960
1102
  }
961
1103
  if (err && err.cap) {