@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/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/pw/coordinator.mjs
CHANGED
|
@@ -5,7 +5,13 @@ function mcpCaller(authHeader, fetchImpl) {
|
|
|
5
5
|
let id = 0;
|
|
6
6
|
return async function call(name, args) {
|
|
7
7
|
const response = await fetchImpl(SERVER_URL, { method: "POST", headers: { "content-type": "application/json", ...authHeader }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }) });
|
|
8
|
-
if (!response.ok)
|
|
8
|
+
if (!response.ok) {
|
|
9
|
+
let payload = null;
|
|
10
|
+
try { payload = await response.json(); } catch { /* keep the HTTP diagnostic */ }
|
|
11
|
+
const error = new Error(payload?.error?.message ?? payload?.error ?? `BotBuddy lock verification returned HTTP ${response.status}`);
|
|
12
|
+
error.code = payload?.error?.code ?? payload?.error?.error ?? payload?.code ?? payload?.error ?? null;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
9
15
|
const json = await response.json(); if (json.error) throw new Error(json.error.message || "BotBuddy lock verification failed");
|
|
10
16
|
const text = json.result?.content?.find((item) => item.type === "text")?.text; try { return text ? JSON.parse(text) : {}; } catch { throw new Error("BotBuddy lock verification returned invalid JSON"); }
|
|
11
17
|
};
|
package/src/pw/run.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { canonicalizeHostString } from "./host.mjs";
|
|
|
7
7
|
import { resolveAgentBinding } from "../wait-profile.mjs";
|
|
8
8
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
9
9
|
import { VERSION } from "../version.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { readAgentSessionTokenEnv, AGENT_KEY_RE } from "../agent-key.mjs";
|
|
11
|
+
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
|
|
11
12
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
12
13
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
13
14
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
@@ -23,19 +24,19 @@ function redact(value, secretValues = []) { return secretValues.reduce((text, se
|
|
|
23
24
|
async function readRegisteredAgentId() { try { await loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
|
|
24
25
|
async function gate({ env, host, lane, deps }) {
|
|
25
26
|
if (env.BB_PW_NO_LOCK === "1") return { allowed: true };
|
|
26
|
-
// BOT-
|
|
27
|
+
// BOT-1649: a per-session `bb_sess_` token authenticates lock verification
|
|
27
28
|
// AS the session agent — the server resolves it, holder matching rides on
|
|
28
|
-
// owner_is_caller (plus any local session/registered agent id).
|
|
29
|
-
// is the
|
|
29
|
+
// owner_is_caller (plus any local session/registered agent id).
|
|
30
|
+
// $BOTBUDDY_AGENT_SESSION_TOKEN is canonical; the former names remain aliases.
|
|
30
31
|
//
|
|
31
32
|
// BOT-1608: the retired profile-identity path is gone (its `agent-profiles.json`
|
|
32
33
|
// store no longer exists). When no session token is exported, fall back to the
|
|
33
34
|
// worktree binding's mcp_env credential (the `bb_mcp_` key from `.mcp.json`) —
|
|
34
35
|
// it authenticates the status call the same way and matching still rides on the
|
|
35
36
|
// server's owner_is_caller.
|
|
36
|
-
const sessionToken = deps.sessionToken ??
|
|
37
|
+
const sessionToken = deps.sessionToken ?? readAgentSessionTokenEnv(env);
|
|
37
38
|
if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
|
|
38
|
-
return { allowed: false, message: "bb-pw:
|
|
39
|
+
return { allowed: false, message: "bb-pw: the supplied session override is malformed. Remove it and run bb setup from this project worktree, then retry. See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
|
|
39
40
|
}
|
|
40
41
|
const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
|
|
41
42
|
const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
|
|
@@ -49,7 +50,7 @@ async function gate({ env, host, lane, deps }) {
|
|
|
49
50
|
} catch { token = null; }
|
|
50
51
|
}
|
|
51
52
|
if (!token) {
|
|
52
|
-
return { allowed: false, message: "bb-pw: no
|
|
53
|
+
return { allowed: false, message: "bb-pw: no worktree session — run bb setup from this project worktree, then retry. See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
|
|
53
54
|
}
|
|
54
55
|
coordinator = createSessionTokenCoordinator({ token, fetchImpl: deps.fetch });
|
|
55
56
|
}
|
|
@@ -82,7 +83,7 @@ async function gate({ env, host, lane, deps }) {
|
|
|
82
83
|
const holderName = status.heldByName ? ` (${status.heldByName})` : "";
|
|
83
84
|
return { allowed: false, message: `bb-pw: lane lock ${foundLaneName} is held by ${holder}${holderName}, not you (caller ${callerId}). Acquire it first or set BB_PW_NO_LOCK=1 for local-only work.` };
|
|
84
85
|
} catch (error) {
|
|
85
|
-
return { allowed: false, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
|
|
86
|
+
return { allowed: false, errorCode: error?.code ?? null, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
// BOT-1522: a stale global install (1.6.1 on the reporting host) replayed the
|
|
@@ -101,16 +102,41 @@ export async function runPw(argv, deps = {}) {
|
|
|
101
102
|
async function runPwInner(argv, deps = {}) {
|
|
102
103
|
const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
|
|
103
104
|
if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
|
|
104
|
-
// BOT-
|
|
105
|
+
// BOT-1649: --agent-session-token / $BOTBUDDY_AGENT_SESSION_TOKEN is canonical;
|
|
106
|
+
// legacy flags and env aliases remain accepted as a
|
|
105
107
|
// session identity alongside --session-id, so a token-armed session need not
|
|
106
108
|
// pass an id. Holder matching still rides on the server's owner_is_caller and
|
|
107
109
|
// the resolved agent ids (gate()).
|
|
108
|
-
while (
|
|
110
|
+
while (["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"].includes(args[0])) { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--tenant" ? { ...deps, tenant: args[1] } : ["--agent-session-token", "--agent-key", "--session-token"].includes(flag) ? { ...deps, sessionToken: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
|
|
109
111
|
let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
|
|
110
112
|
const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
|
|
111
113
|
if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
|
|
114
|
+
// Local-only lanes explicitly opt out of lock ownership, so they also need no
|
|
115
|
+
// remote session mint. Every lock-gated shipped invocation shares the resolver.
|
|
116
|
+
let managedCredential = null;
|
|
117
|
+
if (env.BB_PW_NO_LOCK !== "1" && !deps.sessionToken && !readAgentSessionTokenEnv(env) && (!deps.coordinator || deps.resolveCredential)) {
|
|
118
|
+
try {
|
|
119
|
+
const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
|
|
120
|
+
managedCredential = credential;
|
|
121
|
+
deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
stderr.write(`bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`);
|
|
124
|
+
return 3;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
112
127
|
const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env }); const host = deps.host ?? hostFor(env);
|
|
113
|
-
|
|
128
|
+
let auth = await gate({ env, host, lane: plan.lane, deps });
|
|
129
|
+
// Only an ACTUAL auth rejection carries an errorCode (the coordinator surfaces
|
|
130
|
+
// the MCP auth code). A plain lock denial (lane held by another agent) returns
|
|
131
|
+
// allowed:false with NO errorCode and must never rotate the session (Codex round-5).
|
|
132
|
+
if (!auth.allowed && !deps.coordinator && auth.errorCode != null && isRejectedCachedMcpSession({ auth: true, code: auth.errorCode }, managedCredential?.source, 0)) {
|
|
133
|
+
await clearAgentState(deps.cwd ?? process.cwd());
|
|
134
|
+
const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
|
|
135
|
+
managedCredential = credential;
|
|
136
|
+
deps = { ...deps, sessionToken: credential.token, sessionId: credential.sessionId ?? deps.sessionId ?? null };
|
|
137
|
+
auth = await gate({ env, host, lane: plan.lane, deps });
|
|
138
|
+
}
|
|
139
|
+
if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
|
|
114
140
|
// Report the host the lock service actually stored (alias-collapsed), so the
|
|
115
141
|
// status view and lane telemetry match the server, not the local string (AC-4).
|
|
116
142
|
const effHost = auth.canonicalHost ?? host;
|
package/src/run.mjs
CHANGED
|
@@ -14,14 +14,15 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { callToolJson } from "./api.mjs";
|
|
16
16
|
import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
|
|
17
|
-
import { AGENT_KEY_RE,
|
|
17
|
+
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
18
|
+
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
18
19
|
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
19
20
|
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
20
21
|
import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
|
|
21
22
|
import { VERSION } from "./version.mjs";
|
|
22
23
|
|
|
23
24
|
export const RUN_SCHEMA_VERSION = 1;
|
|
24
|
-
export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
|
|
25
|
+
export const EXIT = Object.freeze({ OK: 0, AUTH: 3, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
|
|
25
26
|
export const DEFAULT_TIMEOUT_SECONDS = 8 * 60 * 60;
|
|
26
27
|
export const TIMEOUT_GRACE_MS = 5_000;
|
|
27
28
|
const MAX_CAPTURE_BYTES = 8_192;
|
|
@@ -30,16 +31,16 @@ const MAX_CAPTURE_BYTES = 8_192;
|
|
|
30
31
|
const LANE_FLUSH_INTERVAL_MS = 2_500;
|
|
31
32
|
const FOREGROUND_KINDS = new Set(["test", "wait", "browser", "ci", "hook", "stack"]);
|
|
32
33
|
|
|
33
|
-
// BOT-
|
|
34
|
-
//
|
|
34
|
+
// BOT-1649: the canonical session-token shape is bb_sess_ + 64 hex; bb_agent_
|
|
35
|
+
// remains accepted as a one-release legacy alias.
|
|
35
36
|
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
36
37
|
|
|
37
38
|
export function parseRunArgs(argv, env = process.env) {
|
|
38
|
-
// BOT-
|
|
39
|
+
// BOT-1649: $BOTBUDDY_AGENT_SESSION_TOKEN authenticates the run and carries its
|
|
39
40
|
// session, so --session-id becomes optional (the relay derives it). The old
|
|
40
41
|
// $BOTBUDDY_SESSION_TOKEN is still accepted for one release. $BOTBUDDY_SESSION_ID
|
|
41
42
|
// is the default when a plain id is used (parity with `botbuddy test`).
|
|
42
|
-
const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken:
|
|
43
|
+
const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentSessionTokenEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, foreground: false, json: false };
|
|
43
44
|
const errors = [];
|
|
44
45
|
const separator = argv.indexOf("--");
|
|
45
46
|
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
@@ -52,6 +53,8 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
52
53
|
const flag = flags[i];
|
|
53
54
|
switch (flag) {
|
|
54
55
|
case "--session-id": opts.sessionId = value(flag, i); i++; break;
|
|
56
|
+
case "--agent-session-token":
|
|
57
|
+
case "--agent-key":
|
|
55
58
|
case "--session-token": opts.sessionToken = value(flag, i); i++; break;
|
|
56
59
|
case "--environment": opts.environment = value(flag, i); i++; break;
|
|
57
60
|
case "--category": opts.category = value(flag, i); i++; break;
|
|
@@ -75,8 +78,8 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
75
78
|
// BOT-1572: a session token stands in for --session-id (the backend derives the
|
|
76
79
|
// session from the token). A malformed token, or a plain id AND a differing
|
|
77
80
|
// token, is a hard error.
|
|
78
|
-
if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$
|
|
79
|
-
if (!opts.foreground && !opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $
|
|
81
|
+
if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex> (bb_agent_ is a legacy alias)");
|
|
82
|
+
if (!opts.foreground && !opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_SESSION_TOKEN");
|
|
80
83
|
if (opts.foreground && !FOREGROUND_KINDS.has(opts.kind)) errors.push("--kind must be test, wait, browser, ci, hook, or stack with --foreground");
|
|
81
84
|
if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
|
|
82
85
|
if (!command.length) errors.push("a workload is required after --");
|
|
@@ -228,8 +231,40 @@ function workerInvocation(context) {
|
|
|
228
231
|
return [process.execPath, [bin, "run", "--worker", context]];
|
|
229
232
|
}
|
|
230
233
|
|
|
231
|
-
export async function launchRun(argv, {
|
|
232
|
-
|
|
234
|
+
export async function launchRun(argv, {
|
|
235
|
+
cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
|
|
236
|
+
resolveCredential = null, env = process.env,
|
|
237
|
+
} = {}) {
|
|
238
|
+
let effectiveArgv = argv;
|
|
239
|
+
let parsed = parseRunArgs(effectiveArgv, env);
|
|
240
|
+
let managedCredential = null;
|
|
241
|
+
// A local test double owns its credential boundary; production invocations
|
|
242
|
+
// self-heal when the session credential was not supplied by flag or env.
|
|
243
|
+
// Keep all other usage failures local and deterministic before minting.
|
|
244
|
+
const onlyMissingIdentity = parsed.errors.length === 1 && parsed.errors[0].includes("session-id");
|
|
245
|
+
if (!parsed.opts.foreground && !parsed.opts.sessionId && !parsed.opts.sessionToken
|
|
246
|
+
&& onlyMissingIdentity && (resolveCredential || call === null)) {
|
|
247
|
+
try {
|
|
248
|
+
const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
|
|
249
|
+
managedCredential = credential;
|
|
250
|
+
effectiveArgv = ["--agent-session-token", credential.token, ...argv];
|
|
251
|
+
parsed = parseRunArgs(effectiveArgv, env);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return {
|
|
254
|
+
exitCode: EXIT.AUTH,
|
|
255
|
+
receipt: {
|
|
256
|
+
schema_version: RUN_SCHEMA_VERSION,
|
|
257
|
+
outcome: "rejected",
|
|
258
|
+
error: error?.code ?? "agent_session_unavailable",
|
|
259
|
+
recovery: error?.code === "client_key_required"
|
|
260
|
+
? "run `bb login` to install the durable client key, then retry"
|
|
261
|
+
: "run `bb doctor --fix` to repair agent authentication, then retry",
|
|
262
|
+
exit_code: EXIT.AUTH,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const { opts, command, errors } = parsed;
|
|
233
268
|
if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
|
|
234
269
|
// BOT-1572 (AC-8): when a session token is present it is the credential — pin it
|
|
235
270
|
// so register_command authenticates as the session's agent and derives session_id
|
|
@@ -238,7 +273,7 @@ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn,
|
|
|
238
273
|
? (t, a) => callToolJson(t, a, { auth: { "x-agent-api-key": opts.sessionToken } })
|
|
239
274
|
: callToolJson);
|
|
240
275
|
const runId = randomUUID();
|
|
241
|
-
const
|
|
276
|
+
const registerArgs = {
|
|
242
277
|
// Omit session_id entirely when only a token is present — the backend derives
|
|
243
278
|
// it. A plain id is still sent (and honoured) when supplied.
|
|
244
279
|
...(opts.sessionId ? { session_id: opts.sessionId } : {}),
|
|
@@ -246,7 +281,19 @@ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn,
|
|
|
246
281
|
command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
|
|
247
282
|
environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
|
|
248
283
|
rerun_reason: opts.rerunReason,
|
|
249
|
-
}
|
|
284
|
+
};
|
|
285
|
+
let registration = await effectiveCall("register_command", registerArgs);
|
|
286
|
+
// A cached session can be revoked server-side before its local expiry. The
|
|
287
|
+
// managed cache is ours to replace once; explicit flag/env credentials belong
|
|
288
|
+
// to the caller and are never silently rotated.
|
|
289
|
+
if (isRejectedCachedMcpSession(registration, managedCredential?.source, 0)) {
|
|
290
|
+
await clearAgentState(cwd);
|
|
291
|
+
const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd });
|
|
292
|
+
managedCredential = credential;
|
|
293
|
+
opts.sessionToken = credential.token;
|
|
294
|
+
opts.sessionId = credential.sessionId ?? opts.sessionId;
|
|
295
|
+
registration = await effectiveCall("register_command", registerArgs);
|
|
296
|
+
}
|
|
250
297
|
if (!registration.ok || registration.isError || !registration.data?.accepted || !registration.data?.run_credential) {
|
|
251
298
|
return { exitCode: EXIT.BACKEND, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", run_id: runId, error: registration.data?.error ?? registration.error ?? "durable_owner_not_established", exit_code: EXIT.BACKEND } };
|
|
252
299
|
}
|
package/src/setup-block.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// guide all agree. The snapshot test (agent-connectivity.test.mjs) asserts the
|
|
5
5
|
// SETUP block appears verbatim in all four help outputs, so drift is caught.
|
|
6
6
|
//
|
|
7
|
-
// This documents the
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// This documents the supported BOT-1649 process. The CLI owns registration and
|
|
8
|
+
// session persistence; normal operator output must never turn that into a
|
|
9
|
+
// multi-command credential handoff.
|
|
10
10
|
|
|
11
11
|
/** The canonical guide, referenced from every SETUP block and recovery. */
|
|
12
12
|
export const CONNECTIVITY_GUIDE = "docs/agent-connectivity.md";
|
|
@@ -35,41 +35,30 @@ export const TIERS = Object.freeze({
|
|
|
35
35
|
setup: "bb mcp setup",
|
|
36
36
|
scope: "tenant",
|
|
37
37
|
}),
|
|
38
|
-
// Tier 3 — per-session agent token
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// attribution).
|
|
38
|
+
// Tier 3 — per-session agent token, tenant-bound, 8 h TTL, revoked on
|
|
39
|
+
// re-register. The CLI caches it in .botbuddy/agent-state.json and restores
|
|
40
|
+
// it automatically for wait/run/test/pw.
|
|
42
41
|
session: Object.freeze({
|
|
43
42
|
n: 3,
|
|
44
43
|
label: "session token",
|
|
45
|
-
prefix: "
|
|
46
|
-
env: "
|
|
44
|
+
prefix: "bb_sess_",
|
|
45
|
+
env: "BOTBUDDY_AGENT_SESSION_TOKEN",
|
|
47
46
|
setup: "register_agent",
|
|
48
47
|
scope: "session",
|
|
49
48
|
}),
|
|
50
49
|
});
|
|
51
50
|
|
|
52
|
-
// The register_agent → export handoff, named once. Recovery strings reuse it so
|
|
53
|
-
// the exact two export lines never drift from the guide.
|
|
54
|
-
export const EXPORT_STEP =
|
|
55
|
-
"register_agent → export BOTBUDDY_AGENT_KEY=<session_token> (and BOTBUDDY_SESSION_ID=<session_id>)";
|
|
56
|
-
|
|
57
51
|
/**
|
|
58
52
|
* The shared SETUP block reproduced verbatim in every `--help` output. Plain
|
|
59
53
|
* text (no ANSI) so the snapshot test can match it byte-for-byte across surfaces.
|
|
60
|
-
*
|
|
54
|
+
* Keep it to the normal two-command flow; low-level credential plumbing belongs
|
|
55
|
+
* only in the advanced reference.
|
|
61
56
|
*/
|
|
62
|
-
export const SETUP_BLOCK = `SETUP —
|
|
57
|
+
export const SETUP_BLOCK = `SETUP — one command, then wait
|
|
63
58
|
1. npm i -g @botbuddy/cli install the CLI
|
|
64
|
-
2. bb
|
|
65
|
-
3. bb
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
4. register_agent (over MCP) tier 3 · session token (bb_agent_, per-session, 8 h)
|
|
70
|
-
5. export BOTBUDDY_AGENT_KEY=<session_token> and BOTBUDDY_SESSION_ID=<session_id>
|
|
71
|
-
6. bb wait needs the tier-3 token; bb run/test also accept $BOTBUDDY_SESSION_ID,
|
|
72
|
-
bb pw falls back to the tier-2 MCP key
|
|
73
|
-
NOTE: each Claude Code Bash call is a FRESH shell — exports do NOT persist between tool calls;
|
|
74
|
-
re-export (or pass --agent-key <token>) in the same command that runs the wait.
|
|
59
|
+
2. bb setup [--tenant <slug>] sign in, verify this repo binding, and cache a worktree session
|
|
60
|
+
3. bb wait '<condition>' no environment variables or setup flags required
|
|
61
|
+
NOTE: setup keeps one user-scoped client key; --tenant only validates this worktree binding.
|
|
62
|
+
bb wait restores or renews its cached worktree session automatically.
|
|
63
|
+
bb mcp setup is separate and only needed to configure an MCP client.
|
|
75
64
|
Full guide: ${CONNECTIVITY_GUIDE}`;
|