@botbuddy/cli 1.14.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 +1 -1
- package/src/auth.mjs +7 -1
- package/src/commands.mjs +81 -5
- package/src/oauth-loopback.mjs +45 -1
- package/src/pw/coordinator.mjs +24 -6
- package/src/pw/run.mjs +38 -20
- package/src/run.mjs +29 -7
- package/src/test-lane.mjs +34 -11
- package/src/wait-profile.mjs +18 -1
- package/src/wait.mjs +86 -16
package/package.json
CHANGED
package/src/auth.mjs
CHANGED
|
@@ -69,6 +69,12 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
69
69
|
const noBrowser = Boolean(options.noBrowser);
|
|
70
70
|
// BOT-1566: optional tenant preselection (validated by the argument parser).
|
|
71
71
|
const tenant = typeof options.tenant === "string" && options.tenant ? options.tenant : null;
|
|
72
|
+
// BOT-1580: display-only caller identity hints. Forwarded to the authorization
|
|
73
|
+
// screen so the approver knows who requested the login; sanitized inside
|
|
74
|
+
// buildAuthorizeUrl. These never influence tenant, scope, or token minting.
|
|
75
|
+
const caller = typeof options.caller === "string" && options.caller ? options.caller : null;
|
|
76
|
+
const callerHarness = typeof options.callerHarness === "string" && options.callerHarness ? options.callerHarness : null;
|
|
77
|
+
const callerTicket = typeof options.callerTicket === "string" && options.callerTicket ? options.callerTicket : null;
|
|
72
78
|
|
|
73
79
|
log(`${bold("BotBuddy OAuth Login")}\n`);
|
|
74
80
|
|
|
@@ -108,7 +114,7 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
108
114
|
|
|
109
115
|
// AC-6/AC-7: build the authorization URL and always print it (headless users
|
|
110
116
|
// open it themselves). The CLI never fetches /authorize.
|
|
111
|
-
const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, tenant });
|
|
117
|
+
const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, tenant, caller, callerHarness, callerTicket });
|
|
112
118
|
log("");
|
|
113
119
|
log(dim("→ Authorize BotBuddy in your browser:"));
|
|
114
120
|
log(` ${cyan(authUrl)}`);
|
package/src/commands.mjs
CHANGED
|
@@ -91,7 +91,7 @@ ${bold("OPTIONS")}
|
|
|
91
91
|
--no-server Don't auto-start codex app-server
|
|
92
92
|
|
|
93
93
|
${bold("AUTH")}
|
|
94
|
-
login [--no-browser] [--tenant <slug>]
|
|
94
|
+
login [--no-browser] [--tenant <slug>] [--caller <name>] [--caller-harness <h>] [--caller-ticket <ref>]
|
|
95
95
|
Authenticate via OAuth (opens browser + localhost callback)
|
|
96
96
|
logout Remove saved credentials
|
|
97
97
|
status Show current auth status (local metadata + server check)
|
|
@@ -176,7 +176,16 @@ export class LoginUsageError extends Error {
|
|
|
176
176
|
// preselects the tenant on the OAuth picker; a malformed slug is a usage
|
|
177
177
|
// error with exit code 2 (never sent to the server).
|
|
178
178
|
export function parseLoginArgs(args) {
|
|
179
|
-
const out = { noBrowser: false, tenant: null, help: false };
|
|
179
|
+
const out = { noBrowser: false, tenant: null, help: false, caller: null, callerHarness: null, callerTicket: null };
|
|
180
|
+
// BOT-1580: display-only caller flags. Unlike --tenant they are free text (an
|
|
181
|
+
// unverified hint shown on the consent screen), so they are never validated
|
|
182
|
+
// into a usage error here — sanitization/length-capping happens when the URL is
|
|
183
|
+
// built. A value is taken verbatim from `--flag value` or `--flag=value`.
|
|
184
|
+
const CALLER_FLAGS = {
|
|
185
|
+
"--caller": "caller",
|
|
186
|
+
"--caller-harness": "callerHarness",
|
|
187
|
+
"--caller-ticket": "callerTicket",
|
|
188
|
+
};
|
|
180
189
|
for (let i = 0; i < args.length; i++) {
|
|
181
190
|
const arg = args[i];
|
|
182
191
|
if (arg === "--help" || arg === "-h") { out.help = true; continue; }
|
|
@@ -189,6 +198,31 @@ export function parseLoginArgs(args) {
|
|
|
189
198
|
out.tenant = value;
|
|
190
199
|
continue;
|
|
191
200
|
}
|
|
201
|
+
let matchedCaller = false;
|
|
202
|
+
for (const [flag, key] of Object.entries(CALLER_FLAGS)) {
|
|
203
|
+
if (arg === flag || arg.startsWith(`${flag}=`)) {
|
|
204
|
+
let value;
|
|
205
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
206
|
+
// `--flag=value` takes the value verbatim (an explicit leading dash is
|
|
207
|
+
// the user's own choice, e.g. --caller=--weird).
|
|
208
|
+
value = arg.slice(`${flag}=`.length);
|
|
209
|
+
} else {
|
|
210
|
+
// `--flag value`: the next token is the value only if it exists and is
|
|
211
|
+
// not itself an option. Otherwise it would silently swallow the next
|
|
212
|
+
// flag (e.g. `--caller --no-browser` would set caller="--no-browser"
|
|
213
|
+
// and drop --no-browser) — reject that as a usage error instead.
|
|
214
|
+
value = args[i + 1];
|
|
215
|
+
if (typeof value !== "string" || value.startsWith("-")) {
|
|
216
|
+
throw new LoginUsageError(`error: ${flag} requires a value`, { exitCode: 2 });
|
|
217
|
+
}
|
|
218
|
+
i++;
|
|
219
|
+
}
|
|
220
|
+
out[key] = value;
|
|
221
|
+
matchedCaller = true;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (matchedCaller) continue;
|
|
192
226
|
if (arg.startsWith("-")) {
|
|
193
227
|
throw new LoginUsageError(`Unknown login option: ${arg}. Run ${cyan("botbuddy login --help")}.`);
|
|
194
228
|
}
|
|
@@ -196,6 +230,26 @@ export function parseLoginArgs(args) {
|
|
|
196
230
|
return out;
|
|
197
231
|
}
|
|
198
232
|
|
|
233
|
+
// BOT-1580: resolve the caller identity from parsed flags, falling back to the
|
|
234
|
+
// BOTBUDDY_CALLER* environment variables so an agent harness can advertise who it
|
|
235
|
+
// is without every wrapper having to pass flags. A flag always wins over the env.
|
|
236
|
+
// Returns only the three caller fields; empty values collapse to null.
|
|
237
|
+
export function resolveCaller(parsed = {}, env = {}) {
|
|
238
|
+
const pick = (flagVal, envKey) => {
|
|
239
|
+
// A flag that was PROVIDED wins over the environment, even when it is an
|
|
240
|
+
// explicit empty `--caller=` — that is how a user clears a stale inherited
|
|
241
|
+
// identity, so it must NOT fall back to the env. `null` means the flag was
|
|
242
|
+
// omitted → fall back to the env var. Either way an empty result is null.
|
|
243
|
+
const raw = flagVal != null ? flagVal : env[envKey];
|
|
244
|
+
return typeof raw === "string" && raw !== "" ? raw : null;
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
caller: pick(parsed.caller, "BOTBUDDY_CALLER"),
|
|
248
|
+
callerHarness: pick(parsed.callerHarness, "BOTBUDDY_CALLER_HARNESS"),
|
|
249
|
+
callerTicket: pick(parsed.callerTicket, "BOTBUDDY_CALLER_TICKET"),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
199
253
|
// BOT-1383: login now runs the RFC 8252 loopback flow (opens the browser,
|
|
200
254
|
// waits for the localhost callback). Parse --no-browser / --tenant and print
|
|
201
255
|
// focused help.
|
|
@@ -209,8 +263,10 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
|
|
|
209
263
|
die(err.message);
|
|
210
264
|
}
|
|
211
265
|
if (options.help) return loginHelp();
|
|
266
|
+
// BOT-1580: merge caller flags with the BOTBUDDY_CALLER* env fallback.
|
|
267
|
+
const caller = resolveCaller(options, process.env);
|
|
212
268
|
try {
|
|
213
|
-
await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant });
|
|
269
|
+
await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant, ...caller });
|
|
214
270
|
} catch (err) {
|
|
215
271
|
die(err.message);
|
|
216
272
|
}
|
|
@@ -221,6 +277,7 @@ function loginHelp() {
|
|
|
221
277
|
|
|
222
278
|
${bold("USAGE")}
|
|
223
279
|
botbuddy login [--no-browser] [--tenant <slug>]
|
|
280
|
+
[--caller <name>] [--caller-harness <harness>] [--caller-ticket <ref>]
|
|
224
281
|
|
|
225
282
|
${bold("HOW IT WORKS")}
|
|
226
283
|
Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
|
|
@@ -238,6 +295,20 @@ ${bold("OPTIONS")}
|
|
|
238
295
|
Preselect a tenant (e.g. ${cyan("supply-guard")}) on the sign-in page when
|
|
239
296
|
your account belongs to more than one. Must be a lowercase slug;
|
|
240
297
|
membership is still enforced by the server.
|
|
298
|
+
--caller <name>
|
|
299
|
+
Show WHO requested this login on the authorization screen, so
|
|
300
|
+
the human approving it can confirm the request came from the
|
|
301
|
+
agent/session they are actually running. Display-only and
|
|
302
|
+
unverified — it never affects the tenant, scope, or token.
|
|
303
|
+
--caller-harness <harness>
|
|
304
|
+
Optional harness label shown next to the caller (e.g.
|
|
305
|
+
${cyan("claude-code")}, ${cyan("codex")}).
|
|
306
|
+
--caller-ticket <ref>
|
|
307
|
+
Optional ticket the caller is working on. A full
|
|
308
|
+
${dim("https://linear.app/…")} URL is shown as a link; anything else is
|
|
309
|
+
shown as plain text.
|
|
310
|
+
${dim("Each --caller* value also falls back to $BOTBUDDY_CALLER,")}
|
|
311
|
+
${dim("$BOTBUDDY_CALLER_HARNESS and $BOTBUDDY_CALLER_TICKET.")}
|
|
241
312
|
|
|
242
313
|
${bold("NOTES")}
|
|
243
314
|
• The authorization URL is always printed so you can open it manually.
|
|
@@ -264,14 +335,19 @@ async function cmdStart(args) {
|
|
|
264
335
|
// (owner token from `login`, agent key from `profile setup`), so probe those
|
|
265
336
|
// rather than a plaintext config secret.
|
|
266
337
|
try {
|
|
338
|
+
// BOT-1580: the auto-login path has no --caller flags of its own, but a
|
|
339
|
+
// harness that exports BOTBUDDY_CALLER* should still be identified on the
|
|
340
|
+
// authorization screen. Resolve the caller from the environment and forward
|
|
341
|
+
// it, exactly as `botbuddy login` does.
|
|
342
|
+
const caller = resolveCaller({}, process.env);
|
|
267
343
|
const owner = await resolveOwnerToken({ getConfig });
|
|
268
344
|
const agentKey = owner ? null : await resolveAgentKey();
|
|
269
345
|
if (!owner && !agentKey) {
|
|
270
346
|
console.log(dim("→ No credentials found. Starting login...\n"));
|
|
271
|
-
await doLogin();
|
|
347
|
+
await doLogin({ ...caller });
|
|
272
348
|
} else if (owner && owner.expiresAt && owner.expiresAt <= Date.now() && !(await resolveAgentKey())) {
|
|
273
349
|
console.log(dim("→ Token expired. Re-authenticating...\n"));
|
|
274
|
-
await doLogin();
|
|
350
|
+
await doLogin({ ...caller });
|
|
275
351
|
}
|
|
276
352
|
} catch (err) {
|
|
277
353
|
die(err.message);
|
package/src/oauth-loopback.mjs
CHANGED
|
@@ -44,9 +44,43 @@ export function generateState() {
|
|
|
44
44
|
return randomBytes(16).toString("hex");
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// BOT-1580: display-only caller identity is UNTRUSTED text. Sanitize it before
|
|
48
|
+
// it ever enters the authorize URL: strip control characters (they can break the
|
|
49
|
+
// consent-screen layout or smuggle terminal escapes into any log that echoes the
|
|
50
|
+
// URL) and cap the length. Returns the cleaned string, or null when nothing
|
|
51
|
+
// printable remains — so an absent/blank/garbage field is simply omitted rather
|
|
52
|
+
// than rendered as an empty or fabricated identity. This is defense in depth; the
|
|
53
|
+
// web consent screen sanitizes again at render time and never trusts this hint
|
|
54
|
+
// for tenant selection, scope, or token minting.
|
|
55
|
+
export function sanitizeCallerField(value, maxLen = 200) {
|
|
56
|
+
if (typeof value !== "string") return null;
|
|
57
|
+
// Strip Unicode control (C0/C1/DEL), format (bidi overrides, zero-width, BOM,
|
|
58
|
+
// soft hyphen), and line/paragraph separators — an untrusted caller must not be
|
|
59
|
+
// able to alter line breaking or visual ordering on the consent screen — while
|
|
60
|
+
// preserving legitimate visible non-ASCII in names.
|
|
61
|
+
const cleaned = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "").trim();
|
|
62
|
+
if (!cleaned) return null;
|
|
63
|
+
return cleaned.slice(0, maxLen);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// BOT-1580: length caps per caller field. Names and harnesses are short labels;
|
|
67
|
+
// a ticket may be a full Linear URL, so it gets more headroom.
|
|
68
|
+
export const CALLER_FIELD_LIMITS = { name: 80, harness: 40, ticket: 200 };
|
|
69
|
+
|
|
47
70
|
// AC-6/AC-7: build the complete /authorize URL. The browser follows it; the CLI
|
|
48
71
|
// never fetches it. `redirectUri` must be the exact loopback callback we bound.
|
|
49
|
-
export function buildAuthorizeUrl({
|
|
72
|
+
export function buildAuthorizeUrl({
|
|
73
|
+
serverUrl,
|
|
74
|
+
clientId,
|
|
75
|
+
redirectUri,
|
|
76
|
+
state,
|
|
77
|
+
codeChallenge,
|
|
78
|
+
scope = "read write lock",
|
|
79
|
+
tenant = null,
|
|
80
|
+
caller = null,
|
|
81
|
+
callerHarness = null,
|
|
82
|
+
callerTicket = null,
|
|
83
|
+
}) {
|
|
50
84
|
const url = new URL(`${serverUrl}/authorize`);
|
|
51
85
|
url.searchParams.set("client_id", clientId);
|
|
52
86
|
url.searchParams.set("redirect_uri", redirectUri);
|
|
@@ -59,6 +93,16 @@ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, cod
|
|
|
59
93
|
// it to the /mcp-auth picker as `requestedTenant`; membership is still
|
|
60
94
|
// enforced server-side at authorize_complete — this only preselects.
|
|
61
95
|
if (tenant) url.searchParams.set("tenant", tenant);
|
|
96
|
+
// BOT-1580: display-only caller identity, so the human approving the login can
|
|
97
|
+
// check it against the agent session they are actually running. These are
|
|
98
|
+
// UNVERIFIED hints — /authorize only forwards them to the consent screen; they
|
|
99
|
+
// never influence tenant, scope, or the minted token.
|
|
100
|
+
const callerName = sanitizeCallerField(caller, CALLER_FIELD_LIMITS.name);
|
|
101
|
+
const harness = sanitizeCallerField(callerHarness, CALLER_FIELD_LIMITS.harness);
|
|
102
|
+
const ticket = sanitizeCallerField(callerTicket, CALLER_FIELD_LIMITS.ticket);
|
|
103
|
+
if (callerName) url.searchParams.set("caller", callerName);
|
|
104
|
+
if (harness) url.searchParams.set("caller_harness", harness);
|
|
105
|
+
if (ticket) url.searchParams.set("caller_ticket", ticket);
|
|
62
106
|
return url.toString();
|
|
63
107
|
}
|
|
64
108
|
|
package/src/pw/coordinator.mjs
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import { SERVER_URL } from "../config.mjs";
|
|
2
|
-
|
|
3
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
103
|
-
session_id
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
: {
|
package/src/wait-profile.mjs
CHANGED
|
@@ -72,7 +72,24 @@ export async function resolveAgentProfile({
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function withPrincipalReceipt(receipt, profile, registration = {}) {
|
|
75
|
-
if (!profile)
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
963
|
+
{ sessionTenant: opts.agentProfile?.tenant ?? sessionTenant ?? null, agentId: null },
|
|
894
964
|
));
|
|
895
965
|
process.exit(EXIT.INTERNAL);
|
|
896
966
|
}
|