@botbuddy/cli 1.19.2 → 1.21.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/agent-key.mjs +29 -0
- package/src/credential-kinds.mjs +9 -5
- package/src/pw/coordinator.mjs +1 -1
- package/src/pw/run.mjs +8 -6
- package/src/run.mjs +10 -7
- package/src/test-lane.mjs +9 -7
- package/src/wait-core.mjs +83 -8
- package/src/wait.mjs +29 -17
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// BOT-1582 — the per-session agent key the CLI presents to the relay for
|
|
2
|
+
// `botbuddy wait`/`run`/`test`/`pw`.
|
|
3
|
+
//
|
|
4
|
+
// register_agent mints this token (bound to the work-graph session it returns)
|
|
5
|
+
// and the relay derives agent, tenant, and the arming session from it, so a
|
|
6
|
+
// session that exports it needs no --profile/--session-id/--token.
|
|
7
|
+
//
|
|
8
|
+
// Prefix: `bb_agent_` + 64 lowercase hex, mirroring the server's SESSION_TOKEN_RE
|
|
9
|
+
// (supabase/functions/_shared/sessionToken.ts). The BOT-1572 `bb_sess_` shape is
|
|
10
|
+
// accepted as a legacy alias for one release (its hash is already on `sessions`).
|
|
11
|
+
// This is a SHAPE check only — authorization is the server's hashed lookup.
|
|
12
|
+
export const AGENT_KEY_RE = /^bb_(agent|sess)_[0-9a-f]{64}$/;
|
|
13
|
+
|
|
14
|
+
// BOT-1582: the env var was renamed BOTBUDDY_SESSION_TOKEN → BOTBUDDY_AGENT_KEY.
|
|
15
|
+
// The new name wins; the old one is honoured for one release so a session that
|
|
16
|
+
// still exports it keeps working. Returns null when neither is set.
|
|
17
|
+
//
|
|
18
|
+
// An EMPTY/blank value counts as unset (not as a present-but-empty token): an env
|
|
19
|
+
// template that declares BOTBUDDY_AGENT_KEY="" while a valid BOTBUDDY_SESSION_TOKEN
|
|
20
|
+
// is still exported must fall through to the legacy name, or the one-release
|
|
21
|
+
// compatibility guarantee breaks (Codex P2). So trim and skip blanks rather than
|
|
22
|
+
// `??`, which would stop at the empty string.
|
|
23
|
+
export function readAgentKeyEnv(env = process.env) {
|
|
24
|
+
const first = typeof env.BOTBUDDY_AGENT_KEY === "string" ? env.BOTBUDDY_AGENT_KEY.trim() : "";
|
|
25
|
+
if (first) return env.BOTBUDDY_AGENT_KEY;
|
|
26
|
+
const legacy = typeof env.BOTBUDDY_SESSION_TOKEN === "string" ? env.BOTBUDDY_SESSION_TOKEN.trim() : "";
|
|
27
|
+
if (legacy) return env.BOTBUDDY_SESSION_TOKEN;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/credential-kinds.mjs
CHANGED
|
@@ -17,11 +17,15 @@ export const CREDENTIAL_PREFIXES = [
|
|
|
17
17
|
{ prefix: "mcp_at_", kind: "oauth", label: "OAuth owner token" },
|
|
18
18
|
{ prefix: "bb_pat_", kind: "pat", label: "personal access token" },
|
|
19
19
|
{ prefix: "bb_cli_", kind: "cli", label: "client key" },
|
|
20
|
-
// BOT-1573/1582 transition:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
20
|
+
// BOT-1573/1582 transition: bb_agent_ is now BOTH the per-session agent token
|
|
21
|
+
// (BOT-1582 renamed it from bb_sess_) AND the older shared service carrier, so
|
|
22
|
+
// the prefix ALONE can no longer tell them apart. Classification is display
|
|
23
|
+
// only (never auth), and mislabelling a live carrier as an agent is worse than
|
|
24
|
+
// the reverse, so bb_agent_ stays classified as the carrier — matching the UI
|
|
25
|
+
// adapter. bb_sess_ (in-flight BOT-1572 tokens, accepted one release) is the
|
|
26
|
+
// one unambiguously-a-session shape, so it keeps the agent kind. A
|
|
27
|
+
// shape-independent way to name a live bb_agent_ session token is deferred to
|
|
28
|
+
// the taxonomy/status work (BOT-1573).
|
|
25
29
|
{ prefix: "bb_agent_", kind: "svc", label: "carrier (legacy)" },
|
|
26
30
|
{ prefix: "bb_sess_", kind: "agent", label: "agent (session)" },
|
|
27
31
|
{ prefix: "bb_ci_", kind: "ci", label: "CI key" },
|
package/src/pw/coordinator.mjs
CHANGED
|
@@ -15,7 +15,7 @@ function laneCoordinator(kind, call, agentId) {
|
|
|
15
15
|
return { kind, async status({ host, slot }) { const result = await call("list_resources", { host, subtype: "playwright_lane" }); const list = Array.isArray(result) ? result : result.resources ?? []; const resource = list.find((item) => item.name === `playwright_lane:${host}:${slot}` || String(item.slot) === String(slot)); /* BOT-1490: canonical_host is a TOP-LEVEL field the server echoes even on an empty page (not-held lane); owner_is_caller is per-row. Older servers send neither → null. */ const canonicalHost = (result && !Array.isArray(result) ? result.canonical_host : null) ?? null; return resource ? { held: resource.status !== "free", heldBy: resource.owner_agent_id ?? null, heldByName: resource.agents?.name ?? null, host: resource.host ?? null, name: resource.name ?? null, slot: resource.slot ?? null, canonicalHost, ownerIsCaller: resource.owner_is_caller ?? null } : { held: false, heldBy: null, heldByName: null, host: null, name: null, slot: null, canonicalHost, ownerIsCaller: null }; }, async emit(event) { await call("record_lane_event", event); }, agentId };
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// BOT-1572: authenticate lock verification with a per-session `
|
|
18
|
+
// BOT-1572/1582: authenticate lock verification with a per-session `bb_agent_` token —
|
|
19
19
|
// the server resolves it to the session agent (mcp-server authenticateAgent), so
|
|
20
20
|
// `list_resources`/`record_lane_event` run AS the session, and holder matching
|
|
21
21
|
// rides on the server's `owner_is_caller`. No machine profile is required.
|
package/src/pw/run.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveAgentProfile } from "../wait-profile.mjs";
|
|
|
8
8
|
import { readProfileIdentity } from "../agent-credential-store.mjs";
|
|
9
9
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
10
10
|
import { VERSION } from "../version.mjs";
|
|
11
|
+
import { readAgentKeyEnv } from "../agent-key.mjs";
|
|
11
12
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
12
13
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
13
14
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
@@ -23,18 +24,19 @@ function redact(value, secretValues = []) { return secretValues.reduce((text, se
|
|
|
23
24
|
async function readRegisteredAgentId() { try { await loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
|
|
24
25
|
async function gate({ env, host, lane, deps }) {
|
|
25
26
|
if (env.BB_PW_NO_LOCK === "1") return { allowed: true };
|
|
26
|
-
// BOT-1572: a per-session `
|
|
27
|
-
// the session agent — no machine profile needed. It takes precedence over the
|
|
27
|
+
// BOT-1572/1582: a per-session `bb_agent_` token authenticates lock verification
|
|
28
|
+
// AS the session agent — no machine profile needed. It takes precedence over the
|
|
28
29
|
// profile path; holder matching then rides on the server's owner_is_caller
|
|
29
30
|
// (plus any local session/registered agent id). The profile path stays intact
|
|
30
|
-
// for a session that has not adopted the token.
|
|
31
|
-
|
|
31
|
+
// for a session that has not adopted the token. $BOTBUDDY_AGENT_KEY is the norm
|
|
32
|
+
// ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
|
|
33
|
+
const sessionToken = deps.sessionToken ?? readAgentKeyEnv(env);
|
|
32
34
|
const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
|
|
33
35
|
const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
|
|
34
36
|
let coordinator, selfAgentIds, callerId;
|
|
35
37
|
if (sessionToken) {
|
|
36
38
|
coordinator = deps.coordinator ?? createSessionTokenCoordinator({ token: sessionToken, fetchImpl: deps.fetch });
|
|
37
|
-
if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: $
|
|
39
|
+
if (coordinator.kind === "unverified") return { allowed: false, message: "bb-pw: $BOTBUDDY_AGENT_KEY is malformed. Re-register the agent, or set BB_PW_NO_LOCK=1 for local-only work." };
|
|
38
40
|
selfAgentIds = new Set([sessionAgentId, registeredAgentId, coordinator.agentId].filter(Boolean));
|
|
39
41
|
callerId = coordinator.agentId ?? sessionAgentId ?? "session-token";
|
|
40
42
|
} else {
|
|
@@ -99,7 +101,7 @@ export async function runPw(argv, deps = {}) {
|
|
|
99
101
|
async function runPwInner(argv, deps = {}) {
|
|
100
102
|
const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
|
|
101
103
|
if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
|
|
102
|
-
// BOT-1572 (AC-8): --session-token / $
|
|
104
|
+
// BOT-1572/1582 (AC-8): --session-token / $BOTBUDDY_AGENT_KEY is accepted as a
|
|
103
105
|
// session identity alongside --session-id, so a token-armed session need not
|
|
104
106
|
// pass an id. Holder matching still rides on the server's owner_is_caller and
|
|
105
107
|
// the resolved agent ids (gate()).
|
package/src/run.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { spawn } from "child_process";
|
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { callToolJson } from "./api.mjs";
|
|
16
16
|
import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
|
|
17
|
+
import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
|
|
17
18
|
|
|
18
19
|
export const RUN_SCHEMA_VERSION = 1;
|
|
19
20
|
export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
|
|
@@ -24,14 +25,16 @@ const MAX_CAPTURE_BYTES = 8_192;
|
|
|
24
25
|
// Well under AC-3's 5 s so a begin/verdict is visible within the window.
|
|
25
26
|
const LANE_FLUSH_INTERVAL_MS = 2_500;
|
|
26
27
|
|
|
27
|
-
// BOT-
|
|
28
|
-
|
|
28
|
+
// BOT-1582: the server's session-token shape (bb_agent_ + 64 hex) plus the
|
|
29
|
+
// BOT-1572 bb_sess_ legacy alias, both accepted for one release.
|
|
30
|
+
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
29
31
|
|
|
30
32
|
export function parseRunArgs(argv, env = process.env) {
|
|
31
|
-
// BOT-1572 (AC-8): $
|
|
32
|
-
// session, so --session-id becomes optional (the relay derives it).
|
|
33
|
+
// BOT-1572/1582 (AC-8): $BOTBUDDY_AGENT_KEY authenticates the run and carries its
|
|
34
|
+
// session, so --session-id becomes optional (the relay derives it). The old
|
|
35
|
+
// $BOTBUDDY_SESSION_TOKEN is still accepted for one release. $BOTBUDDY_SESSION_ID
|
|
33
36
|
// is the default when a plain id is used (parity with `botbuddy test`).
|
|
34
|
-
const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: env
|
|
37
|
+
const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentKeyEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
|
|
35
38
|
const errors = [];
|
|
36
39
|
const separator = argv.indexOf("--");
|
|
37
40
|
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
@@ -66,8 +69,8 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
66
69
|
// BOT-1572: a session token stands in for --session-id (the backend derives the
|
|
67
70
|
// session from the token). A malformed token, or a plain id AND a differing
|
|
68
71
|
// token, is a hard error.
|
|
69
|
-
if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$
|
|
70
|
-
if (!opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $
|
|
72
|
+
if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>");
|
|
73
|
+
if (!opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
|
|
71
74
|
if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
|
|
72
75
|
if (!command.length) errors.push("a workload is required after --");
|
|
73
76
|
if (!Number.isInteger(opts.expectedDuration) || opts.expectedDuration < 0) errors.push("--expected-duration must be a non-negative integer");
|
package/src/test-lane.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { execFileSync } from "node:child_process";
|
|
|
21
21
|
|
|
22
22
|
import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
|
|
23
23
|
import { callToolJson } from "./api.mjs";
|
|
24
|
+
import { readAgentKeyEnv } from "./agent-key.mjs";
|
|
24
25
|
|
|
25
26
|
// EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
|
|
26
27
|
export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
|
|
@@ -36,9 +37,10 @@ const LANE_KIND_SET = new Set(LANE_KINDS);
|
|
|
36
37
|
export function parseTestArgs(argv, { env = process.env } = {}) {
|
|
37
38
|
const opts = {
|
|
38
39
|
sessionId: env.BOTBUDDY_SESSION_ID ?? null,
|
|
39
|
-
// BOT-1572 (AC-8): a session token stands in for --session-id; it flows to
|
|
40
|
-
// sub-invoked `run`/`wait` which derive the session from it.
|
|
41
|
-
|
|
40
|
+
// BOT-1572/1582 (AC-8): a session token stands in for --session-id; it flows to
|
|
41
|
+
// the sub-invoked `run`/`wait` which derive the session from it. $BOTBUDDY_AGENT_KEY
|
|
42
|
+
// is the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release).
|
|
43
|
+
sessionToken: readAgentKeyEnv(env),
|
|
42
44
|
environment: "local",
|
|
43
45
|
ticket: null, pr: null, repo: null,
|
|
44
46
|
laneKind: null,
|
|
@@ -136,8 +138,8 @@ export function defaultGitInfo({ cwd = process.cwd(), ticket = null, pr = null,
|
|
|
136
138
|
// The production lane launcher: run the lane through the durable `botbuddy run`
|
|
137
139
|
// worker, carrying the telemetry env into its detached child.
|
|
138
140
|
async function defaultLaunchLane({ command, sessionId, sessionToken, environment, expectedDurationSeconds, childEnv, testRun, cwd, call }) {
|
|
139
|
-
// BOT-1572: pass --session-id only when a plain id is used; a token-only lane
|
|
140
|
-
// relies on the inherited $
|
|
141
|
+
// BOT-1572/1582: pass --session-id only when a plain id is used; a token-only lane
|
|
142
|
+
// relies on the inherited $BOTBUDDY_AGENT_KEY (and an explicit --session-token
|
|
141
143
|
// so the child never falls back to a machine credential).
|
|
142
144
|
const argv = [
|
|
143
145
|
...(sessionId ? ["--session-id", sessionId] : []),
|
|
@@ -150,7 +152,7 @@ async function defaultLaunchLane({ command, sessionId, sessionToken, environment
|
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
function waitCommand(testRunId, sessionId, sessionToken) {
|
|
153
|
-
// BOT-1572: a token-armed session runs the wait with only $
|
|
155
|
+
// BOT-1572/1582: a token-armed session runs the wait with only $BOTBUDDY_AGENT_KEY.
|
|
154
156
|
const idFlag = sessionId && !sessionToken ? ` --session-id ${sessionId}` : "";
|
|
155
157
|
return `botbuddy wait 'test-run:id=${testRunId}'${idFlag} --heartbeat`;
|
|
156
158
|
}
|
|
@@ -169,7 +171,7 @@ export async function launchTestLane(argv, {
|
|
|
169
171
|
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors }) };
|
|
170
172
|
}
|
|
171
173
|
if (!opts.sessionId && !opts.sessionToken) {
|
|
172
|
-
process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID or $
|
|
174
|
+
process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID or $BOTBUDDY_AGENT_KEY)\n");
|
|
173
175
|
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
|
|
174
176
|
}
|
|
175
177
|
|
package/src/wait-core.mjs
CHANGED
|
@@ -765,6 +765,28 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
765
765
|
// cannot actually be served. The wait loop handles it separately (fold the
|
|
766
766
|
// capacity_source_stale marker / fast exit); it must not read as capacity here.
|
|
767
767
|
if (signal.payload?.source_stale === true) return false;
|
|
768
|
+
// BOT-1577: a RANKED grant restricts the host broadcast to named winners.
|
|
769
|
+
// When the payload carries granted_wait_session_ids, match ONLY if THIS
|
|
770
|
+
// wait's session id is in it — otherwise keep parking (the server already
|
|
771
|
+
// vetted the slot count and this wait's minSlots when it built the list).
|
|
772
|
+
// A payload WITHOUT the list is a legacy broadcast (older server, or the
|
|
773
|
+
// no-contention case) → fall back to the free_slots >= N threshold, so this
|
|
774
|
+
// stays backward compatible.
|
|
775
|
+
const granted = signal.payload?.granted_wait_session_ids;
|
|
776
|
+
if (Array.isArray(granted)) {
|
|
777
|
+
if (waitSessionId == null || !granted.includes(waitSessionId)) return false;
|
|
778
|
+
// BOT-1577: a ranked grant is only valid until its 120s hold expires. This
|
|
779
|
+
// signal is durable — on a reconnect from an old cursor (or a slow wake) after
|
|
780
|
+
// the hold lapsed and the slot was re-granted to another waiter, event-stream
|
|
781
|
+
// replays the original grant. Reject it once expired — and reserve
|
|
782
|
+
// GRANT_CONSUME_MARGIN_MS (finalize feed-lag reprobe + the up-to-5s finalizeWait
|
|
783
|
+
// that runs before `botbuddy wait` exits), since a grant that lapses in that window
|
|
784
|
+
// is re-granted before the caller can start — so the original client does not start
|
|
785
|
+
// too (double-provision). A payload without grant_expires_at (older server) is accepted;
|
|
786
|
+
// a present-but-malformed expiry fails closed (parks) rather than matching forever.
|
|
787
|
+
if (!grantUnexpired(signal.payload?.grant_expires_at, GRANT_CONSUME_MARGIN_MS)) return false;
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
768
790
|
const free = Number(signal.payload?.free_slots);
|
|
769
791
|
return Number.isFinite(free) && free >= params.minSlots;
|
|
770
792
|
}
|
|
@@ -901,6 +923,55 @@ export function matchFrame(frame, conditions, waitSessionId, sessionTenant) {
|
|
|
901
923
|
return null;
|
|
902
924
|
}
|
|
903
925
|
|
|
926
|
+
/**
|
|
927
|
+
* BOT-1577: derive this wait's grant detail from a ranked container_capacity
|
|
928
|
+
* signal, for the receipt (`matched[].grant = {position, tier, score}`). Returns
|
|
929
|
+
* null when the signal is not a ranked capacity grant addressed to this wait
|
|
930
|
+
* (legacy broadcast, another host, or a payload without the grant list).
|
|
931
|
+
* `position` is 1-based in the server's grant order; tier/score come from the
|
|
932
|
+
* index-aligned granted_grants entry when present.
|
|
933
|
+
*/
|
|
934
|
+
// BOT-1577: a ranked capacity grant is valid only until its 120s hold expires
|
|
935
|
+
// (payload.grant_expires_at, an ISO timestamp). `marginMs` reserves remaining-time before
|
|
936
|
+
// the expiry: a grant accepted with less than the margin left could lapse between the
|
|
937
|
+
// match and the terminal receipt — finalize() can spend up to FEED_PROBE_BUDGET_MS in the
|
|
938
|
+
// feed-lag reprobe for a mixed capacity/linear wait — after which the periodic evaluator
|
|
939
|
+
// re-grants the slot, so accepting it would double-provision (BOT-1577 P1). A payload
|
|
940
|
+
// without the field (older server) is treated as valid for backward compatibility; an
|
|
941
|
+
// unparseable value fails open (valid) rather than dropping a live grant.
|
|
942
|
+
// Minimum hold time a grant must have left when accepted, so it survives the whole path
|
|
943
|
+
// from acceptance to the caller actually PROVISIONING the slot (BOT-1577 P1):
|
|
944
|
+
// • finalize()'s feed-lag reprobe .......... FEED_PROBE_BUDGET_MS (2s)
|
|
945
|
+
// • finalizeWait() runWait() awaits ......... 5s (after the receipt, before exit)
|
|
946
|
+
// • the caller's docker preflight ........... 30s (a single Docker command budget)
|
|
947
|
+
// Below this, a delayed/replayed grant can lapse mid-preflight and be re-granted before
|
|
948
|
+
// the caller starts, double-provisioning the slot. The 120s hold's remainder (~120−37s)
|
|
949
|
+
// then covers the actual `supabase start`; a caller that provisions beyond that window is
|
|
950
|
+
// the consumer's responsibility to re-validate at start (the SG start gate, BOT-1576).
|
|
951
|
+
const GRANT_CONSUME_MARGIN_MS = 2000 + 5000 + 30000;
|
|
952
|
+
function grantUnexpired(grantExpiresAt, marginMs = 0) {
|
|
953
|
+
if (grantExpiresAt == null) return true; // absent (older server) ⇒ accept (legacy)
|
|
954
|
+
const t = Date.parse(grantExpiresAt);
|
|
955
|
+
if (!Number.isFinite(t)) return false; // present but MALFORMED ⇒ fail closed (park)
|
|
956
|
+
return Date.now() + marginMs < t;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export function capacityGrantForReceipt(signal, waitSessionId) {
|
|
960
|
+
if (!signal || signal.signal_type !== "container_capacity" || waitSessionId == null) return null;
|
|
961
|
+
const granted = signal.payload?.granted_wait_session_ids;
|
|
962
|
+
if (!Array.isArray(granted)) return null;
|
|
963
|
+
const idx = granted.indexOf(waitSessionId);
|
|
964
|
+
if (idx < 0) return null;
|
|
965
|
+
if (!grantUnexpired(signal.payload?.grant_expires_at)) return null; // BOT-1577: stale grant ⇒ no receipt
|
|
966
|
+
const grant = { position: idx + 1 };
|
|
967
|
+
const meta = Array.isArray(signal.payload?.granted_grants) ? signal.payload.granted_grants[idx] : null;
|
|
968
|
+
if (meta && typeof meta === "object") {
|
|
969
|
+
if (meta.tier != null) grant.tier = meta.tier;
|
|
970
|
+
if (meta.score != null) grant.score = meta.score;
|
|
971
|
+
}
|
|
972
|
+
return grant;
|
|
973
|
+
}
|
|
974
|
+
|
|
904
975
|
// ---------------------------------------------------------------------------
|
|
905
976
|
// The wait loop.
|
|
906
977
|
// ---------------------------------------------------------------------------
|
|
@@ -1425,16 +1496,20 @@ export async function runWaitLoop({
|
|
|
1425
1496
|
|
|
1426
1497
|
const matched = matchFrame(frame, conditions, waitSessionId, sessionTenant);
|
|
1427
1498
|
if (matched) {
|
|
1499
|
+
const entry = {
|
|
1500
|
+
condition_id: matched.id,
|
|
1501
|
+
signal_type: signal.signal_type,
|
|
1502
|
+
seq: signal.seq,
|
|
1503
|
+
subject_key: signal.subject_key,
|
|
1504
|
+
payload: signal.payload ?? null,
|
|
1505
|
+
provenance: "spine",
|
|
1506
|
+
};
|
|
1507
|
+
// BOT-1577: a ranked capacity grant records this wait's position/tier/score.
|
|
1508
|
+
const grant = capacityGrantForReceipt(signal, waitSessionId);
|
|
1509
|
+
if (grant) entry.grant = grant;
|
|
1428
1510
|
return finalize("matched", {
|
|
1429
1511
|
exitCode: EXIT.MATCHED,
|
|
1430
|
-
matched: [
|
|
1431
|
-
condition_id: matched.id,
|
|
1432
|
-
signal_type: signal.signal_type,
|
|
1433
|
-
seq: signal.seq,
|
|
1434
|
-
subject_key: signal.subject_key,
|
|
1435
|
-
payload: signal.payload ?? null,
|
|
1436
|
-
provenance: "spine",
|
|
1437
|
-
}],
|
|
1512
|
+
matched: [entry],
|
|
1438
1513
|
});
|
|
1439
1514
|
}
|
|
1440
1515
|
}
|
package/src/wait.mjs
CHANGED
|
@@ -18,13 +18,14 @@ import { resolveAgentProfile, withPrincipalReceipt, PROFILE_FILE } from "./wait-
|
|
|
18
18
|
import { VERSION } from "./version.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
21
|
+
import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
|
|
21
22
|
|
|
22
23
|
// A protocol is deliberately distinct from package semver: compatible pinned
|
|
23
24
|
// clients keep working until the server raises this minimum, while a stale
|
|
24
25
|
// implementation gets a typed, safe upgrade instruction.
|
|
25
26
|
// BOT-1554: protocol 2 makes --session-id mandatory for every non-timer wait (the
|
|
26
27
|
// server keeps MINIMUM_WAIT_PROTOCOL=1 so pre-2 installs are not 426'd).
|
|
27
|
-
// BOT-1572: protocol 3 — a $
|
|
28
|
+
// BOT-1572/1582: protocol 3 — a $BOTBUDDY_AGENT_KEY (bb_agent_) authenticates the
|
|
28
29
|
// wait on its own; no profile / --session-id / --token needed (the relay derives
|
|
29
30
|
// agent, tenant, and session from the token). Falls back to protocol-2 behaviour
|
|
30
31
|
// when no session token is present.
|
|
@@ -33,8 +34,9 @@ const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
|
|
|
33
34
|
const MIN_RECEIPT_MAX_BYTES = 512;
|
|
34
35
|
// BOT-1554: identical to the server's session-id shape check.
|
|
35
36
|
const SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
36
|
-
// BOT-
|
|
37
|
-
|
|
37
|
+
// BOT-1582: the server's session-token shape (bb_agent_ + 64 hex), plus the
|
|
38
|
+
// BOT-1572 bb_sess_ legacy alias, both accepted for one release.
|
|
39
|
+
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
38
40
|
|
|
39
41
|
const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
|
|
40
42
|
|
|
@@ -90,6 +92,11 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
90
92
|
Host-shared; a silently-dead host is detected server-side
|
|
91
93
|
(capacity_source_stale) via a host beacon — the client
|
|
92
94
|
grace (default 900s) is a backstop, no longer the only guard.
|
|
95
|
+
Grants are RANKED (BOT-1577): when several waiters
|
|
96
|
+
contend, a freed slot is handed to the highest-scored
|
|
97
|
+
one and its receipt carries grant:{position,tier,score};
|
|
98
|
+
the others keep parking. Older servers broadcast without
|
|
99
|
+
the ranking (first-come). Needs cli >= 1.20.0.
|
|
93
100
|
ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
|
|
94
101
|
| scope=next[,branch=<name>][,workflow=<name>]}
|
|
95
102
|
a PR's CI reaching a terminal conclusion (owner-scoped);
|
|
@@ -159,10 +166,11 @@ function parseArgv(argv) {
|
|
|
159
166
|
// BOT-1467: attribute this wait to the arming session's agent (the id from
|
|
160
167
|
// register_agent) instead of the tenant-bound profile agent. Env fallback.
|
|
161
168
|
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.
|
|
164
|
-
//
|
|
165
|
-
|
|
169
|
+
// BOT-1572/1582: the per-session token. When present it is the ONLY credential —
|
|
170
|
+
// profile, --session-id, and --token are unnecessary. $BOTBUDDY_AGENT_KEY is
|
|
171
|
+
// the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release); the flag
|
|
172
|
+
// is for tests/overrides.
|
|
173
|
+
sessionToken: readAgentKeyEnv(process.env),
|
|
166
174
|
help: false,
|
|
167
175
|
};
|
|
168
176
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -654,14 +662,15 @@ function profileErrorReceipt(profile, error) {
|
|
|
654
662
|
// renders. Keep each to one line and never embed a secret value — names, slots,
|
|
655
663
|
// and env-var names only.
|
|
656
664
|
const RECOVERY = Object.freeze({
|
|
657
|
-
// $
|
|
658
|
-
// harness exports it
|
|
659
|
-
|
|
665
|
+
// BOT-1582: $BOTBUDDY_AGENT_KEY (bb_agent_+64hex, renamed from
|
|
666
|
+
// $BOTBUDDY_SESSION_TOKEN) is minted by register_agent; the harness exports it
|
|
667
|
+
// at session start. The legacy var is still accepted for one release.
|
|
668
|
+
sessionToken: "register_agent → export BOTBUDDY_AGENT_KEY=<session_token> (the harness exports it at session start)",
|
|
660
669
|
// $BOTBUDDY_SESSION_ID is the work-graph session id returned by register_agent.
|
|
661
670
|
sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
|
|
662
671
|
// A --token / session-token contradiction: the session token is the whole
|
|
663
672
|
// credential, so drop --token (never advise also setting a session id here).
|
|
664
|
-
sessionTokenConflict: "unset --token — $
|
|
673
|
+
sessionTokenConflict: "unset --token — $BOTBUDDY_AGENT_KEY is the whole credential (protocol 3)",
|
|
665
674
|
// Grammar/condition errors point at the help and the canonical doc.
|
|
666
675
|
conditions: "check the condition grammar: botbuddy wait --help / docs/agent-wait.md",
|
|
667
676
|
});
|
|
@@ -684,7 +693,10 @@ function profileResolutionRecovery(errorCode) {
|
|
|
684
693
|
// supported path is exporting that same profile env var directly, which
|
|
685
694
|
// resolveAgentProfile reads. (The 0600 config.json store is the owner login
|
|
686
695
|
// token's, not a profile credential's — a wait cannot consume it.)
|
|
687
|
-
|
|
696
|
+
// BOT-1582: $BOTBUDDY_AGENT_KEY is now the per-session token var (valid for
|
|
697
|
+
// waits), so it is no longer named here; BOTBUDDY_AGENT_API_KEY remains a human
|
|
698
|
+
// PAT that never authenticates a wait.
|
|
699
|
+
const HUMAN_PAT_NOTE = "human PATs (e.g. BOTBUDDY_AGENT_API_KEY) are not valid for waits";
|
|
688
700
|
function profileCredentialRecovery(profile) {
|
|
689
701
|
return `${profileRecovery(profile)} (stores $${profile.tokenEnv} in the macOS Keychain; on a non-Keychain host export $${profile.tokenEnv} directly); ${HUMAN_PAT_NOTE}`;
|
|
690
702
|
}
|
|
@@ -732,19 +744,19 @@ export async function runWait(argv) {
|
|
|
732
744
|
const deadlineMs = Date.now() + timeoutSec * 1000;
|
|
733
745
|
|
|
734
746
|
const needsRelay = conditions.some((c) => c.type !== "timer");
|
|
735
|
-
// BOT-1572: a $
|
|
747
|
+
// BOT-1572/1582: a $BOTBUDDY_AGENT_KEY authenticates the wait on its own. When
|
|
736
748
|
// present it supersedes the profile + --session-id path entirely: the relay
|
|
737
749
|
// derives agent, tenant, and session from the token. --profile / --session-id
|
|
738
750
|
// are simply ignored; a conflicting explicit --token (a DIFFERENT credential)
|
|
739
751
|
// is a contradiction and is rejected.
|
|
740
752
|
if (needsRelay && opts.sessionToken) {
|
|
741
753
|
if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
|
|
742
|
-
process.stderr.write(`botbuddy wait: $
|
|
754
|
+
process.stderr.write(`botbuddy wait: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; ${RECOVERY.sessionToken}\n`);
|
|
743
755
|
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
|
|
744
756
|
process.exit(EXIT.INVALID);
|
|
745
757
|
}
|
|
746
758
|
if (opts.token && opts.token !== opts.sessionToken) {
|
|
747
|
-
process.stderr.write(`botbuddy wait: --token conflicts with $
|
|
759
|
+
process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_KEY; ${RECOVERY.sessionTokenConflict}\n`);
|
|
748
760
|
emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
|
|
749
761
|
process.exit(EXIT.INVALID);
|
|
750
762
|
}
|
|
@@ -904,13 +916,13 @@ export async function runWait(argv) {
|
|
|
904
916
|
]);
|
|
905
917
|
if (opts.useSessionToken || SESSION_TOKEN_ERRORS.has(err.errorCode)) {
|
|
906
918
|
process.stderr.write(
|
|
907
|
-
`botbuddy wait: session token rejected (${err.errorCode || "unauthorized"}) — re-register the agent and export the new $
|
|
919
|
+
`botbuddy wait: session token rejected (${err.errorCode || "unauthorized"}) — re-register the agent and export the new $BOTBUDDY_AGENT_KEY\n`,
|
|
908
920
|
);
|
|
909
921
|
emitReceipt(withPrincipalReceipt({
|
|
910
922
|
schema_version: 1,
|
|
911
923
|
outcome: "error",
|
|
912
924
|
error: err.errorCode || "unauthorized",
|
|
913
|
-
recovery: "register_agent → export
|
|
925
|
+
recovery: "register_agent → export BOTBUDDY_AGENT_KEY=<session_token>",
|
|
914
926
|
}, opts.agentProfile, { sessionTenant, agentId: registeredAgentId, sessionId: registeredSessionId }));
|
|
915
927
|
process.exit(EXIT.AUTH);
|
|
916
928
|
}
|