@botbuddy/cli 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,30 @@
1
1
  import { SERVER_URL } from "../config.mjs";
2
- export function createProfileCoordinator({ profile, identity, fetchImpl = fetch } = {}) {
3
- if (!profile?.token || !identity?.agentId || identity.tenant !== profile.tenant) return { kind: "unverified" };
2
+
3
+ // Shared JSON-RPC caller auth header differs (profile key vs. session token).
4
+ function mcpCaller(authHeader, fetchImpl) {
4
5
  let id = 0;
5
- async function call(name, args) {
6
- const response = await fetchImpl(SERVER_URL, { method: "POST", headers: { "content-type": "application/json", "x-agent-api-key": profile.token }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }) });
6
+ return async function call(name, args) {
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 } }) });
7
8
  if (!response.ok) throw new Error(`BotBuddy lock verification returned HTTP ${response.status}`);
8
9
  const json = await response.json(); if (json.error) throw new Error(json.error.message || "BotBuddy lock verification failed");
9
10
  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"); }
10
- }
11
- return { kind: "profile", 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: identity.agentId };
11
+ };
12
+ }
13
+
14
+ function laneCoordinator(kind, call, agentId) {
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
+ }
17
+
18
+ // BOT-1572: authenticate lock verification with a per-session `bb_sess_` token —
19
+ // the server resolves it to the session agent (mcp-server authenticateAgent), so
20
+ // `list_resources`/`record_lane_event` run AS the session, and holder matching
21
+ // rides on the server's `owner_is_caller`. No machine profile is required.
22
+ export function createSessionTokenCoordinator({ token, fetchImpl = fetch } = {}) {
23
+ if (!token) return { kind: "unverified" };
24
+ return laneCoordinator("session_token", mcpCaller({ "x-agent-api-key": token }, fetchImpl), null);
25
+ }
26
+
27
+ export function createProfileCoordinator({ profile, identity, fetchImpl = fetch } = {}) {
28
+ if (!profile?.token || !identity?.agentId || identity.tenant !== profile.tenant) return { kind: "unverified" };
29
+ return laneCoordinator("profile", mcpCaller({ "x-agent-api-key": profile.token }, fetchImpl), identity.agentId);
12
30
  }
package/src/pw/run.mjs CHANGED
@@ -2,7 +2,7 @@ import os from "node:os";
2
2
  import { planInvocation } from "./args.mjs";
3
3
  import { actionTypeFromMethod, NAV } from "./readiness.mjs";
4
4
  import { isStaleRefError, staleRefRemediation } from "./targets.mjs";
5
- import { createProfileCoordinator } from "./coordinator.mjs";
5
+ import { createProfileCoordinator, createSessionTokenCoordinator } from "./coordinator.mjs";
6
6
  import { canonicalizeHostString } from "./host.mjs";
7
7
  import { resolveAgentProfile } from "../wait-profile.mjs";
8
8
  import { readProfileIdentity } from "../agent-credential-store.mjs";
