@botbuddy/cli 1.9.0 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.mjs +22 -12
- package/src/botbuddy-release-repair.json +1 -0
- package/src/profile-bootstrap.mjs +64 -3
- package/src/test-lane.mjs +37 -3
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -53,20 +53,30 @@ export async function callTool(toolName, args = {}) {
|
|
|
53
53
|
// * { ok:true, data } — tool result JSON (data.success may still be false)
|
|
54
54
|
// * { ok:false, auth:true } — not authenticated / token expired
|
|
55
55
|
// * { ok:false, status } — HTTP/JSON-RPC/transport error (status may be null)
|
|
56
|
-
|
|
56
|
+
// BOT-1487: resolve the auth header ONCE so a caller that issues several tool
|
|
57
|
+
// calls (e.g. `profile setup` does whoami → register_agent) can pin the SAME
|
|
58
|
+
// credential across all of them. Without this, each callToolJson independently
|
|
59
|
+
// re-reads the owner token, so a concurrent `botbuddy login` could swap the
|
|
60
|
+
// bound tenant mid-sequence (a TOCTOU that would register in the wrong tenant).
|
|
61
|
+
// Returns { auth } with the header object, or { error } with the structured
|
|
62
|
+
// not-authenticated / token-expired receipt to return verbatim.
|
|
63
|
+
export async function resolveCallAuth() {
|
|
57
64
|
const owner = await resolveOwnerToken({ getConfig });
|
|
58
|
-
let auth;
|
|
59
65
|
if (owner && !(owner.expiresAt && Date.now() >= owner.expiresAt)) {
|
|
60
|
-
auth
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
return { auth: { Authorization: `Bearer ${owner.token}` } };
|
|
67
|
+
}
|
|
68
|
+
const agentKey = await resolveAgentKey();
|
|
69
|
+
if (agentKey) return { auth: { "x-agent-api-key": agentKey } };
|
|
70
|
+
if (owner) return { error: { ok: false, auth: true, error: "token_expired" } };
|
|
71
|
+
return { error: { ok: false, auth: true, error: "not_authenticated" } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null } = {}) {
|
|
75
|
+
let auth = pinnedAuth;
|
|
76
|
+
if (!auth) {
|
|
77
|
+
const resolved = await resolveCallAuth();
|
|
78
|
+
if (resolved.error) return resolved.error;
|
|
79
|
+
auth = resolved.auth;
|
|
70
80
|
}
|
|
71
81
|
const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
|
|
72
82
|
let res;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema_version":1,"source_version":"1.9.0","source_identity":"8bc02282f116b4857951c61a187ad7894c3153b36559b46cd171df24b0af98c4"}
|
|
@@ -2,7 +2,7 @@ import { hostname } from "node:os";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
import { ensureProfileCredentialBackend, readProfileIdentity, readProfileRetryIdentity, recordProfileRetryIdentity, installProfileCredential, profileCredentialEnvironment, ProfileCredentialStoreError } from "./agent-credential-store.mjs";
|
|
5
|
-
import { callToolJson } from "./api.mjs";
|
|
5
|
+
import { callToolJson, resolveCallAuth } from "./api.mjs";
|
|
6
6
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
7
7
|
|
|
8
8
|
const PROFILES = Object.freeze({
|
|
@@ -38,6 +38,7 @@ function registrationCredential(data) {
|
|
|
38
38
|
export async function bootstrapProfile(profileName, {
|
|
39
39
|
name = null,
|
|
40
40
|
call = callToolJson,
|
|
41
|
+
resolveAuth = resolveCallAuth,
|
|
41
42
|
readIdentity = readProfileIdentity,
|
|
42
43
|
readRetryIdentity = readProfileRetryIdentity,
|
|
43
44
|
recordRetryIdentity = recordProfileRetryIdentity,
|
|
@@ -55,14 +56,60 @@ export async function bootstrapProfile(profileName, {
|
|
|
55
56
|
// cannot loop forever on stale metadata.
|
|
56
57
|
const reusableIdentity = existing?.tenant === profile.tenant ? existing.agentId : retryIdentity?.agentId ?? null;
|
|
57
58
|
const agentName = name ?? (existing?.tenant === profile.tenant ? existing.name : retryIdentity?.name ?? defaultProfileAgentName(profileName));
|
|
59
|
+
// BOT-1487: tenancy is auth-bound after BOT-1416 — register_agent selects the
|
|
60
|
+
// tenant from the OAuth session / agent key and rejects a runtime tenant_id
|
|
61
|
+
// with MCP_TENANT_CONTEXT_IS_AUTH_ONLY. So we no longer send tenant_id; the
|
|
62
|
+
// returned tenant_id is attested against the profile's tenant below instead.
|
|
63
|
+
//
|
|
64
|
+
// register_agent is a MUTATING call (it persists the agent, its credential
|
|
65
|
+
// hash, and tenant membership). If the session is logged into another tenant,
|
|
66
|
+
// registering first and rejecting the response afterwards would leave a stray,
|
|
67
|
+
// unreachable agent in the wrong tenant on every attempt (names are randomized
|
|
68
|
+
// and the wrong-tenant path records no retry identity). So resolve the bound
|
|
69
|
+
// tenant with the read-only `whoami` FIRST and gate registration on it.
|
|
70
|
+
//
|
|
71
|
+
// Fail CLOSED (AGENTS.md — no fallbacks; non-determinism is worse than
|
|
72
|
+
// failure): the mutating register_agent runs ONLY on a positively attested
|
|
73
|
+
// tenant that matches the profile. A missing/erroring/malformed whoami never
|
|
74
|
+
// silently proceeds, because the later attestation cannot undo an agent that
|
|
75
|
+
// registration already persisted in the wrong tenant.
|
|
76
|
+
//
|
|
77
|
+
// Snapshot the credential ONCE and pin it to both whoami and register_agent.
|
|
78
|
+
// Otherwise each call re-reads the owner token, so a concurrent `botbuddy
|
|
79
|
+
// login` could swap the bound tenant between the attesting whoami and the
|
|
80
|
+
// mutating register (a TOCTOU that would register in the second tenant).
|
|
81
|
+
const pinned = await resolveAuth();
|
|
82
|
+
if (pinned?.error) throw new ProfileBootstrapError("profile_agent_required");
|
|
83
|
+
const auth = pinned?.auth ?? null;
|
|
84
|
+
const identity = await call("whoami", {}, { auth });
|
|
85
|
+
const identityCode = typeof identity?.data?.code === "string" ? identity.data.code : null;
|
|
86
|
+
if (identityCode === "MCP_TENANT_SELECTION_REQUIRED" || identityCode === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY") {
|
|
87
|
+
throw new ProfileBootstrapError("profile_tenant_selection_required");
|
|
88
|
+
}
|
|
89
|
+
const boundTenant = typeof identity?.data?.tenant_id === "string" ? identity.data.tenant_id : null;
|
|
90
|
+
if (!boundTenant) {
|
|
91
|
+
// whoami did not positively attest a tenant (transport/server error or a
|
|
92
|
+
// response without a string tenant_id) — refuse rather than register blind.
|
|
93
|
+
throw new ProfileBootstrapError("profile_tenant_unverified");
|
|
94
|
+
}
|
|
95
|
+
if (boundTenant !== profile.tenant) {
|
|
96
|
+
throw new ProfileBootstrapError("profile_credential_wrong_tenant");
|
|
97
|
+
}
|
|
58
98
|
const args = {
|
|
59
99
|
name: agentName,
|
|
60
100
|
type: "codex",
|
|
61
|
-
tenant_id: profile.tenant,
|
|
62
101
|
...(reusableIdentity ? { agent_id: reusableIdentity } : {}),
|
|
63
102
|
};
|
|
64
|
-
const response = await call("register_agent", args);
|
|
103
|
+
const response = await call("register_agent", args, { auth });
|
|
65
104
|
const data = response?.data;
|
|
105
|
+
// Defense-in-depth: whoami above already gated on the tenant, so register_agent
|
|
106
|
+
// should not return a tenant-context error here — but if the contract drifts,
|
|
107
|
+
// surface its own actionable recovery, not the misleading
|
|
108
|
+
// `profile_agent_required` → `botbuddy login` loop that keeps failing because
|
|
109
|
+
// login itself is fine.
|
|
110
|
+
if (data?.code === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY" || data?.code === "MCP_TENANT_SELECTION_REQUIRED") {
|
|
111
|
+
throw new ProfileBootstrapError("profile_tenant_selection_required");
|
|
112
|
+
}
|
|
66
113
|
if (!response?.ok || (response.isError && data?.code !== "FRESH_CONNECTION_REQUIRED")) {
|
|
67
114
|
throw new ProfileBootstrapError("profile_agent_required");
|
|
68
115
|
}
|
|
@@ -126,6 +173,20 @@ export function profileBootstrapRecovery(code, profileName) {
|
|
|
126
173
|
if (code === "profile_credential_persist_failed") {
|
|
127
174
|
return `ensure ~/.botbuddy is writable and no other setup is running, then re-run: botbuddy profile setup ${profileName}`;
|
|
128
175
|
}
|
|
176
|
+
if (code === "profile_tenant_unverified") {
|
|
177
|
+
// whoami could not confirm the bound tenant, so registration was refused
|
|
178
|
+
// before any mutation. Usually transient — retry; login only if it persists.
|
|
179
|
+
return `could not confirm your tenant with the server — retry: botbuddy profile setup ${profileName} (if it persists, run botbuddy login, then re-run setup)`;
|
|
180
|
+
}
|
|
181
|
+
if (code === "profile_tenant_selection_required") {
|
|
182
|
+
// Tenancy is chosen at MCP authentication (BOT-1416), not passed to
|
|
183
|
+
// register_agent. Point the user at selecting the profile's tenant when
|
|
184
|
+
// they sign in — never the bare login loop that implies login is broken.
|
|
185
|
+
const tenant = getAgentProfile(profileName)?.tenant;
|
|
186
|
+
return tenant
|
|
187
|
+
? `sign in and select the "${tenant}" tenant during authentication, then re-run: botbuddy profile setup ${profileName}`
|
|
188
|
+
: `sign in and select the matching tenant during authentication, then re-run: botbuddy profile setup ${profileName}`;
|
|
189
|
+
}
|
|
129
190
|
return `botbuddy login && botbuddy profile setup ${profileName}`;
|
|
130
191
|
}
|
|
131
192
|
|
package/src/test-lane.mjs
CHANGED
|
@@ -26,12 +26,19 @@ import { callToolJson } from "./api.mjs";
|
|
|
26
26
|
export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
|
|
27
27
|
export const WAITS_URL = "https://app.bot-buddy.ai/waits";
|
|
28
28
|
const KNOWN_RUNNERS = new Set(["playwright", "generic"]);
|
|
29
|
+
// BOT-1549: mirror of the DB enum public.test_lane_kind (BOT-1547) and
|
|
30
|
+
// supabase/functions/_shared/test-adapters.ts LANE_KINDS. A lane declares its
|
|
31
|
+
// kind in .botbuddy/lanes.json; an unknown value (config or --lane-kind) is a
|
|
32
|
+
// hard, named failure — never a guessed default (gate 9).
|
|
33
|
+
export const LANE_KINDS = Object.freeze(["smoke", "regression", "diagnostics", "e2e", "unit", "integration", "other"]);
|
|
34
|
+
const LANE_KIND_SET = new Set(LANE_KINDS);
|
|
29
35
|
|
|
30
36
|
export function parseTestArgs(argv, { env = process.env } = {}) {
|
|
31
37
|
const opts = {
|
|
32
38
|
sessionId: env.BOTBUDDY_SESSION_ID ?? null,
|
|
33
39
|
environment: "local",
|
|
34
40
|
ticket: null, pr: null, repo: null,
|
|
41
|
+
laneKind: null,
|
|
35
42
|
wait: false, json: false,
|
|
36
43
|
};
|
|
37
44
|
const errors = [];
|
|
@@ -49,6 +56,7 @@ export function parseTestArgs(argv, { env = process.env } = {}) {
|
|
|
49
56
|
case "--ticket": opts.ticket = value(flag, i); i++; break;
|
|
50
57
|
case "--pr": { const raw = value(flag, i); i++; opts.pr = raw == null ? null : Number(raw); if (raw != null && !Number.isInteger(opts.pr)) errors.push("--pr must be an integer"); break; }
|
|
51
58
|
case "--repo": opts.repo = value(flag, i); i++; break;
|
|
59
|
+
case "--lane-kind": opts.laneKind = value(flag, i); i++; break;
|
|
52
60
|
case "--wait": opts.wait = true; break;
|
|
53
61
|
case "--json": opts.json = true; break;
|
|
54
62
|
default:
|
|
@@ -88,7 +96,12 @@ export function resolveLane(laneName, { cwd = process.cwd(), readFileImpl = read
|
|
|
88
96
|
const lane = lanes[laneName];
|
|
89
97
|
if (!Array.isArray(lane.command) || lane.command.length === 0) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" has no command` };
|
|
90
98
|
if (!KNOWN_RUNNERS.has(lane.runner)) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" runner must be playwright|generic` };
|
|
91
|
-
|
|
99
|
+
// BOT-1549: an optional per-lane `kind` must be a known lane_kind — a bad
|
|
100
|
+
// value is rejected here, never coerced or defaulted (gate 9).
|
|
101
|
+
if (lane.kind != null && !LANE_KIND_SET.has(lane.kind)) {
|
|
102
|
+
return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" lane_kind must be one of ${LANE_KINDS.join("|")} (got "${lane.kind}")` };
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, appSlug: config.app_slug ?? null, lane, laneKind: lane.kind ?? null, path, root };
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
// Best-effort git/gh context. Every field falls back to null — never fabricated
|
|
@@ -151,8 +164,18 @@ export async function launchTestLane(argv, {
|
|
|
151
164
|
if (resolved.error === "missing") process.stderr.write(`botbuddy test: no lane config — create ${resolved.path}\n`);
|
|
152
165
|
else if (resolved.error === "unknown") process.stderr.write(`botbuddy test: unknown lane "${laneName}". Available: ${resolved.available.join(", ") || "(none)"}\n`);
|
|
153
166
|
else process.stderr.write(`botbuddy test: invalid lane config (${resolved.detail}) at ${resolved.path}\n`);
|
|
154
|
-
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: resolved.error, path: resolved.path }) };
|
|
167
|
+
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: resolved.error, path: resolved.path, detail: resolved.detail }) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// BOT-1549: resolve the effective lane kind — a --lane-kind flag overrides
|
|
171
|
+
// the lane's config kind for this run. An invalid flag value is rejected
|
|
172
|
+
// before any run is created (gate 9), mirroring the config-side check.
|
|
173
|
+
if (opts.laneKind != null && !LANE_KIND_SET.has(opts.laneKind)) {
|
|
174
|
+
const detail = `invalid lane_kind: --lane-kind must be one of ${LANE_KINDS.join("|")} (got "${opts.laneKind}")`;
|
|
175
|
+
process.stderr.write(`botbuddy test: ${detail}\n`);
|
|
176
|
+
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: "invalid", detail }) };
|
|
155
177
|
}
|
|
178
|
+
const laneKind = opts.laneKind ?? resolved.laneKind ?? null;
|
|
156
179
|
|
|
157
180
|
const git = gitInfo({ cwd, ticket: opts.ticket, pr: opts.pr, repo: opts.repo });
|
|
158
181
|
const sha7 = (git.sha ?? "").slice(0, 7) || "nosha";
|
|
@@ -167,6 +190,8 @@ export async function launchTestLane(argv, {
|
|
|
167
190
|
commit_sha: git.sha ?? undefined, branch_name: git.branch ?? undefined,
|
|
168
191
|
pr_number: git.prNumber ?? undefined, pr_url: git.prUrl ?? undefined,
|
|
169
192
|
source_kind: "cli-lane", source_id: laneRunId,
|
|
193
|
+
// BOT-1549: stamp the resolved lane kind on the run; absent ⇒ omitted (NULL).
|
|
194
|
+
lane_kind: laneKind ?? undefined,
|
|
170
195
|
};
|
|
171
196
|
const created = await call("create_test_run", createArgs);
|
|
172
197
|
const testRunId = created?.ok && !created.isError ? created.data?.id ?? null : null;
|
|
@@ -183,6 +208,15 @@ export async function launchTestLane(argv, {
|
|
|
183
208
|
|
|
184
209
|
// Step 3: launch the lane under the durable worker, telemetry env in the child.
|
|
185
210
|
const childEnv = { BOTBUDDY_LANE: laneName };
|
|
211
|
+
// BOT-1549: carry the lane kind to the child so uploaded executions
|
|
212
|
+
// (test-ingest / playwright-upload) can stamp lane_kind too. ALWAYS set the
|
|
213
|
+
// key: runWorker spawns the child with `{ ...process.env, ...child_env }`
|
|
214
|
+
// (run.mjs), so a bare `if (laneKind)` would let a stale BOTBUDDY_LANE_KIND
|
|
215
|
+
// already in the environment leak into the child even though this run's
|
|
216
|
+
// lane_kind is NULL — making child-uploaded executions disagree with their
|
|
217
|
+
// run (Codex P2). Setting "" when there is no effective kind deterministically
|
|
218
|
+
// clears any inherited value; empty ⇒ unset by parseLaneKind's contract.
|
|
219
|
+
childEnv.BOTBUDDY_LANE_KIND = laneKind ?? "";
|
|
186
220
|
if (testRunId) { childEnv.BOTBUDDY_TEST_RUN_ID = testRunId; childEnv.BOTBUDDY_TEST_RUN_EVENTS = eventsPath; }
|
|
187
221
|
const testRun = testRunId
|
|
188
222
|
? { test_run_id: testRunId, events_path: eventsPath, lane: laneName, runner: resolved.lane.runner, git_sha: git.sha ?? null, branch: git.branch ?? null }
|
|
@@ -248,7 +282,7 @@ function laneList(cwd) {
|
|
|
248
282
|
}
|
|
249
283
|
|
|
250
284
|
function testHelp() {
|
|
251
|
-
console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--wait] [--json] [-- <extra args>]
|
|
285
|
+
console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
|
|
252
286
|
botbuddy test list List the lanes configured in .botbuddy/lanes.json
|
|
253
287
|
botbuddy test help Show this help
|
|
254
288
|
|