@botbuddy/cli 1.30.0 → 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/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/test-lane.mjs +45 -14
- package/src/wait.mjs +245 -168
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}`;
|
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,
|