@@ -23,24 +23,38 @@ function redact(value, secretValues = []) { return secretValues.reduce((text, se
23
23
  async function readRegisteredAgentId() { try { await loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
24
24
  async function gate({ env, host, lane, deps }) {
25
25
  if (env.BB_PW_NO_LOCK === "1") return { allowed: true };
26
- let profile, identity;
27
- try {
28
- profile = await (deps.resolveProfile ?? resolveAgentProfile)({ cwd: deps.cwd ?? process.cwd(), env, explicitProfile: deps.profile ?? null });
29
- identity = await (deps.readIdentity ?? readProfileIdentity)(profile.name);
30
- } catch (error) {
31
- return { allowed: false, message: `bb-pw: profile verification failed (${error.message}). Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work.` };
32
- }
33
- const coordinator = deps.coordinator ?? createProfileCoordinator({ profile, identity, fetchImpl: deps.fetch });
34
- if (!profile.token || !identity || identity.tenant !== profile.tenant || coordinator.kind !== "profile") return { allowed: false, message: "bb-pw: profile identity is missing or tenant-mismatched. Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work." };
35
- const laneName = `playwright_lane:${host}:${lane}`;
36
- // The operator's own holder identities: the tenant-bound profile agent (today's
37
- // happy path) OR the arming session agent — named explicitly with --session-id /
38
- // $BOTBUDDY_SESSION_ID (BOT-1467 precedent) or read from the local register_agent
39
- // identity. A foreign operator's agent is in none of these, so the gate stays a
40
- // real refusal (AC-3).
26
+ // BOT-1572: a per-session `bb_sess_` token authenticates lock verification AS
27
+ // the session agent — no machine profile needed. It takes precedence over the
28
+ // profile path; holder matching then rides on the server's owner_is_caller
29
+ // (plus any local session/registered agent id). The profile path stays intact
30
+ // for a session that has not adopted the token.
31
+ const sessionToken = deps.sessionToken ?? env.BOTBUDDY_SESSION_TOKEN ?? null;
41
32
  const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
42
33
  const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
43
- const selfAgentIds = new Set([identity.agentId, sessionAgentId, registeredAgentId].filter(Boolean));
34
+ let coordinator, selfAgentIds, callerId;
35
+ if (sessionToken) {
36
+ coordinator = deps.coordinator ?? createSessionTokenCoordinator({ token: sessionToken, fetchImpl: deps.fetch });
37
+ if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: $BOTBUDDY_SESSION_TOKEN is malformed. Re-register the agent, or set BB_PW_NO_LOCK=1 for local-only work." };
38
+ selfAgentIds = new Set([sessionAgentId, registeredAgentId, coordinator.agentId].filter(Boolean));
39
+ callerId = coordinator.agentId ?? sessionAgentId ?? "session-token";
40
+ } else {
41
+ let profile, identity;
42
+ try {
43
+ profile = await (deps.resolveProfile ?? resolveAgentProfile)({ cwd: deps.cwd ?? process.cwd(), env, explicitProfile: deps.profile ?? null });
44
+ identity = await (deps.readIdentity ?? readProfileIdentity)(profile.name);
45
+ } catch (error) {
46
+ return { allowed: false, message: `bb-pw: profile verification failed (${error.message}). Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work.` };
47
+ }
48
+ coordinator = deps.coordinator ?? createProfileCoordinator({ profile, identity, fetchImpl: deps.fetch });
49
+ if (!profile.token || !identity || identity.tenant !== profile.tenant || coordinator.kind !== "profile") return { allowed: false, message: "bb-pw: profile identity is missing or tenant-mismatched. Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work." };
50
+ // The operator's own holder identities: the tenant-bound profile agent OR the
51
+ // arming session agent (--session-id / $BOTBUDDY_SESSION_ID / local register_agent
52
+ // id). A foreign operator's agent is in none of these, so the gate stays a real
53
+ // refusal (AC-3).
54
+ selfAgentIds = new Set([identity.agentId, sessionAgentId, registeredAgentId].filter(Boolean));
55
+ callerId = identity.agentId;
56
+ }
57
+ const laneName = `playwright_lane:${host}:${lane}`;
44
58
  try {
45
59
  const status = await coordinator.status({ host, slot: lane });
46
60
  // The server's alias map can collapse the locally-normalized host further
@@ -61,10 +75,10 @@ async function gate({ env, host, lane, deps }) {
61
75
  // operator knows whether to re-acquire vs. wait for a handover (BOT-660). Name it
62
76
  // by the server's canonical host so a not-held message matches the dashboard even
63
77
  // when local normalization cannot reach the alias-collapsed key (BOT-1490 AC-5).
64
- if (!status.held) return { allowed: false, message: `bb-pw: lane lock ${foundLaneName} is not held (caller ${identity.agentId}). Acquire it via BotBuddy first, or set BB_PW_NO_LOCK=1 for local-only work.` };
78
+ if (!status.held) return { allowed: false, message: `bb-pw: lane lock ${foundLaneName} is not held (caller ${callerId}). Acquire it via BotBuddy first, or set BB_PW_NO_LOCK=1 for local-only work.` };
65
79
  const holder = status.heldBy ?? "unknown";
66
80
  const holderName = status.heldByName ? ` (${status.heldByName})` : "";
67
- return { allowed: false, message: `bb-pw: lane lock ${foundLaneName} is held by ${holder}${holderName}, not you (caller ${identity.agentId}). Acquire it first or set BB_PW_NO_LOCK=1 for local-only work.` };
81
+ 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.` };
68
82
  } catch (error) {
69
83
  return { allowed: false, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
70
84
  }
@@ -85,7 +99,11 @@ export async function runPw(argv, deps = {}) {
85
99
  async function runPwInner(argv, deps = {}) {
86
100
  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; }
87
101
  if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
88
- while (args[0] === "--profile" || args[0] === "--session-id") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--profile" ? { ...deps, profile: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
102
+ // BOT-1572 (AC-8): --session-token / $BOTBUDDY_SESSION_TOKEN is accepted as a
103
+ // session identity alongside --session-id, so a token-armed session need not
104
+ // pass an id. Holder matching still rides on the server's owner_is_caller and
105
+ // the resolved agent ids (gate()).
106
+ while (args[0] === "--profile" || args[0] === "--session-id" || args[0] === "--session-token") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--profile" ? { ...deps, profile: args[1] } : flag === "--session-token" ? { ...deps, sessionToken: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
89
107
  let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
90
108
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
91
109
  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); }
package/src/run.mjs CHANGED
@@ -24,8 +24,14 @@ const MAX_CAPTURE_BYTES = 8_192;
24
24
  // Well under AC-3's 5 s so a begin/verdict is visible within the window.
25
25
  const LANE_FLUSH_INTERVAL_MS = 2_500;
26
26
 
27
- export function parseRunArgs(argv) {
28
- const opts = { sessionId: null, environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
27
+ // BOT-1572: identical to the server's session-token shape check (bb_sess_ + 64 hex).
28
+ const SESSION_TOKEN_RE = /^bb_sess_[0-9a-f]{64}$/;
29
+
30
+ export function parseRunArgs(argv, env = process.env) {
31
+ // BOT-1572 (AC-8): $BOTBUDDY_SESSION_TOKEN authenticates the run and carries its
32
+ // session, so --session-id becomes optional (the relay derives it). $BOTBUDDY_SESSION_ID
33
+ // is the default when a plain id is used (parity with `botbuddy test`).
34
+ const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: env.BOTBUDDY_SESSION_TOKEN ?? null, environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
29
35
  const errors = [];
30
36
  const separator = argv.indexOf("--");
31
37
  const flags = separator === -1 ? argv : argv.slice(0, separator);
@@ -38,6 +44,7 @@ export function parseRunArgs(argv) {
38
44
  const flag = flags[i];
39
45
  switch (flag) {
40
46
  case "--session-id": opts.sessionId = value(flag, i); i++; break;
47
+ case "--session-token": opts.sessionToken = value(flag, i); i++; break;
41
48
  case "--environment": opts.environment = value(flag, i); i++; break;
42
49
  case "--category": opts.category = value(flag, i); i++; break;
43
50
  case "--kind": opts.kind = value(flag, i); i++; break;
@@ -56,7 +63,11 @@ export function parseRunArgs(argv) {
56
63
  default: errors.push(`unknown option: ${flag}`);
57
64
  }
58
65
  }
59
- if (!opts.sessionId) errors.push("--session-id is required (from register_agent)");
66
+ // BOT-1572: a session token stands in for --session-id (the backend derives the
67
+ // session from the token). A malformed token, or a plain id AND a differing
68
+ // token, is a hard error.
69
+ if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_SESSION_TOKEN must match bb_sess_<64 hex>");
70
+ if (!opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_SESSION_TOKEN");
60
71
  if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
61
72
  if (!command.length) errors.push("a workload is required after --");
62
73
  if (!Number.isInteger(opts.expectedDuration) || opts.expectedDuration < 0) errors.push("--expected-duration must be a non-negative integer");
@@ -95,12 +106,21 @@ function workerInvocation(context) {
95
106
  return [process.execPath, [bin, "run", "--worker", context]];
96
107
  }
97
108
 
98
- export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = callToolJson, childEnv = null, testRun = null } = {}) {
109
+ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null } = {}) {
99
110
  const { opts, command, errors } = parseRunArgs(argv);
100
111
  if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
112
+ // BOT-1572 (AC-8): when a session token is present it is the credential — pin it
113
+ // so register_command authenticates as the session's agent and derives session_id
114
+ // from the token. Tests inject `call` directly and bypass this.
115
+ const effectiveCall = call ?? (opts.sessionToken
116
+ ? (t, a) => callToolJson(t, a, { auth: { "x-agent-api-key": opts.sessionToken } })
117
+ : callToolJson);
101
118
  const runId = randomUUID();
102
- const registration = await call("register_command", {
103
- session_id: opts.sessionId, run_id: runId, tool_category: opts.category,
119
+ const registration = await effectiveCall("register_command", {
120
+ // Omit session_id entirely when only a token is present — the backend derives
121
+ // it. A plain id is still sent (and honoured) when supplied.
122
+ ...(opts.sessionId ? { session_id: opts.sessionId } : {}),
123
+ run_id: runId, tool_category: opts.category,
104
124
  command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
105
125
  environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
106
126
  rerun_reason: opts.rerunReason,
@@ -116,7 +136,9 @@ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn,
116
136
  const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
117
137
  worker.unref();
118
138
  } catch (error) {
119
- await call("update_command_run", { run_id: runId, run_credential: registration.data.run_credential, status: "owner_lost", invalidated_reason: `launcher_failed:${error instanceof Error ? error.message : String(error)}` });
139
+ // BOT-1572 (Codex P2): use effectiveCall `call` may be null (the token-pinned
140
+ // default path), so calling it here would throw and strand the registered run.
141
+ await effectiveCall("update_command_run", { run_id: runId, run_credential: registration.data.run_credential, status: "owner_lost", invalidated_reason: `launcher_failed:${error instanceof Error ? error.message : String(error)}` });
120
142
  return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "owner_lost", run_id: runId, exit_code: EXIT.INTERNAL } };
121
143
  }
122
144
  return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "launched", run_id: runId, session_id: opts.sessionId, durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id", exit_code: EXIT.OK } };
package/src/test-lane.mjs CHANGED
@@ -36,6 +36,9 @@ const LANE_KIND_SET = new Set(LANE_KINDS);
36
36
  export function parseTestArgs(argv, { env = process.env } = {}) {
37
37
  const opts = {
38
38
  sessionId: env.BOTBUDDY_SESSION_ID ?? null,
39
+ // BOT-1572 (AC-8): a session token stands in for --session-id; it flows to the
40
+ // sub-invoked `run`/`wait` which derive the session from it.
41
+ sessionToken: env.BOTBUDDY_SESSION_TOKEN ?? null,
39
42
  environment: "local",
40
43
  ticket: null, pr: null, repo: null,
41
44
  laneKind: null,
@@ -52,6 +55,7 @@ export function parseTestArgs(argv, { env = process.env } = {}) {
52
55
  const flag = flags[i];
53
56
  switch (flag) {
54
57
  case "--session-id": opts.sessionId = value(flag, i); i++; break;
58
+ case "--session-token": opts.sessionToken = value(flag, i); i++; break;
55
59
  case "--environment": opts.environment = value(flag, i); i++; break;
56
60
  case "--ticket": opts.ticket = value(flag, i); i++; break;
57
61
  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; }
@@ -131,14 +135,24 @@ export function defaultGitInfo({ cwd = process.cwd(), ticket = null, pr = null,
131
135
 
132
136
  // The production lane launcher: run the lane through the durable `botbuddy run`
133
137
  // worker, carrying the telemetry env into its detached child.
134
- async function defaultLaunchLane({ command, sessionId, environment, expectedDurationSeconds, childEnv, testRun, cwd, call }) {
135
- const argv = ["--session-id", sessionId, "--environment", environment, "--category", "validation", "--kind", "full_suite", "--expected-duration", String(expectedDurationSeconds ?? 0), "--", ...command];
136
- const result = await launchRun(argv, { cwd, call, childEnv, testRun });
138
+ 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 $BOTBUDDY_SESSION_TOKEN (and an explicit --session-token
141
+ // so the child never falls back to a machine credential).
142
+ const argv = [
143
+ ...(sessionId ? ["--session-id", sessionId] : []),
144
+ ...(sessionToken ? ["--session-token", sessionToken] : []),
145
+ "--environment", environment, "--category", "validation", "--kind", "full_suite",
146
+ "--expected-duration", String(expectedDurationSeconds ?? 0), "--", ...command,
147
+ ];
148
+ const result = await launchRun(argv, { cwd, call: sessionToken ? null : call, childEnv, testRun });
137
149
  return { exitCode: result.exitCode, runId: result.receipt?.run_id ?? null };
138
150
  }
139
151
 
140
- function waitCommand(testRunId, sessionId) {
141
- return `botbuddy wait 'test-run:id=${testRunId}' --session-id ${sessionId} --heartbeat`;
152
+ function waitCommand(testRunId, sessionId, sessionToken) {
153
+ // BOT-1572: a token-armed session runs the wait with only $BOTBUDDY_SESSION_TOKEN.
154
+ const idFlag = sessionId && !sessionToken ? ` --session-id ${sessionId}` : "";
155
+ return `botbuddy wait 'test-run:id=${testRunId}'${idFlag} --heartbeat`;
142
156
  }
143
157
 
144
158
  export async function launchTestLane(argv, {
@@ -154,11 +168,20 @@ export async function launchTestLane(argv, {
154
168
  for (const e of errors) process.stderr.write(`botbuddy test: ${e}\n`);
155
169
  return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors }) };
156
170
  }
157
- if (!opts.sessionId) {
158
- process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID)\n");
171
+ if (!opts.sessionId && !opts.sessionToken) {
172
+ process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID or $BOTBUDDY_SESSION_TOKEN)\n");
159
173
  return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
160
174
  }
161
175
 
176
+ // BOT-1572 (Codex P1): a token-only session has no owner/profile credential, so
177
+ // create_test_run / update_test_run must authenticate with the session token —
178
+ // otherwise both fail and the run launches with test_run_id=null, silently
179
+ // dropping the test-run record and case telemetry. Wrap the call so the token
180
+ // is pinned; an injected test `call` that ignores the 3rd arg is unaffected.
181
+ const apiCall = opts.sessionToken
182
+ ? (name, args) => call(name, args, { auth: { "x-agent-api-key": opts.sessionToken } })
183
+ : call;
184
+
162
185
  const resolved = resolveLane(laneName, { cwd });
163
186
  if (!resolved.ok) {
164
187
  if (resolved.error === "missing") process.stderr.write(`botbuddy test: no lane config — create ${resolved.path}\n`);
@@ -193,12 +216,12 @@ export async function launchTestLane(argv, {
193
216
  // BOT-1549: stamp the resolved lane kind on the run; absent ⇒ omitted (NULL).
194
217
  lane_kind: laneKind ?? undefined,
195
218
  };
196
- const created = await call("create_test_run", createArgs);
219
+ const created = await apiCall("create_test_run", createArgs);
197
220
  const testRunId = created?.ok && !created.isError ? created.data?.id ?? null : null;
198
221
 
199
222
  if (testRunId) {
200
223
  // Step 2: attach the ticket + promote to active in one update.
201
- await call("update_test_run", {
224
+ await apiCall("update_test_run", {
202
225
  test_run_id: testRunId,
203
226
  ticket_id: git.ticket ?? undefined,
204
227
  ticket_url: git.ticketUrl ?? undefined,
@@ -223,7 +246,7 @@ export async function launchTestLane(argv, {
223
246
  : null;
224
247
  const launch = await launchLane({
225
248
  command: [...resolved.lane.command, ...extra],
226
- sessionId: opts.sessionId, environment: opts.environment,
249
+ sessionId: opts.sessionId, sessionToken: opts.sessionToken, environment: opts.environment,
227
250
  expectedDurationSeconds: resolved.lane.expected_duration_seconds ?? 0,
228
251
  childEnv, testRun, cwd, call, testRunId, lane: laneName, runner: resolved.lane.runner,
229
252
  });
@@ -232,7 +255,7 @@ export async function launchTestLane(argv, {
232
255
  const line = testRunId
233
256
  ? {
234
257
  outcome: "launched", test_run_id: testRunId, command_run_id: commandRunId, lane: laneName,
235
- wait: waitCommand(testRunId, opts.sessionId),
258
+ wait: waitCommand(testRunId, opts.sessionId, opts.sessionToken),
236
259
  receipt_path: commandRunId ? receiptPath(commandRunId) : null, waits_url: WAITS_URL,
237
260
  }
238
261
  : {
@@ -72,7 +72,24 @@ export async function resolveAgentProfile({
72
72
  }
73
73
 
74
74
  export function withPrincipalReceipt(receipt, profile, registration = {}) {
75
- if (!profile) return receipt;
75
+ if (!profile) {
76
+ // BOT-1572: a session-token wait has no profile, but the receipt must still
77
+ // carry principal.session_id / agent_id / tenant_id derived by the relay from
78
+ // the token. Only attach when the relay actually echoed a session identity.
79
+ if (registration.sessionId || registration.agentId || registration.sessionTenant != null) {
80
+ return {
81
+ ...receipt,
82
+ principal: {
83
+ profile: null,
84
+ tenant_id: registration.sessionTenant ?? null,
85
+ agent_id: registration.agentId ?? null,
86
+ session_id: registration.sessionId ?? null,
87
+ session_agent_id: registration.sessionAgentId ?? null,
88
+ },
89
+ };
90
+ }
91
+ return receipt;
92
+ }
76
93
  return {
77
94
  ...receipt,
78
95
  principal: {
package/src/wait.mjs CHANGED
@@ -24,11 +24,17 @@ import { latestPublicCliCommand } from "./public-invocation.mjs";
24
24
  // implementation gets a typed, safe upgrade instruction.
25
25
  // BOT-1554: protocol 2 makes --session-id mandatory for every non-timer wait (the
26
26
  // server keeps MINIMUM_WAIT_PROTOCOL=1 so pre-2 installs are not 426'd).
27
- export const WAIT_PROTOCOL_VERSION = 2;
27
+ // BOT-1572: protocol 3 — a $BOTBUDDY_SESSION_TOKEN (bb_sess_) authenticates the
28
+ // wait on its own; no profile / --session-id / --token needed (the relay derives
29
+ // agent, tenant, and session from the token). Falls back to protocol-2 behaviour
30
+ // when no session token is present.
31
+ export const WAIT_PROTOCOL_VERSION = 3;
28
32
  const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
29
33
  const MIN_RECEIPT_MAX_BYTES = 512;
30
34
  // BOT-1554: identical to the server's session-id shape check.
31
35
  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-1572: identical to the server's session-token shape check (bb_sess_ + 64 hex).
37
+ const SESSION_TOKEN_RE = /^bb_sess_[0-9a-f]{64}$/;
32
38
 
33
39
  const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
34
40
 
@@ -153,6 +159,10 @@ function parseArgv(argv) {
153
159
  // BOT-1467: attribute this wait to the arming session's agent (the id from
154
160
  // register_agent) instead of the tenant-bound profile agent. Env fallback.
155
161
  sessionId: process.env.BOTBUDDY_SESSION_ID || null,
162
+ // BOT-1572: the per-session token. When present it is the ONLY credential —
163
+ // profile, --session-id, and --token are unnecessary. Env is the norm; the
164
+ // flag is for tests/overrides.
165
+ sessionToken: process.env.BOTBUDDY_SESSION_TOKEN || null,
156
166
  help: false,
157
167
  };
158
168
  for (let i = 0; i < argv.length; i++) {
@@ -174,6 +184,7 @@ function parseArgv(argv) {
174
184
  else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
175
185
  else if (a === "--url") opts.url = optionValue();
176
186
  else if (a === "--token") opts.token = optionValue();
187
+ else if (a === "--session-token") opts.sessionToken = optionValue();
177
188
  else if (a === "--profile") opts.profile = optionValue();
178
189
  else if (a === "--session-id") opts.sessionId = optionValue();
179
190
  else if (a.startsWith("--")) opts.unknown = a;
@@ -186,14 +197,19 @@ function parseArgv(argv) {
186
197
  // {waitSessionId, cursorStart} on success. Profiled waits fail closed unless the
187
198
  // relay authenticates and attests their machine principal.
188
199
  async function registerWait(opts, conditions, deadlineIso) {
189
- const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
190
- method: "POST",
191
- headers: {
192
- Authorization: `Bearer ${opts.token}`,
193
- "x-agent-api-key": opts.token || "",
194
- "Content-Type": "application/json",
195
- },
196
- body: JSON.stringify({
200
+ // BOT-1572: a session-token wait sends NO profile / expected_tenant /
201
+ // session_id — the relay derives all three from the token. Everything else
202
+ // (protocol-2 profile wait) is unchanged.
203
+ const registerBody = opts.useSessionToken
204
+ ? {
205
+ action: "register",
206
+ client_version: VERSION,
207
+ wait_protocol_version: WAIT_PROTOCOL_VERSION,
208
+ conditions,
209
+ deadline: deadlineIso,
210
+ mode: opts.mode,
211
+ }
212
+ : {
197
213
  action: "register",
198
214
  profile: opts.agentProfile.name,
199
215
  expected_tenant: opts.agentProfile.tenant,
@@ -205,7 +221,15 @@ async function registerWait(opts, conditions, deadlineIso) {
205
221
  conditions,
206
222
  deadline: deadlineIso,
207
223
  mode: opts.mode,
208
- }),
224
+ };
225
+ const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
226
+ method: "POST",
227
+ headers: {
228
+ Authorization: `Bearer ${opts.token}`,
229
+ "x-agent-api-key": opts.token || "",
230
+ "Content-Type": "application/json",
231
+ },
232
+ body: JSON.stringify(registerBody),
209
233
  });
210
234
  if (res.status === 429) {
211
235
  const body = await res.json().catch(() => ({}));
@@ -326,13 +350,23 @@ async function registerWait(opts, conditions, deadlineIso) {
326
350
  throw err;
327
351
  }
328
352
  const body = await res.json();
329
- if (body.profile !== opts.agentProfile.name || typeof body.agent_id !== "string" || !body.agent_id) {
353
+ if (opts.useSessionToken) {
354
+ // BOT-1572: a session-token wait has no profile to attest; the relay instead
355
+ // returns the derived session_id + agent_id. Their absence means the relay
356
+ // did not honour the token as a session credential — fail closed.
357
+ if (typeof body.agent_id !== "string" || !body.agent_id ||
358
+ typeof body.session_id !== "string" || !body.session_id) {
359
+ const err = new Error("relay did not attest the session token");
360
+ err.auth = true;
361
+ err.errorCode = "session_token_not_enforced";
362
+ throw err;
363
+ }
364
+ } else if (body.profile !== opts.agentProfile.name || typeof body.agent_id !== "string" || !body.agent_id) {
330
365
  const err = new Error("relay did not attest the tenant-bound agent profile");
331
366
  err.auth = true;
332
367
  err.errorCode = "profile_not_enforced";
333
368
  throw err;
334
- }
335
- if (body.session_tenant !== opts.agentProfile.tenant) {
369
+ } else if (body.session_tenant !== opts.agentProfile.tenant) {
336
370
  const err = new Error(
337
371
  `profile expects ${opts.agentProfile.tenant} but registration resolved ${body.session_tenant ?? "no tenant"}`,
338
372
  );
@@ -655,7 +689,25 @@ export async function runWait(argv) {
655
689
  const deadlineMs = Date.now() + timeoutSec * 1000;
656
690
 
657
691
  const needsRelay = conditions.some((c) => c.type !== "timer");
658
- if (needsRelay) {
692
+ // BOT-1572: a $BOTBUDDY_SESSION_TOKEN authenticates the wait on its own. When
693
+ // present it supersedes the profile + --session-id path entirely: the relay
694
+ // derives agent, tenant, and session from the token. --profile / --session-id
695
+ // are simply ignored; a conflicting explicit --token (a DIFFERENT credential)
696
+ // is a contradiction and is rejected.
697
+ if (needsRelay && opts.sessionToken) {
698
+ if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
699
+ process.stderr.write("botbuddy wait: $BOTBUDDY_SESSION_TOKEN must match bb_sess_<64 hex>\n");
700
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token" });
701
+ process.exit(EXIT.INVALID);
702
+ }
703
+ if (opts.token && opts.token !== opts.sessionToken) {
704
+ process.stderr.write("botbuddy wait: --token conflicts with $BOTBUDDY_SESSION_TOKEN; pass only one\n");
705
+ emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict" });
706
+ process.exit(EXIT.INVALID);
707
+ }
708
+ opts.useSessionToken = true;
709
+ opts.token = opts.sessionToken;
710
+ } else if (needsRelay) {
659
711
  // BOT-1554: a relay wait MUST name its arming work-graph session. Fail fast —
660
712
  // before any profile resolution or network call — so a wait can never be filed
661
713
  // under whatever credential the machine holds (the "/waits all Megan" bug). A
@@ -799,6 +851,24 @@ export async function runWait(argv) {
799
851
  process.exit(EXIT.INVALID);
800
852
  }
801
853
  if (err && err.auth) {
854
+ // BOT-1572: a session-token failure (revoked/expired/rotated, or the relay
855
+ // refusing to honour the token) is fixed by RE-REGISTERING, not by profile
856
+ // setup. Lead with that, and never touch the (absent) agentProfile.
857
+ const SESSION_TOKEN_ERRORS = new Set([
858
+ "session_token_revoked", "session_token_expired", "session_token_not_enforced",
859
+ ]);
860
+ if (opts.useSessionToken || SESSION_TOKEN_ERRORS.has(err.errorCode)) {
861
+ process.stderr.write(
862
+ `botbuddy wait: session token rejected (${err.errorCode || "unauthorized"}) — re-register the agent and export the new $BOTBUDDY_SESSION_TOKEN\n`,
863
+ );
864
+ emitReceipt(withPrincipalReceipt({
865
+ schema_version: 1,
866
+ outcome: "error",
867
+ error: err.errorCode || "unauthorized",
868
+ recovery: "register_agent → export BOTBUDDY_SESSION_TOKEN=<session_token>",
869
+ }, opts.agentProfile, { sessionTenant, agentId: registeredAgentId, sessionId: registeredSessionId }));
870
+ process.exit(EXIT.AUTH);
871
+ }
802
872
  // BOT-1554: these are all SESSION-IDENTITY failures — the supplied session id
803
873
  // (or the credential behind it) is a shared service carrier, an
804
874
  // ended/foreign session, or a wrong-tenant session. Re-running profile setup
@@ -823,7 +893,7 @@ export async function runWait(argv) {
823
893
  error: err.errorCode,
824
894
  ...(err.carrierAgentId ? { carrier_agent_id: err.carrierAgentId } : {}),
825
895
  recovery: "register_agent → export BOTBUDDY_SESSION_ID=<new session_id>",
826
- }, opts.agentProfile, { sessionTenant: opts.agentProfile.tenant, agentId: null }));
896
+ }, opts.agentProfile, { sessionTenant: opts.agentProfile?.tenant ?? sessionTenant ?? null, agentId: null }));
827
897
  process.exit(EXIT.AUTH);
828
898
  }
829
899
  const error = typedProfileError(err.errorCode);
@@ -890,7 +960,7 @@ export async function runWait(argv) {
890
960
  emitReceipt(withPrincipalReceipt(
891
961
  { schema_version: 1, outcome: "error", error: "register_failed", detail: String(err && err.message || err) },
892
962
  opts.agentProfile,
893
- { sessionTenant: opts.agentProfile.tenant, agentId: null },
963
+ { sessionTenant: opts.agentProfile?.tenant ?? sessionTenant ?? null, agentId: null },
894
964
  ));
895
965
  process.exit(EXIT.INTERNAL);
896
966
  }