@botbuddy/cli 1.20.0 → 1.21.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-key.mjs +29 -0
- package/src/credential-kinds.mjs +9 -5
- package/src/pw/coordinator.mjs +1 -1
- package/src/pw/run.mjs +8 -6
- package/src/run.mjs +10 -7
- package/src/test-lane.mjs +9 -7
- package/src/wait-profile.mjs +13 -0
- package/src/wait.mjs +167 -18
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// BOT-1582 — the per-session agent key the CLI presents to the relay for
|
|
2
|
+
// `botbuddy wait`/`run`/`test`/`pw`.
|
|
3
|
+
//
|
|
4
|
+
// register_agent mints this token (bound to the work-graph session it returns)
|
|
5
|
+
// and the relay derives agent, tenant, and the arming session from it, so a
|
|
6
|
+
// session that exports it needs no --profile/--session-id/--token.
|
|
7
|
+
//
|
|
8
|
+
// Prefix: `bb_agent_` + 64 lowercase hex, mirroring the server's SESSION_TOKEN_RE
|
|
9
|
+
// (supabase/functions/_shared/sessionToken.ts). The BOT-1572 `bb_sess_` shape is
|
|
10
|
+
// accepted as a legacy alias for one release (its hash is already on `sessions`).
|
|
11
|
+
// This is a SHAPE check only — authorization is the server's hashed lookup.
|
|
12
|
+
export const AGENT_KEY_RE = /^bb_(agent|sess)_[0-9a-f]{64}$/;
|
|
13
|
+
|
|
14
|
+
// BOT-1582: the env var was renamed BOTBUDDY_SESSION_TOKEN → BOTBUDDY_AGENT_KEY.
|
|
15
|
+
// The new name wins; the old one is honoured for one release so a session that
|
|
16
|
+
// still exports it keeps working. Returns null when neither is set.
|
|
17
|
+
//
|
|
18
|
+
// An EMPTY/blank value counts as unset (not as a present-but-empty token): an env
|
|
19
|
+
// template that declares BOTBUDDY_AGENT_KEY="" while a valid BOTBUDDY_SESSION_TOKEN
|
|
20
|
+
// is still exported must fall through to the legacy name, or the one-release
|
|
21
|
+
// compatibility guarantee breaks (Codex P2). So trim and skip blanks rather than
|
|
22
|
+
// `??`, which would stop at the empty string.
|
|
23
|
+
export function readAgentKeyEnv(env = process.env) {
|
|
24
|
+
const first = typeof env.BOTBUDDY_AGENT_KEY === "string" ? env.BOTBUDDY_AGENT_KEY.trim() : "";
|
|
25
|
+
if (first) return env.BOTBUDDY_AGENT_KEY;
|
|
26
|
+
const legacy = typeof env.BOTBUDDY_SESSION_TOKEN === "string" ? env.BOTBUDDY_SESSION_TOKEN.trim() : "";
|
|
27
|
+
if (legacy) return env.BOTBUDDY_SESSION_TOKEN;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/credential-kinds.mjs
CHANGED
|
@@ -17,11 +17,15 @@ export const CREDENTIAL_PREFIXES = [
|
|
|
17
17
|
{ prefix: "mcp_at_", kind: "oauth", label: "OAuth owner token" },
|
|
18
18
|
{ prefix: "bb_pat_", kind: "pat", label: "personal access token" },
|
|
19
19
|
{ prefix: "bb_cli_", kind: "cli", label: "client key" },
|
|
20
|
-
// BOT-1573/1582 transition:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
20
|
+
// BOT-1573/1582 transition: bb_agent_ is now BOTH the per-session agent token
|
|
21
|
+
// (BOT-1582 renamed it from bb_sess_) AND the older shared service carrier, so
|
|
22
|
+
// the prefix ALONE can no longer tell them apart. Classification is display
|
|
23
|
+
// only (never auth), and mislabelling a live carrier as an agent is worse than
|
|
24
|
+
// the reverse, so bb_agent_ stays classified as the carrier — matching the UI
|
|
25
|
+
// adapter. bb_sess_ (in-flight BOT-1572 tokens, accepted one release) is the
|
|
26
|
+
// one unambiguously-a-session shape, so it keeps the agent kind. A
|
|
27
|
+
// shape-independent way to name a live bb_agent_ session token is deferred to
|
|
28
|
+
// the taxonomy/status work (BOT-1573).
|
|
25
29
|
{ prefix: "bb_agent_", kind: "svc", label: "carrier (legacy)" },
|
|
26
30
|
{ prefix: "bb_sess_", kind: "agent", label: "agent (session)" },
|
|
27
31
|
{ prefix: "bb_ci_", kind: "ci", label: "CI key" },
|
package/src/pw/coordinator.mjs
CHANGED
|
@@ -15,7 +15,7 @@ function laneCoordinator(kind, call, agentId) {
|
|
|
15
15
|
return { kind, async status({ host, slot }) { const result = await call("list_resources", { host, subtype: "playwright_lane" }); const list = Array.isArray(result) ? result : result.resources ?? []; const resource = list.find((item) => item.name === `playwright_lane:${host}:${slot}` || String(item.slot) === String(slot)); /* BOT-1490: canonical_host is a TOP-LEVEL field the server echoes even on an empty page (not-held lane); owner_is_caller is per-row. Older servers send neither → null. */ const canonicalHost = (result && !Array.isArray(result) ? result.canonical_host : null) ?? null; return resource ? { held: resource.status !== "free", heldBy: resource.owner_agent_id ?? null, heldByName: resource.agents?.name ?? null, host: resource.host ?? null, name: resource.name ?? null, slot: resource.slot ?? null, canonicalHost, ownerIsCaller: resource.owner_is_caller ?? null } : { held: false, heldBy: null, heldByName: null, host: null, name: null, slot: null, canonicalHost, ownerIsCaller: null }; }, async emit(event) { await call("record_lane_event", event); }, agentId };
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// BOT-1572: authenticate lock verification with a per-session `
|
|
18
|
+
// BOT-1572/1582: authenticate lock verification with a per-session `bb_agent_` token —
|
|
19
19
|
// the server resolves it to the session agent (mcp-server authenticateAgent), so
|
|
20
20
|
// `list_resources`/`record_lane_event` run AS the session, and holder matching
|
|
21
21
|
// rides on the server's `owner_is_caller`. No machine profile is required.
|
package/src/pw/run.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveAgentProfile } from "../wait-profile.mjs";
|
|
|
8
8
|
import { readProfileIdentity } from "../agent-credential-store.mjs";
|
|
9
9
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
10
10
|
import { VERSION } from "../version.mjs";
|
|
11
|
+
import { readAgentKeyEnv } from "../agent-key.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,18 +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-1572: a per-session `
|
|
27
|
-
// the session agent — no machine profile needed. It takes precedence over the
|
|
27
|
+
// BOT-1572/1582: a per-session `bb_agent_` token authenticates lock verification
|
|
28
|
+
// AS the session agent — no machine profile needed. It takes precedence over the
|
|
28
29
|
// profile path; holder matching then rides on the server's owner_is_caller
|
|
29
30
|
// (plus any local session/registered agent id). The profile path stays intact
|
|
30
|
-
// for a session that has not adopted the token.
|
|
31
|
-
|
|
31
|
+
// for a session that has not adopted the token. $BOTBUDDY_AGENT_KEY is the norm
|
|
32
|
+
// ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
|
|
33
|
+
const sessionToken = deps.sessionToken ?? readAgentKeyEnv(env);
|
|
32
34
|
const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
|
|
33
35
|
const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
|
|
34
36
|
let coordinator, selfAgentIds, callerId;
|
|
35
37
|
if (sessionToken) {
|
|
36
38
|
coordinator = deps.coordinator ?? createSessionTokenCoordinator({ token: sessionToken, fetchImpl: deps.fetch });
|
|
37
|
-
if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: $
|
|
39
|
+
if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: $BOTBUDDY_AGENT_KEY is malformed. Re-register the agent, or set BB_PW_NO_LOCK=1 for local-only work." };
|
|
38
40
|
selfAgentIds = new Set([sessionAgentId, registeredAgentId, coordinator.agentId].filter(Boolean));
|
|
39
41
|
callerId = coordinator.agentId ?? sessionAgentId ?? "session-token";
|
|
40
42
|
} else {
|
|
@@ -99,7 +101,7 @@ export async function runPw(argv, deps = {}) {
|
|
|
99
101
|
async function runPwInner(argv, deps = {}) {
|
|
100
102
|
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; }
|
|
101
103
|
if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
|
|
102
|
-
// BOT-1572 (AC-8): --session-token / $
|
|
104
|
+
// BOT-1572/1582 (AC-8): --session-token / $BOTBUDDY_AGENT_KEY is accepted as a
|
|
103
105
|
// session identity alongside --session-id, so a token-armed session need not
|
|
104
106
|
// pass an id. Holder matching still rides on the server's owner_is_caller and
|
|
105
107
|
// the resolved agent ids (gate()).
|
package/src/run.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { spawn } from "child_process";
|
|
|
14
14
|
import { fileURLToPath } from "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, readAgentKeyEnv } from "./agent-key.mjs";
|
|
17
18
|
|
|
18
19
|
export const RUN_SCHEMA_VERSION = 1;
|
|
19
20
|
export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
|
|
@@ -24,14 +25,16 @@ const MAX_CAPTURE_BYTES = 8_192;
|
|
|
24
25
|
// Well under AC-3's 5 s so a begin/verdict is visible within the window.
|
|
25
26
|
const LANE_FLUSH_INTERVAL_MS = 2_500;
|
|
26
27
|
|
|
27
|
-
// BOT-
|
|
28
|
-
|
|
28
|
+
// BOT-1582: the server's session-token shape (bb_agent_ + 64 hex) plus the
|
|
29
|
+
// BOT-1572 bb_sess_ legacy alias, both accepted for one release.
|
|
30
|
+
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
29
31
|
|
|
30
32
|
export function parseRunArgs(argv, env = process.env) {
|
|
31
|
-
// BOT-1572 (AC-8): $
|
|
32
|
-
// session, so --session-id becomes optional (the relay derives it).
|
|
33
|
+
// BOT-1572/1582 (AC-8): $BOTBUDDY_AGENT_KEY authenticates the run and carries its
|
|
34
|
+
// session, so --session-id becomes optional (the relay derives it). The old
|
|
35
|
+
// $BOTBUDDY_SESSION_TOKEN is still accepted for one release. $BOTBUDDY_SESSION_ID
|
|
33
36
|
// is the default when a plain id is used (parity with `botbuddy test`).
|
|
34
|
-
const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: env
|
|
37
|
+
const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentKeyEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
|
|
35
38
|
const errors = [];
|
|
36
39
|
const separator = argv.indexOf("--");
|
|
37
40
|
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
@@ -66,8 +69,8 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
66
69
|
// BOT-1572: a session token stands in for --session-id (the backend derives the
|
|
67
70
|
// session from the token). A malformed token, or a plain id AND a differing
|
|
68
71
|
// token, is a hard error.
|
|
69
|
-
if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$
|
|
70
|
-
if (!opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $
|
|
72
|
+
if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>");
|
|
73
|
+
if (!opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
|
|
71
74
|
if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
|
|
72
75
|
if (!command.length) errors.push("a workload is required after --");
|
|
73
76
|
if (!Number.isInteger(opts.expectedDuration) || opts.expectedDuration < 0) errors.push("--expected-duration must be a non-negative integer");
|
package/src/test-lane.mjs
CHANGED
|
@@ -21,6 +21,7 @@ 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 { readAgentKeyEnv } from "./agent-key.mjs";
|
|
24
25
|
|
|
25
26
|
// EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
|
|
26
27
|
export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
|
|
@@ -36,9 +37,10 @@ const LANE_KIND_SET = new Set(LANE_KINDS);
|
|
|
36
37
|
export function parseTestArgs(argv, { env = process.env } = {}) {
|
|
37
38
|
const opts = {
|
|
38
39
|
sessionId: env.BOTBUDDY_SESSION_ID ?? null,
|
|
39
|
-
// BOT-1572 (AC-8): a session token stands in for --session-id; it flows to
|
|
40
|
-
// sub-invoked `run`/`wait` which derive the session from it.
|
|
41
|
-
|
|
40
|
+
// 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. $BOTBUDDY_AGENT_KEY
|
|
42
|
+
// is the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
|
|
43
|
+
sessionToken: readAgentKeyEnv(env),
|
|
42
44
|
environment: "local",
|
|
43
45
|
ticket: null, pr: null, repo: null,
|
|
44
46
|
laneKind: null,
|
|
@@ -136,8 +138,8 @@ export function defaultGitInfo({ cwd = process.cwd(), ticket = null, pr = null,
|
|
|
136
138
|
// The production lane launcher: run the lane through the durable `botbuddy run`
|
|
137
139
|
// worker, carrying the telemetry env into its detached child.
|
|
138
140
|
async function defaultLaunchLane({ command, sessionId, sessionToken, environment, expectedDurationSeconds, childEnv, testRun, cwd, call }) {
|
|
139
|
-
// BOT-1572: pass --session-id only when a plain id is used; a token-only lane
|
|
140
|
-
// relies on the inherited $
|
|
141
|
+
// BOT-1572/1582: pass --session-id only when a plain id is used; a token-only lane
|
|
142
|
+
// relies on the inherited $BOTBUDDY_AGENT_KEY (and an explicit --session-token
|
|
141
143
|
// so the child never falls back to a machine credential).
|
|
142
144
|
const argv = [
|
|
143
145
|
...(sessionId ? ["--session-id", sessionId] : []),
|
|
@@ -150,7 +152,7 @@ async function defaultLaunchLane({ command, sessionId, sessionToken, environment
|
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
function waitCommand(testRunId, sessionId, sessionToken) {
|
|
153
|
-
// BOT-1572: a token-armed session runs the wait with only $
|
|
155
|
+
// BOT-1572/1582: a token-armed session runs the wait with only $BOTBUDDY_AGENT_KEY.
|
|
154
156
|
const idFlag = sessionId && !sessionToken ? ` --session-id ${sessionId}` : "";
|
|
155
157
|
return `botbuddy wait 'test-run:id=${testRunId}'${idFlag} --heartbeat`;
|
|
156
158
|
}
|
|
@@ -169,7 +171,7 @@ export async function launchTestLane(argv, {
|
|
|
169
171
|
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors }) };
|
|
170
172
|
}
|
|
171
173
|
if (!opts.sessionId && !opts.sessionToken) {
|
|
172
|
-
process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID or $
|
|
174
|
+
process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID or $BOTBUDDY_AGENT_KEY)\n");
|
|
173
175
|
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
|
|
174
176
|
}
|
|
175
177
|
|
package/src/wait-profile.mjs
CHANGED
|
@@ -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,17 +14,18 @@
|
|
|
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";
|
|
21
|
+
import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
|
|
21
22
|
|
|
22
23
|
// A protocol is deliberately distinct from package semver: compatible pinned
|
|
23
24
|
// clients keep working until the server raises this minimum, while a stale
|
|
24
25
|
// implementation gets a typed, safe upgrade instruction.
|
|
25
26
|
// BOT-1554: protocol 2 makes --session-id mandatory for every non-timer wait (the
|
|
26
27
|
// server keeps MINIMUM_WAIT_PROTOCOL=1 so pre-2 installs are not 426'd).
|
|
27
|
-
// BOT-1572: protocol 3 — a $
|
|
28
|
+
// BOT-1572/1582: protocol 3 — a $BOTBUDDY_AGENT_KEY (bb_agent_) authenticates the
|
|
28
29
|
// wait on its own; no profile / --session-id / --token needed (the relay derives
|
|
29
30
|
// agent, tenant, and session from the token). Falls back to protocol-2 behaviour
|
|
30
31
|
// when no session token is present.
|
|
@@ -33,8 +34,9 @@ const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
|
|
|
33
34
|
const MIN_RECEIPT_MAX_BYTES = 512;
|
|
34
35
|
// BOT-1554: identical to the server's session-id shape check.
|
|
35
36
|
const SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
36
|
-
// BOT-
|
|
37
|
-
|
|
37
|
+
// BOT-1582: the server's session-token shape (bb_agent_ + 64 hex), plus the
|
|
38
|
+
// BOT-1572 bb_sess_ legacy alias, both accepted for one release.
|
|
39
|
+
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
38
40
|
|
|
39
41
|
const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
|
|
40
42
|
|
|
@@ -164,10 +166,11 @@ function parseArgv(argv) {
|
|
|
164
166
|
// BOT-1467: attribute this wait to the arming session's agent (the id from
|
|
165
167
|
// register_agent) instead of the tenant-bound profile agent. Env fallback.
|
|
166
168
|
sessionId: process.env.BOTBUDDY_SESSION_ID || null,
|
|
167
|
-
// BOT-1572: the per-session token. When present it is the ONLY credential —
|
|
168
|
-
// profile, --session-id, and --token are unnecessary.
|
|
169
|
-
//
|
|
170
|
-
|
|
169
|
+
// BOT-1572/1582: the per-session token. When present it is the ONLY credential —
|
|
170
|
+
// profile, --session-id, and --token are unnecessary. $BOTBUDDY_AGENT_KEY is
|
|
171
|
+
// the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release); the flag
|
|
172
|
+
// is for tests/overrides.
|
|
173
|
+
sessionToken: readAgentKeyEnv(process.env),
|
|
171
174
|
help: false,
|
|
172
175
|
};
|
|
173
176
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -659,14 +662,15 @@ function profileErrorReceipt(profile, error) {
|
|
|
659
662
|
// renders. Keep each to one line and never embed a secret value — names, slots,
|
|
660
663
|
// and env-var names only.
|
|
661
664
|
const RECOVERY = Object.freeze({
|
|
662
|
-
// $
|
|
663
|
-
// harness exports it
|
|
664
|
-
|
|
665
|
+
// BOT-1582: $BOTBUDDY_AGENT_KEY (bb_agent_+64hex, renamed from
|
|
666
|
+
// $BOTBUDDY_SESSION_TOKEN) is minted by register_agent; the harness exports it
|
|
667
|
+
// at session start. The legacy var is still accepted for one release.
|
|
668
|
+
sessionToken: "register_agent → export BOTBUDDY_AGENT_KEY=<session_token> (the harness exports it at session start)",
|
|
665
669
|
// $BOTBUDDY_SESSION_ID is the work-graph session id returned by register_agent.
|
|
666
670
|
sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
|
|
667
671
|
// A --token / session-token contradiction: the session token is the whole
|
|
668
672
|
// credential, so drop --token (never advise also setting a session id here).
|
|
669
|
-
sessionTokenConflict: "unset --token — $
|
|
673
|
+
sessionTokenConflict: "unset --token — $BOTBUDDY_AGENT_KEY is the whole credential (protocol 3)",
|
|
670
674
|
// Grammar/condition errors point at the help and the canonical doc.
|
|
671
675
|
conditions: "check the condition grammar: botbuddy wait --help / docs/agent-wait.md",
|
|
672
676
|
});
|
|
@@ -689,11 +693,139 @@ function profileResolutionRecovery(errorCode) {
|
|
|
689
693
|
// supported path is exporting that same profile env var directly, which
|
|
690
694
|
// resolveAgentProfile reads. (The 0600 config.json store is the owner login
|
|
691
695
|
// token's, not a profile credential's — a wait cannot consume it.)
|
|
692
|
-
|
|
696
|
+
// BOT-1582: $BOTBUDDY_AGENT_KEY is now the per-session token var (valid for
|
|
697
|
+
// waits), so it is no longer named here; BOTBUDDY_AGENT_API_KEY remains a human
|
|
698
|
+
// PAT that never authenticates a wait.
|
|
699
|
+
const HUMAN_PAT_NOTE = "human PATs (e.g. BOTBUDDY_AGENT_API_KEY) are not valid for waits";
|
|
693
700
|
function profileCredentialRecovery(profile) {
|
|
694
701
|
return `${profileRecovery(profile)} (stores $${profile.tokenEnv} in the macOS Keychain; on a non-Keychain host export $${profile.tokenEnv} directly); ${HUMAN_PAT_NOTE}`;
|
|
695
702
|
}
|
|
696
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
|
+
|
|
697
829
|
export async function runWait(argv) {
|
|
698
830
|
const opts = parseArgv(argv);
|
|
699
831
|
const emitReceipt = (receipt, options = {}) => emit(receipt, {
|
|
@@ -720,6 +852,9 @@ export async function runWait(argv) {
|
|
|
720
852
|
for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
|
|
721
853
|
process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
|
|
722
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: [] });
|
|
723
858
|
process.exit(EXIT.INVALID);
|
|
724
859
|
}
|
|
725
860
|
|
|
@@ -737,20 +872,22 @@ export async function runWait(argv) {
|
|
|
737
872
|
const deadlineMs = Date.now() + timeoutSec * 1000;
|
|
738
873
|
|
|
739
874
|
const needsRelay = conditions.some((c) => c.type !== "timer");
|
|
740
|
-
// BOT-1572: a $
|
|
875
|
+
// BOT-1572/1582: a $BOTBUDDY_AGENT_KEY authenticates the wait on its own. When
|
|
741
876
|
// present it supersedes the profile + --session-id path entirely: the relay
|
|
742
877
|
// derives agent, tenant, and session from the token. --profile / --session-id
|
|
743
878
|
// are simply ignored; a conflicting explicit --token (a DIFFERENT credential)
|
|
744
879
|
// is a contradiction and is rejected.
|
|
745
880
|
if (needsRelay && opts.sessionToken) {
|
|
746
881
|
if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
|
|
747
|
-
process.stderr.write(`botbuddy wait: $
|
|
882
|
+
process.stderr.write(`botbuddy wait: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; ${RECOVERY.sessionToken}\n`);
|
|
748
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 });
|
|
749
885
|
process.exit(EXIT.INVALID);
|
|
750
886
|
}
|
|
751
887
|
if (opts.token && opts.token !== opts.sessionToken) {
|
|
752
|
-
process.stderr.write(`botbuddy wait: --token conflicts with $
|
|
888
|
+
process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_KEY; ${RECOVERY.sessionTokenConflict}\n`);
|
|
753
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 });
|
|
754
891
|
process.exit(EXIT.INVALID);
|
|
755
892
|
}
|
|
756
893
|
opts.useSessionToken = true;
|
|
@@ -770,11 +907,13 @@ export async function runWait(argv) {
|
|
|
770
907
|
error: "session_id_required",
|
|
771
908
|
recovery: RECOVERY.sessionId,
|
|
772
909
|
});
|
|
910
|
+
await reportSetupError(opts, { error: "session_id_required", exitCode: EXIT.INVALID, conditions });
|
|
773
911
|
process.exit(EXIT.INVALID);
|
|
774
912
|
}
|
|
775
913
|
if (!SESSION_UUID.test(opts.sessionId)) {
|
|
776
914
|
process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}'); ${RECOVERY.sessionId}\n`);
|
|
777
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 });
|
|
778
917
|
process.exit(EXIT.INVALID);
|
|
779
918
|
}
|
|
780
919
|
try {
|
|
@@ -787,6 +926,7 @@ export async function runWait(argv) {
|
|
|
787
926
|
const recovery = profileResolutionRecovery(code);
|
|
788
927
|
process.stderr.write(`bb-wait: ${err.message}; ${recovery}\n`);
|
|
789
928
|
emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
|
|
929
|
+
await reportSetupError(opts, { error: code, exitCode: EXIT.INVALID, conditions });
|
|
790
930
|
process.exit(EXIT.INVALID);
|
|
791
931
|
}
|
|
792
932
|
opts.token = opts.agentProfile.token;
|
|
@@ -795,6 +935,10 @@ export async function runWait(argv) {
|
|
|
795
935
|
`botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run ${profileCredentialRecovery(opts.agentProfile)}\n`,
|
|
796
936
|
);
|
|
797
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 });
|
|
798
942
|
process.exit(EXIT.AUTH);
|
|
799
943
|
}
|
|
800
944
|
}
|
|
@@ -909,14 +1053,17 @@ export async function runWait(argv) {
|
|
|
909
1053
|
]);
|
|
910
1054
|
if (opts.useSessionToken || SESSION_TOKEN_ERRORS.has(err.errorCode)) {
|
|
911
1055
|
process.stderr.write(
|
|
912
|
-
`botbuddy wait: session token rejected (${err.errorCode || "unauthorized"}) — re-register the agent and export the new $
|
|
1056
|
+
`botbuddy wait: session token rejected (${err.errorCode || "unauthorized"}) — re-register the agent and export the new $BOTBUDDY_AGENT_KEY\n`,
|
|
913
1057
|
);
|
|
914
1058
|
emitReceipt(withPrincipalReceipt({
|
|
915
1059
|
schema_version: 1,
|
|
916
1060
|
outcome: "error",
|
|
917
1061
|
error: err.errorCode || "unauthorized",
|
|
918
|
-
recovery: "register_agent → export
|
|
1062
|
+
recovery: "register_agent → export BOTBUDDY_AGENT_KEY=<session_token>",
|
|
919
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 });
|
|
920
1067
|
process.exit(EXIT.AUTH);
|
|
921
1068
|
}
|
|
922
1069
|
// BOT-1554: these are all SESSION-IDENTITY failures — the supplied session id
|
|
@@ -944,11 +1091,13 @@ export async function runWait(argv) {
|
|
|
944
1091
|
...(err.carrierAgentId ? { carrier_agent_id: err.carrierAgentId } : {}),
|
|
945
1092
|
recovery: "register_agent → export BOTBUDDY_SESSION_ID=<new session_id>",
|
|
946
1093
|
}, opts.agentProfile, { sessionTenant: opts.agentProfile?.tenant ?? sessionTenant ?? null, agentId: null }));
|
|
1094
|
+
await reportSetupError(opts, { error: err.errorCode, exitCode: EXIT.AUTH, conditions });
|
|
947
1095
|
process.exit(EXIT.AUTH);
|
|
948
1096
|
}
|
|
949
1097
|
const error = typedProfileError(err.errorCode);
|
|
950
1098
|
process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run ${profileCredentialRecovery(opts.agentProfile)}\n`);
|
|
951
1099
|
emitReceipt(profileErrorReceipt(opts.agentProfile, error));
|
|
1100
|
+
await reportSetupError(opts, { error, exitCode: EXIT.AUTH, conditions });
|
|
952
1101
|
process.exit(EXIT.AUTH);
|
|
953
1102
|
}
|
|
954
1103
|
if (err && err.cap) {
|