@botbuddy/cli 1.26.0 → 1.28.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 +2 -1
- package/src/commands.mjs +54 -9
- package/src/pw/run.mjs +2 -2
- package/src/run.mjs +165 -10
- package/src/setup-block.mjs +74 -0
- package/src/telemetry-config.mjs +109 -0
- package/src/telemetry-delivery.mjs +40 -0
- package/src/telemetry-outbox.mjs +327 -0
- package/src/wait.mjs +7 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
"./playwright-reporter": "./src/test/playwright-reporter.mjs",
|
|
14
|
+
"./telemetry": "./src/telemetry-outbox.mjs",
|
|
14
15
|
"./package.json": "./package.json"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
package/src/commands.mjs
CHANGED
|
@@ -12,19 +12,23 @@ import { cmdDocker } from "./docker-hygiene.mjs";
|
|
|
12
12
|
import { cmdRun } from "./run.mjs";
|
|
13
13
|
import { cmdTest } from "./test-lane.mjs";
|
|
14
14
|
import { runWait } from "./wait.mjs";
|
|
15
|
+
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
15
16
|
import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
16
17
|
import { VERSION } from "./version.mjs";
|
|
17
18
|
import { setupMcpKey, revokeMcpKey, resolveMcpConfigKey, resolveEnvVarName, DEFAULT_MCP_ENV_VAR, McpKeyError, McpKeyStoreError } from "./mcp-key.mjs";
|
|
18
19
|
import { readAgentBinding } from "./wait-profile.mjs";
|
|
19
20
|
import { runPw } from "./pw/run.mjs";
|
|
20
21
|
import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
|
|
22
|
+
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
23
|
+
import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
|
|
24
|
+
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
21
25
|
|
|
22
26
|
// BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
|
|
23
27
|
// invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
|
|
24
28
|
// must not pay for a registry round-trip.
|
|
25
29
|
export function shouldCheckForUpdates(argv) {
|
|
26
30
|
const [command] = argv;
|
|
27
|
-
if (command === "wait") return false;
|
|
31
|
+
if (command === "wait" || command === "telemetry") return false;
|
|
28
32
|
if (argv.includes("--json")) return false;
|
|
29
33
|
return true;
|
|
30
34
|
}
|
|
@@ -53,6 +57,7 @@ export async function run(argv, {
|
|
|
53
57
|
case "stack": return cmdStack(args);
|
|
54
58
|
case "docker": return cmdDocker(args);
|
|
55
59
|
case "run": return cmdRun(args);
|
|
60
|
+
case "telemetry": return cmdTelemetry(args);
|
|
56
61
|
case "test": return cmdTest(args);
|
|
57
62
|
case "wait": return runWait(args);
|
|
58
63
|
case "pw": return runPw(args);
|
|
@@ -80,8 +85,38 @@ export async function run(argv, {
|
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
function
|
|
84
|
-
|
|
88
|
+
async function cmdTelemetry(args) {
|
|
89
|
+
const subcommand = args[0] ?? "help";
|
|
90
|
+
if (!new Set(["status", "replay", "doctor", "help", "--help"]).has(subcommand) || args.length > 1) {
|
|
91
|
+
die("Usage: botbuddy telemetry <status|replay|doctor>");
|
|
92
|
+
}
|
|
93
|
+
if (subcommand === "help" || subcommand === "--help") {
|
|
94
|
+
console.log("botbuddy telemetry status\nbotbuddy telemetry replay\nbotbuddy telemetry doctor");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let location;
|
|
98
|
+
try { location = await loadTelemetryLocation(process.cwd()); }
|
|
99
|
+
catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
|
|
100
|
+
const outbox = await createTelemetryOutbox({ tenant: location.tenant, repository: location.repository, producerVersion: VERSION });
|
|
101
|
+
if (subcommand === "replay") {
|
|
102
|
+
let identity;
|
|
103
|
+
try {
|
|
104
|
+
identity = await loadTelemetryIdentity(process.cwd());
|
|
105
|
+
await attestTelemetryCredential(identity, outbox);
|
|
106
|
+
} catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
|
|
107
|
+
await outbox.importLegacyTestRunReceipts(".botbuddy/test-runs");
|
|
108
|
+
const receipt = await outbox.replay((event) => deliverExecutionEvent(event, { credential: identity.credential }));
|
|
109
|
+
console.log(JSON.stringify(receipt));
|
|
110
|
+
return receipt.remaining === 0 ? 0 : 5;
|
|
111
|
+
}
|
|
112
|
+
const status = await outbox.status();
|
|
113
|
+
console.log(JSON.stringify(status));
|
|
114
|
+
if (subcommand === "doctor") return status.queued_count === 0 && !status.last_error_class ? 0 : 5;
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function cmdHelp(log = console.log) {
|
|
119
|
+
log(`${bold("bb")} ${dim(`v${VERSION}`)} — Swarm coordination CLI
|
|
85
120
|
|
|
86
121
|
${dim(`Run everything as ${cyan("bb <command>")} — the short, preferred alias. ${cyan("botbuddy <command>")} is the identical long form.`)}
|
|
87
122
|
|
|
@@ -132,6 +167,10 @@ ${bold("DOCKER HYGIENE")}
|
|
|
132
167
|
${bold("DURABLE WORKLOADS")}
|
|
133
168
|
run --session-id <id> --environment <env> -- <command>
|
|
134
169
|
Launch a receipt-bearing command under a detached owner
|
|
170
|
+
run --foreground --kind <kind> --environment <env> -- <command>
|
|
171
|
+
Run locally with a durable lifecycle outbox
|
|
172
|
+
telemetry <status|replay|doctor>
|
|
173
|
+
Inspect, replay, or gate durable lifecycle delivery
|
|
135
174
|
|
|
136
175
|
${bold("AGENT WAITS")}
|
|
137
176
|
wait [--any] <condition>... [options]
|
|
@@ -146,7 +185,9 @@ ${bold("OTHER")}
|
|
|
146
185
|
locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
|
|
147
186
|
update Install the latest @botbuddy/cli in the background via npm
|
|
148
187
|
help Show this help
|
|
149
|
-
version Show version
|
|
188
|
+
version Show version
|
|
189
|
+
|
|
190
|
+
${SETUP_BLOCK}`);
|
|
150
191
|
}
|
|
151
192
|
|
|
152
193
|
// ─── BOT-876: generic tool access ───────────────────────────────
|
|
@@ -287,8 +328,8 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
|
|
|
287
328
|
}
|
|
288
329
|
}
|
|
289
330
|
|
|
290
|
-
function loginHelp() {
|
|
291
|
-
|
|
331
|
+
export function loginHelp(log = console.log) {
|
|
332
|
+
log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
|
|
292
333
|
|
|
293
334
|
${bold("USAGE")}
|
|
294
335
|
botbuddy login [--no-browser] [--tenant <slug>]
|
|
@@ -352,7 +393,9 @@ ${bold("NOTES")}
|
|
|
352
393
|
${bold("RECOVERY")}
|
|
353
394
|
If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
|
|
354
395
|
unattended CI host (no browser), mint a key on a machine with a browser
|
|
355
|
-
(${cyan("botbuddy mcp setup")}) and export it as ${cyan("$BOTBUDDY_MCP_KEY")} on the CI host
|
|
396
|
+
(${cyan("botbuddy mcp setup")}) and export it as ${cyan("$BOTBUDDY_MCP_KEY")} on the CI host.
|
|
397
|
+
|
|
398
|
+
${SETUP_BLOCK}`);
|
|
356
399
|
}
|
|
357
400
|
|
|
358
401
|
async function cmdStart(args) {
|
|
@@ -561,7 +604,7 @@ function cmdHeartbeat(args) {
|
|
|
561
604
|
|
|
562
605
|
// ─── BOT-1607: `botbuddy mcp` — the tier-2 bb_mcp_ config key ────────────────
|
|
563
606
|
|
|
564
|
-
function mcpHelp(log) {
|
|
607
|
+
export function mcpHelp(log = console.log) {
|
|
565
608
|
log(`Usage: botbuddy mcp <setup|revoke|status|env> [options]
|
|
566
609
|
|
|
567
610
|
setup [--env <NAME>] [--tenant <slug>] [--label <label>] [--expiry-days <n>] [--force]
|
|
@@ -587,7 +630,9 @@ function mcpHelp(log) {
|
|
|
587
630
|
or in the desktop LaunchAgent env-install recipe.
|
|
588
631
|
|
|
589
632
|
Present the key from .mcp.json instead of reusing an agent session token — it is
|
|
590
|
-
independently revocable, so a leaked config credential rotates without a re-login
|
|
633
|
+
independently revocable, so a leaked config credential rotates without a re-login.
|
|
634
|
+
|
|
635
|
+
${SETUP_BLOCK}`);
|
|
591
636
|
}
|
|
592
637
|
|
|
593
638
|
// Actionable recovery for an mcp setup/revoke failure code.
|
package/src/pw/run.mjs
CHANGED
|
@@ -35,7 +35,7 @@ async function gate({ env, host, lane, deps }) {
|
|
|
35
35
|
// server's owner_is_caller.
|
|
36
36
|
const sessionToken = deps.sessionToken ?? readAgentKeyEnv(env);
|
|
37
37
|
if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
|
|
38
|
-
return { allowed: false, message: "bb-pw: $BOTBUDDY_AGENT_KEY is malformed (expected bb_agent_<64 hex>). Re-register
|
|
38
|
+
return { allowed: false, message: "bb-pw: $BOTBUDDY_AGENT_KEY is malformed (tier-3 session token; expected bb_agent_<64 hex>). Re-register: register_agent → export BOTBUDDY_AGENT_KEY. See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
|
|
39
39
|
}
|
|
40
40
|
const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
|
|
41
41
|
const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
|
|
@@ -49,7 +49,7 @@ async function gate({ env, host, lane, deps }) {
|
|
|
49
49
|
} catch { token = null; }
|
|
50
50
|
}
|
|
51
51
|
if (!token) {
|
|
52
|
-
return { allowed: false, message: "bb-pw: no BotBuddy credential —
|
|
52
|
+
return { allowed: false, message: "bb-pw: no BotBuddy credential — a lane needs the tier-3 session token (bb_agent_: register_agent → export $BOTBUDDY_AGENT_KEY) or your tier-2 .mcp.json key ($BOTBUDDY_MCP_KEY, bb mcp setup). See docs/agent-connectivity.md. (BB_PW_NO_LOCK=1 runs local-only, unlocked.)" };
|
|
53
53
|
}
|
|
54
54
|
coordinator = createSessionTokenCoordinator({ token, fetchImpl: deps.fetch });
|
|
55
55
|
}
|
package/src/run.mjs
CHANGED
|
@@ -6,15 +6,19 @@
|
|
|
6
6
|
// allowed to call update_command_run. A bb-wait is therefore a notification
|
|
7
7
|
// mechanism, never the owner of a child process.
|
|
8
8
|
|
|
9
|
-
import { createHash, randomUUID } from "crypto";
|
|
10
|
-
import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "fs/promises";
|
|
11
|
-
import { dirname, join } from "path";
|
|
12
|
-
import { homedir } from "os";
|
|
13
|
-
import { spawn } from "child_process";
|
|
14
|
-
import { fileURLToPath } from "url";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { callToolJson } from "./api.mjs";
|
|
16
16
|
import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
|
|
17
17
|
import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
|
|
18
|
+
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
19
|
+
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
20
|
+
import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
|
|
21
|
+
import { VERSION } from "./version.mjs";
|
|
18
22
|
|
|
19
23
|
export const RUN_SCHEMA_VERSION = 1;
|
|
20
24
|
export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
|
|
@@ -24,6 +28,7 @@ const MAX_CAPTURE_BYTES = 8_192;
|
|
|
24
28
|
// BOT-1510: how often the worker flushes lane case verdicts to the backend.
|
|
25
29
|
// Well under AC-3's 5 s so a begin/verdict is visible within the window.
|
|
26
30
|
const LANE_FLUSH_INTERVAL_MS = 2_500;
|
|
31
|
+
const FOREGROUND_KINDS = new Set(["test", "wait", "browser", "ci", "hook", "stack"]);
|
|
27
32
|
|
|
28
33
|
// BOT-1582: the server's session-token shape (bb_agent_ + 64 hex) plus the
|
|
29
34
|
// BOT-1572 bb_sess_ legacy alias, both accepted for one release.
|
|
@@ -34,7 +39,7 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
34
39
|
// session, so --session-id becomes optional (the relay derives it). The old
|
|
35
40
|
// $BOTBUDDY_SESSION_TOKEN is still accepted for one release. $BOTBUDDY_SESSION_ID
|
|
36
41
|
// is the default when a plain id is used (parity with `botbuddy test`).
|
|
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 };
|
|
42
|
+
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, foreground: false, json: false };
|
|
38
43
|
const errors = [];
|
|
39
44
|
const separator = argv.indexOf("--");
|
|
40
45
|
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
@@ -62,6 +67,7 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
62
67
|
} catch (error) { errors.push(`--rerun-reason must be a JSON object (${error instanceof Error ? error.message : String(error)})`); }
|
|
63
68
|
break;
|
|
64
69
|
}
|
|
70
|
+
case "--foreground": opts.foreground = true; break;
|
|
65
71
|
case "--json": opts.json = true; break;
|
|
66
72
|
default: errors.push(`unknown option: ${flag}`);
|
|
67
73
|
}
|
|
@@ -70,7 +76,8 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
70
76
|
// session from the token). A malformed token, or a plain id AND a differing
|
|
71
77
|
// token, is a hard error.
|
|
72
78
|
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");
|
|
79
|
+
if (!opts.foreground && !opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
|
|
80
|
+
if (opts.foreground && !FOREGROUND_KINDS.has(opts.kind)) errors.push("--kind must be test, wait, browser, ci, hook, or stack with --foreground");
|
|
74
81
|
if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
|
|
75
82
|
if (!command.length) errors.push("a workload is required after --");
|
|
76
83
|
if (!Number.isInteger(opts.expectedDuration) || opts.expectedDuration < 0) errors.push("--expected-duration must be a non-negative integer");
|
|
@@ -78,6 +85,118 @@ export function parseRunArgs(argv, env = process.env) {
|
|
|
78
85
|
return { opts, command, errors };
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
function foregroundOutcome({ exitCode, signal, timedOut }) {
|
|
89
|
+
if (timedOut) return "timed_out";
|
|
90
|
+
if (signal) return "canceled";
|
|
91
|
+
return exitCode === 0 ? "passed" : "failed";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function signalExitCode(signal) {
|
|
95
|
+
const codes = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129, SIGKILL: 137 };
|
|
96
|
+
return codes[signal] ?? EXIT.INTERNAL;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Foreground deliberately shares no ownership code with BOT-1350's detached
|
|
100
|
+
// runner. It has exactly one durable pre-spawn boundary (the outbox start
|
|
101
|
+
// event), then returns the child verdict even when terminal delivery degrades.
|
|
102
|
+
export async function launchForegroundRun(argv, {
|
|
103
|
+
cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process,
|
|
104
|
+
} = {}) {
|
|
105
|
+
const { opts, command, errors } = parseRunArgs(argv);
|
|
106
|
+
if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
|
|
107
|
+
if (!opts.foreground) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: "foreground_flag_required", exit_code: EXIT.INVALID } };
|
|
108
|
+
if (!identity?.repository || !identity?.tenant || !identity?.appSlug) {
|
|
109
|
+
return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: "telemetry_identity_required", exit_code: EXIT.INVALID } };
|
|
110
|
+
}
|
|
111
|
+
const telemetry = outbox ?? await createTelemetryOutbox({
|
|
112
|
+
tenant: identity.tenant,
|
|
113
|
+
repository: identity.repository,
|
|
114
|
+
producerVersion: identity.producerVersion ?? VERSION,
|
|
115
|
+
});
|
|
116
|
+
// An authenticated invocation is also the bounded recovery opportunity for
|
|
117
|
+
// prior offline records. Failure remains durable and never alters the child
|
|
118
|
+
// verdict below.
|
|
119
|
+
if (!outbox) {
|
|
120
|
+
// A cached proof keeps offline hooks fast; a new or rotated credential must
|
|
121
|
+
// be authenticated once before it may scope a BotBuddy lifecycle event.
|
|
122
|
+
try {
|
|
123
|
+
await attestTelemetryCredential(identity, telemetry);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.message ?? "telemetry_credential_unattested", exit_code: EXIT.INVALID } };
|
|
126
|
+
}
|
|
127
|
+
await telemetry.importLegacyTestRunReceipts(join(cwd, ".botbuddy", "test-runs"), { maxReceipts: 8, deadline: Date.now() + 2_250 }).catch(() => {});
|
|
128
|
+
await telemetry.replay(
|
|
129
|
+
(event) => deliverExecutionEvent(event, { credential: identity.credential }),
|
|
130
|
+
// Recovery is opportunistic: a large offline backlog must never turn the
|
|
131
|
+
// next hook invocation into an unbounded pre-spawn upload job.
|
|
132
|
+
{ maxEvents: 8, deadline: Date.now() + 2_250 },
|
|
133
|
+
).catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
const runId = randomUUID();
|
|
136
|
+
const context = {
|
|
137
|
+
repo: identity.repository,
|
|
138
|
+
environment: opts.environment,
|
|
139
|
+
runner: identity.appSlug,
|
|
140
|
+
phase: opts.kind === "wait" ? "waiting" : "testing",
|
|
141
|
+
health: "uninstrumented",
|
|
142
|
+
};
|
|
143
|
+
const base = {
|
|
144
|
+
schema_version: 1,
|
|
145
|
+
producer_kind: opts.kind,
|
|
146
|
+
source_run_id: runId,
|
|
147
|
+
attempt: 1,
|
|
148
|
+
occurred_at: new Date().toISOString(),
|
|
149
|
+
context,
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
await telemetry.append({ ...base, action: "start", producer_event_id: `${runId}:start`, producer_sequence: 0 });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.code ?? "telemetry_start_not_durable", exit_code: EXIT.INTERNAL } };
|
|
155
|
+
}
|
|
156
|
+
let child;
|
|
157
|
+
try {
|
|
158
|
+
// A foreground command still needs its own process group: hooks commonly
|
|
159
|
+
// launch package managers or test runners, whose descendants otherwise
|
|
160
|
+
// survive a timeout after their immediate parent exits.
|
|
161
|
+
child = spawnImpl(command[0], command.slice(1), { cwd, detached: process.platform !== "win32", stdio: "inherit", windowsHide: true });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
await telemetry.append({ ...base, action: "terminal", producer_event_id: `${runId}:terminal`, producer_sequence: 1, outcome: "crashed", diagnostic: { code: "spawn_failed", category: "spawn" } }).catch(() => {});
|
|
164
|
+
return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "crashed", run_id: runId, telemetry: "degraded", exit_code: EXIT.INTERNAL } };
|
|
165
|
+
}
|
|
166
|
+
let timedOut = false;
|
|
167
|
+
let killTimer;
|
|
168
|
+
const timeout = setTimeout(() => {
|
|
169
|
+
timedOut = true;
|
|
170
|
+
terminateProcessGroup(child, "SIGTERM");
|
|
171
|
+
killTimer = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), TIMEOUT_GRACE_MS);
|
|
172
|
+
}, opts.timeout * 1000);
|
|
173
|
+
const forwarded = forwardTerminationSignals(child, signalEmitter);
|
|
174
|
+
const result = await waitForChild(child);
|
|
175
|
+
forwarded.cleanup();
|
|
176
|
+
clearTimeout(timeout);
|
|
177
|
+
clearTimeout(killTimer);
|
|
178
|
+
const terminalSignal = forwarded.signal ?? result.signal;
|
|
179
|
+
const outcome = result.error ? "crashed" : foregroundOutcome({ ...result, signal: terminalSignal, timedOut });
|
|
180
|
+
let telemetryState = "durable";
|
|
181
|
+
try {
|
|
182
|
+
await telemetry.append({
|
|
183
|
+
...base,
|
|
184
|
+
action: "terminal",
|
|
185
|
+
producer_event_id: `${runId}:terminal`,
|
|
186
|
+
producer_sequence: 1,
|
|
187
|
+
// BOT-1317 derives duration from the two lifecycle timestamps. `base`
|
|
188
|
+
// intentionally represents pre-spawn time, so terminal needs a fresh
|
|
189
|
+
// timestamp after the child has actually completed.
|
|
190
|
+
occurred_at: new Date().toISOString(),
|
|
191
|
+
context: { ...context, phase: outcome === "passed" ? "complete" : "failed" },
|
|
192
|
+
outcome,
|
|
193
|
+
...(terminalSignal ? { diagnostic: { code: "child_signaled", category: "signal", signal: terminalSignal } } : {}),
|
|
194
|
+
});
|
|
195
|
+
} catch { telemetryState = "degraded"; }
|
|
196
|
+
const exitCode = result.error ? EXIT.INTERNAL : timedOut ? signalExitCode("SIGTERM") : terminalSignal ? signalExitCode(terminalSignal) : result.exitCode ?? EXIT.INTERNAL;
|
|
197
|
+
return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome, run_id: runId, telemetry: telemetryState, exit_code: exitCode } };
|
|
198
|
+
}
|
|
199
|
+
|
|
81
200
|
export function terminalStatus({ exitCode, signal, timedOut = false, ownerLost = false }) {
|
|
82
201
|
if (ownerLost) return "owner_lost";
|
|
83
202
|
if (timedOut) return "timed_out";
|
|
@@ -280,7 +399,30 @@ function boundedCapture() {
|
|
|
280
399
|
}
|
|
281
400
|
|
|
282
401
|
function terminateProcessGroup(child, signal) {
|
|
283
|
-
|
|
402
|
+
if (process.platform !== "win32" && Number.isInteger(child?.pid) && child.pid > 0) {
|
|
403
|
+
try { process.kill(-child.pid, signal); return; } catch { /* fall back to the immediate child */ }
|
|
404
|
+
}
|
|
405
|
+
try { child.kill?.(signal); } catch { /* already exited */ }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function forwardTerminationSignals(child, signalEmitter) {
|
|
409
|
+
let signal = null;
|
|
410
|
+
let escalation = null;
|
|
411
|
+
const handlers = new Map();
|
|
412
|
+
for (const candidate of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
413
|
+
const handler = () => {
|
|
414
|
+
if (signal) { terminateProcessGroup(child, "SIGKILL"); return; }
|
|
415
|
+
signal = candidate;
|
|
416
|
+
terminateProcessGroup(child, candidate);
|
|
417
|
+
escalation = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), TIMEOUT_GRACE_MS);
|
|
418
|
+
};
|
|
419
|
+
handlers.set(candidate, handler);
|
|
420
|
+
signalEmitter.on(candidate, handler);
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
get signal() { return signal; },
|
|
424
|
+
cleanup() { clearTimeout(escalation); for (const [candidate, handler] of handlers) signalEmitter.removeListener(candidate, handler); },
|
|
425
|
+
};
|
|
284
426
|
}
|
|
285
427
|
|
|
286
428
|
function waitForChild(child) {
|
|
@@ -343,7 +485,20 @@ export async function cmdRun(args) {
|
|
|
343
485
|
return;
|
|
344
486
|
}
|
|
345
487
|
if (args[0] === "help" || args[0] === "--help") {
|
|
346
|
-
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run cancel <run_id>");
|
|
488
|
+
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (args.includes("--foreground")) {
|
|
492
|
+
let identity;
|
|
493
|
+
try { identity = await loadTelemetryIdentity(); }
|
|
494
|
+
catch (error) {
|
|
495
|
+
oneLine({ schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.message ?? "telemetry_identity_invalid", exit_code: EXIT.INVALID }, false);
|
|
496
|
+
process.exitCode = EXIT.INVALID;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const result = await launchForegroundRun(args, { identity: { ...identity, producerVersion: VERSION } });
|
|
500
|
+
oneLine(result.receipt, Boolean(parseRunArgs(args).opts.json));
|
|
501
|
+
process.exitCode = result.exitCode;
|
|
347
502
|
return;
|
|
348
503
|
}
|
|
349
504
|
const result = await launchRun(args);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// BOT-1644 — ONE source of truth for the connectivity ladder and the credential
|
|
2
|
+
// tier names, so every CLI help surface (`bb --help`, `bb wait --help`,
|
|
3
|
+
// `bb login --help`, `bb mcp --help`), the recovery strings, and the canonical
|
|
4
|
+
// guide all agree. The snapshot test (agent-connectivity.test.mjs) asserts the
|
|
5
|
+
// SETUP block appears verbatim in all four help outputs, so drift is caught.
|
|
6
|
+
//
|
|
7
|
+
// This documents the CURRENT supported process (BOT-1644 scope). It does NOT
|
|
8
|
+
// describe the P1–P6 "self-healing auth" redesign, which is a separate future
|
|
9
|
+
// project.
|
|
10
|
+
|
|
11
|
+
/** The canonical guide, referenced from every SETUP block and recovery. */
|
|
12
|
+
export const CONNECTIVITY_GUIDE = "docs/agent-connectivity.md";
|
|
13
|
+
|
|
14
|
+
// The three connectivity credential tiers, named consistently everywhere. A
|
|
15
|
+
// connectivity error names the MISSING tier and the exact next command; the
|
|
16
|
+
// guide's tiered table and the help SETUP block use these same labels.
|
|
17
|
+
export const TIERS = Object.freeze({
|
|
18
|
+
// Tier 1 — per-machine client key. `bb login`, USER-scoped (reaches every
|
|
19
|
+
// tenant you belong to; each request pins one). Never a wait credential.
|
|
20
|
+
client: Object.freeze({
|
|
21
|
+
n: 1,
|
|
22
|
+
label: "client key",
|
|
23
|
+
prefix: "bb_cli_",
|
|
24
|
+
env: "BOTBUDDY_CLIENT_KEY",
|
|
25
|
+
setup: "bb login",
|
|
26
|
+
scope: "user",
|
|
27
|
+
}),
|
|
28
|
+
// Tier 2 — per-tenant MCP config key. `bb mcp setup`, TENANT-bound, lives in
|
|
29
|
+
// .mcp.json under $BOTBUDDY_MCP_KEY. Authenticates MCP calls + register_agent.
|
|
30
|
+
mcp: Object.freeze({
|
|
31
|
+
n: 2,
|
|
32
|
+
label: "MCP key",
|
|
33
|
+
prefix: "bb_mcp_",
|
|
34
|
+
env: "BOTBUDDY_MCP_KEY",
|
|
35
|
+
setup: "bb mcp setup",
|
|
36
|
+
scope: "tenant",
|
|
37
|
+
}),
|
|
38
|
+
// Tier 3 — per-session agent token. `register_agent` (over MCP), tenant-bound,
|
|
39
|
+
// 8 h TTL, revoked on re-register. The ONLY credential a wait/run/test/pw
|
|
40
|
+
// accepts. Exported as $BOTBUDDY_AGENT_KEY (+ $BOTBUDDY_SESSION_ID for
|
|
41
|
+
// attribution).
|
|
42
|
+
session: Object.freeze({
|
|
43
|
+
n: 3,
|
|
44
|
+
label: "session token",
|
|
45
|
+
prefix: "bb_agent_",
|
|
46
|
+
env: "BOTBUDDY_AGENT_KEY",
|
|
47
|
+
setup: "register_agent",
|
|
48
|
+
scope: "session",
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The register_agent → export handoff, named once. Recovery strings reuse it so
|
|
53
|
+
// the exact two export lines never drift from the guide.
|
|
54
|
+
export const EXPORT_STEP =
|
|
55
|
+
"register_agent → export BOTBUDDY_AGENT_KEY=<session_token> (and BOTBUDDY_SESSION_ID=<session_id>)";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The shared SETUP block reproduced verbatim in every `--help` output. Plain
|
|
59
|
+
* text (no ANSI) so the snapshot test can match it byte-for-byte across surfaces.
|
|
60
|
+
* The E2E ladder in order, then the fresh-shell caveat and the guide link.
|
|
61
|
+
*/
|
|
62
|
+
export const SETUP_BLOCK = `SETUP — the connectivity ladder (install → connect → wait)
|
|
63
|
+
1. npm i -g @botbuddy/cli install the CLI
|
|
64
|
+
2. bb login tier 1 · client key (bb_cli_, user-scoped) → Keychain
|
|
65
|
+
3. bb mcp setup tier 2 · MCP key (bb_mcp_, tenant-bound) → Keychain + .mcp.json ($BOTBUDDY_MCP_KEY)
|
|
66
|
+
(Keychainless host: the key is printed ONCE — store + export + edit .mcp.json yourself)
|
|
67
|
+
the repo's pre-committed .botbuddy-agent.json binds tenant + mcp_env
|
|
68
|
+
4. register_agent (over MCP) tier 3 · session token (bb_agent_, per-session, 8 h)
|
|
69
|
+
5. export BOTBUDDY_AGENT_KEY=<session_token> and BOTBUDDY_SESSION_ID=<session_id>
|
|
70
|
+
6. bb wait needs the tier-3 token; bb run/test also accept $BOTBUDDY_SESSION_ID,
|
|
71
|
+
bb pw falls back to the tier-2 MCP key
|
|
72
|
+
NOTE: each Claude Code Bash call is a FRESH shell — exports do NOT persist between tool calls;
|
|
73
|
+
re-export (or pass --agent-key <token>) in the same command that runs the wait.
|
|
74
|
+
Full guide: ${CONNECTIVITY_GUIDE}`;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// BOT-1316 — checked-in repo identity, separate from local credentials.
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { resolveAgentBinding } from "./wait-profile.mjs";
|
|
8
|
+
import { callToolJson } from "./api.mjs";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const REQUIRED_KEYS = ["schema_version", "repository", "app_slug", "profile", "expected_tenant"];
|
|
12
|
+
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
13
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
14
|
+
|
|
15
|
+
export function parseTelemetryConfig(raw) {
|
|
16
|
+
let value;
|
|
17
|
+
try { value = JSON.parse(raw); } catch { throw new Error(".botbuddy/config.json is not valid JSON"); }
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(".botbuddy/config.json must be an object");
|
|
19
|
+
const keys = Object.keys(value).sort();
|
|
20
|
+
if (keys.length !== REQUIRED_KEYS.length || keys.some((key, index) => key !== [...REQUIRED_KEYS].sort()[index])) {
|
|
21
|
+
throw new Error(".botbuddy/config.json must contain exactly schema_version, repository, app_slug, profile, expected_tenant");
|
|
22
|
+
}
|
|
23
|
+
if (value.schema_version !== 1 || !REPOSITORY.test(value.repository) || !SLUG.test(value.app_slug) || !SLUG.test(value.profile) || !SLUG.test(value.expected_tenant)) {
|
|
24
|
+
throw new Error(".botbuddy/config.json has an invalid schema_version or identity value");
|
|
25
|
+
}
|
|
26
|
+
return { schemaVersion: 1, repository: value.repository, appSlug: value.app_slug, profile: value.profile, expectedTenant: value.expected_tenant };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function repositoryFromRemote(cwd = process.cwd()) {
|
|
30
|
+
const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], { cwd });
|
|
31
|
+
const raw = stdout.trim().replace(/\.git$/, "");
|
|
32
|
+
const match = raw.match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i);
|
|
33
|
+
if (!match) throw new Error("origin remote is not a canonical GitHub repository");
|
|
34
|
+
return match[1];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function verifyTelemetryIdentity(config, {
|
|
38
|
+
cwd = process.cwd(), repositoryFromRemote: remote = repositoryFromRemote, resolveBinding = resolveAgentBinding, call = callToolJson, verifyCredential = false,
|
|
39
|
+
} = {}) {
|
|
40
|
+
const repository = await remote(cwd);
|
|
41
|
+
if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
|
|
42
|
+
const binding = await resolveBinding({ cwd });
|
|
43
|
+
if (!binding?.token) throw new Error("telemetry requires a locally stored tenant-bound credential");
|
|
44
|
+
if (binding.tenant !== config.expectedTenant) throw new Error("telemetry tenant mismatch with checked-in config");
|
|
45
|
+
// A binding file names the intended tenant; it is not proof that a rotated
|
|
46
|
+
// environment credential still belongs there. Ask the server's read-only
|
|
47
|
+
// identity endpoint before allowing a foreground child to run.
|
|
48
|
+
if (verifyCredential) {
|
|
49
|
+
const verified = await call("whoami", {}, {
|
|
50
|
+
auth: { "x-agent-api-key": binding.token },
|
|
51
|
+
tenant: config.expectedTenant,
|
|
52
|
+
signal: AbortSignal.timeout(2_000),
|
|
53
|
+
});
|
|
54
|
+
if (!verified?.ok || verified.isError || verified.data?.tenant_id !== config.expectedTenant) {
|
|
55
|
+
throw new Error("telemetry credential is not attested to the expected tenant");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile, credential: binding.token };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadTelemetryLocation(cwd = process.cwd(), {
|
|
62
|
+
repositoryFromRemote: remote = repositoryFromRemote,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
65
|
+
const root = stdout.trim();
|
|
66
|
+
const raw = await readFile(join(root, ".botbuddy", "config.json"), "utf8");
|
|
67
|
+
const config = parseTelemetryConfig(raw);
|
|
68
|
+
const repository = await remote(root);
|
|
69
|
+
if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
|
|
70
|
+
return { root, repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function loadTelemetryIdentity(cwd = process.cwd(), options = {}) {
|
|
74
|
+
const location = await loadTelemetryLocation(cwd, options);
|
|
75
|
+
const config = {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
repository: location.repository,
|
|
78
|
+
appSlug: location.appSlug,
|
|
79
|
+
profile: location.profile,
|
|
80
|
+
expectedTenant: location.tenant,
|
|
81
|
+
};
|
|
82
|
+
return verifyTelemetryIdentity(config, { cwd: location.root, ...options });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function credentialFingerprint(credential) {
|
|
86
|
+
return createHash("sha256").update(credential).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A tenant-bound key must be proven once before foreground execution can trust
|
|
90
|
+
// it. The proof is keyed to a non-reversible fingerprint in the 0600 outbox
|
|
91
|
+
// status file, so a later rotation or cross-tenant environment value cannot
|
|
92
|
+
// reuse a prior attestation. Once cached, the check remains fully offline.
|
|
93
|
+
export async function attestTelemetryCredential(identity, outbox, { call = callToolJson } = {}) {
|
|
94
|
+
const fingerprint = credentialFingerprint(identity?.credential ?? "");
|
|
95
|
+
if (!identity?.credential || !identity?.tenant || !outbox?.hasCredentialAttestation || !outbox?.recordCredentialAttestation) {
|
|
96
|
+
throw new Error("telemetry credential attestation is unavailable");
|
|
97
|
+
}
|
|
98
|
+
if (await outbox.hasCredentialAttestation({ tenant: identity.tenant, fingerprint })) return { cached: true };
|
|
99
|
+
const verified = await call("whoami", {}, {
|
|
100
|
+
auth: { "x-agent-api-key": identity.credential },
|
|
101
|
+
tenant: identity.tenant,
|
|
102
|
+
signal: AbortSignal.timeout(2_000),
|
|
103
|
+
});
|
|
104
|
+
if (!verified?.ok || verified.isError || verified.data?.tenant_id !== identity.tenant) {
|
|
105
|
+
throw new Error("telemetry credential is not attested to the expected tenant");
|
|
106
|
+
}
|
|
107
|
+
await outbox.recordCredentialAttestation({ tenant: identity.tenant, fingerprint });
|
|
108
|
+
return { cached: false };
|
|
109
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// BOT-1316 — direct HTTP delivery to BOT-1317. This intentionally bypasses the
|
|
2
|
+
// MCP tool surface: agents never need to call telemetry MCP tools.
|
|
3
|
+
export function executionIngestEndpoint(serverUrl = process.env.BOTBUDDY_SERVER_URL || "https://api.bot-buddy.ai/functions/v1/mcp-server") {
|
|
4
|
+
const url = new URL(serverUrl);
|
|
5
|
+
url.pathname = url.pathname.replace(/\/mcp-server\/?$/, "/execution-ingest");
|
|
6
|
+
return url.toString();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function deliverExecutionEvent(event, {
|
|
10
|
+
credential, endpoint = executionIngestEndpoint(), fetchImpl = fetch, timeoutMs = 2_000,
|
|
11
|
+
} = {}) {
|
|
12
|
+
if (typeof credential !== "string" || !credential) return { accepted: false, error: "credential_missing" };
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetchImpl(endpoint, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json", "x-agent-api-key": credential },
|
|
19
|
+
body: JSON.stringify(event),
|
|
20
|
+
signal: controller.signal,
|
|
21
|
+
});
|
|
22
|
+
let body = {};
|
|
23
|
+
try { body = await response.json(); } catch { /* typed HTTP status still controls retry */ }
|
|
24
|
+
if (response.status === 200 || response.status === 202) return { accepted: true, receipt: body.receipt ?? "accepted" };
|
|
25
|
+
// A reaper may have terminalized a run after a successful upload whose
|
|
26
|
+
// response was lost. BOT-1317 correctly refuses a contradictory terminal;
|
|
27
|
+
// retaining that record would otherwise block every later local event.
|
|
28
|
+
if (response.status === 409 && body.error === "terminal_conflict") return { accepted: true, receipt: "terminal_conflict_retired" };
|
|
29
|
+
// The queue cannot repair an event rejected by the server's contract.
|
|
30
|
+
// Quarantine these client errors so one malformed historical line does not
|
|
31
|
+
// starve later valid lifecycle events. Authentication is deliberately left
|
|
32
|
+
// retryable: a key can be restored or rotated without discarding evidence.
|
|
33
|
+
if ([400, 404, 409, 413, 422].includes(response.status)) {
|
|
34
|
+
return { accepted: false, permanent: true, error: body.error ?? `http_${response.status}` };
|
|
35
|
+
}
|
|
36
|
+
return { accepted: false, error: body.error ?? `http_${response.status}` };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return { accepted: false, error: controller.signal.aborted ? "timeout" : error?.code ?? "transport" };
|
|
39
|
+
} finally { clearTimeout(timeout); }
|
|
40
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// BOT-1316 — durable, local-first lifecycle delivery for foreground wrappers.
|
|
2
|
+
// This module deliberately knows nothing about MCP or child output. Producers
|
|
3
|
+
// append a small, allowlisted lifecycle event before spawning a child; delivery
|
|
4
|
+
// is a separate, idempotent step against execution-ingest.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { chmod, link, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const OUTBOX_SCHEMA_VERSION = 1;
|
|
12
|
+
export const MAX_OUTBOX_LINE_BYTES = 16 * 1024;
|
|
13
|
+
const LOCK_WAIT_MS = 25;
|
|
14
|
+
const LOCK_ATTEMPTS = 80;
|
|
15
|
+
const SENSITIVE = /(?:[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@[^\s/]+(?:\/[^\s]*)?|bb_[a-z0-9_-]{10,}|gh[pousr]_[a-z0-9]{16,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{16,}|xox[baprs]-[a-z0-9-]{10,}|sk-[a-z0-9]{20,}|(?:akia|asia)[a-z0-9]{16}|-----begin[a-z0-9 ]*private key-----[\s\S]*?-----end[a-z0-9 ]*private key-----)|\bbearer\s+[^\s"']+|(?:^|[^a-z0-9])(?:[a-z0-9]+_)*(?:token|api[_-]?key|private[_-]?key|password|secret)(?:_[a-z0-9]+)*\s*[=:]\s*(?:"[^"]*"|'[^']*'|[^\s"']+)/gi;
|
|
16
|
+
const DROP_KEYS = new Set(["stdout", "stderr", "output", "raw_output", "bounded_log", "env", "environment_values", "environment_value", "secrets"]);
|
|
17
|
+
const CONTEXT_KEYS = new Set([
|
|
18
|
+
"agent", "repo", "ticket", "pr", "branch", "sha", "environment", "runner", "phase",
|
|
19
|
+
"worker", "shard", "retry", "current_spec", "passed", "failed", "skipped", "health", "correlations",
|
|
20
|
+
]);
|
|
21
|
+
const CORRELATION_KEYS = new Set([
|
|
22
|
+
"tool_event_id", "command_receipt_id", "test_run_id", "test_execution_id", "wait_session_id",
|
|
23
|
+
"lane_session_id", "agent_signal_seq", "stack_lease_id",
|
|
24
|
+
]);
|
|
25
|
+
const PROGRESS_KEYS = new Set(["completed", "total", "phase", "current_item", "worker", "shard"]);
|
|
26
|
+
const DIAGNOSTIC_KEYS = new Set(["code", "message", "category", "signal", "retryable"]);
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
30
|
+
|
|
31
|
+
function redactString(value) {
|
|
32
|
+
SENSITIVE.lastIndex = 0;
|
|
33
|
+
return value.replace(SENSITIVE, "[REDACTED]");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Exported for wrappers and reporter adapters. Removing forbidden fields is more
|
|
37
|
+
// robust than merely redacting values: raw output is never useful lifecycle data.
|
|
38
|
+
export function redactTelemetry(value) {
|
|
39
|
+
if (Array.isArray(value)) return value.map(redactTelemetry);
|
|
40
|
+
if (!value || typeof value !== "object") return typeof value === "string" ? redactString(value) : value;
|
|
41
|
+
return Object.fromEntries(Object.entries(value)
|
|
42
|
+
.filter(([key]) => !DROP_KEYS.has(key.toLowerCase()))
|
|
43
|
+
.map(([key, item]) => [key, redactTelemetry(item)]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function repositoryHash(repository) {
|
|
47
|
+
return hash(repository.toLowerCase()).slice(0, 32);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validTenant(value) { return typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value); }
|
|
51
|
+
function validRepository(value) { return typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value); }
|
|
52
|
+
|
|
53
|
+
function eventPayload(event) {
|
|
54
|
+
const clean = redactTelemetry(event);
|
|
55
|
+
if (!clean || typeof clean !== "object" || Array.isArray(clean)) throw new Error("telemetry event must be an object");
|
|
56
|
+
// execution-ingest is intentionally strict. Keep this client-side allowlist so
|
|
57
|
+
// a reporter cannot accidentally turn arbitrary process state into telemetry.
|
|
58
|
+
const allowed = new Set(["schema_version", "action", "producer_kind", "source_run_id", "attempt", "producer_event_id", "producer_sequence", "occurred_at", "context", "progress", "outcome", "diagnostic", "evidence_refs"]);
|
|
59
|
+
for (const key of Object.keys(clean)) if (!allowed.has(key)) delete clean[key];
|
|
60
|
+
// Mirror the server contract at the producer boundary. A malformed reporter
|
|
61
|
+
// must not create a permanently retrying record merely because it included
|
|
62
|
+
// a harmless but unrecognised field.
|
|
63
|
+
if (clean.context && typeof clean.context === "object" && !Array.isArray(clean.context)) {
|
|
64
|
+
clean.context = Object.fromEntries(Object.entries(clean.context).filter(([key]) => CONTEXT_KEYS.has(key)));
|
|
65
|
+
if (clean.context.correlations && typeof clean.context.correlations === "object" && !Array.isArray(clean.context.correlations)) {
|
|
66
|
+
clean.context.correlations = Object.fromEntries(Object.entries(clean.context.correlations).filter(([key]) => CORRELATION_KEYS.has(key)));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (clean.progress && typeof clean.progress === "object" && !Array.isArray(clean.progress)) {
|
|
70
|
+
clean.progress = Object.fromEntries(Object.entries(clean.progress).filter(([key]) => PROGRESS_KEYS.has(key)));
|
|
71
|
+
}
|
|
72
|
+
if (clean.diagnostic && typeof clean.diagnostic === "object" && !Array.isArray(clean.diagnostic)) {
|
|
73
|
+
clean.diagnostic = Object.fromEntries(Object.entries(clean.diagnostic).filter(([key]) => DIAGNOSTIC_KEYS.has(key)));
|
|
74
|
+
}
|
|
75
|
+
return clean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function durableWrite(path, value, { append = false } = {}) {
|
|
79
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
80
|
+
const handle = await open(path, append ? "a" : "w", 0o600);
|
|
81
|
+
try {
|
|
82
|
+
await handle.writeFile(text);
|
|
83
|
+
await handle.sync();
|
|
84
|
+
} finally {
|
|
85
|
+
await handle.close();
|
|
86
|
+
}
|
|
87
|
+
await chmod(path, 0o600);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function atomicJson(path, value) {
|
|
91
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
92
|
+
await durableWrite(temp, `${JSON.stringify(value)}\n`);
|
|
93
|
+
await rename(temp, path);
|
|
94
|
+
// Persist the directory entry, not just the file data, before reporting the
|
|
95
|
+
// acknowledgement checkpoint as durable.
|
|
96
|
+
let dir;
|
|
97
|
+
try { dir = await open(dirname(path), "r"); await dir.sync(); } finally { await dir?.close(); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseRecord(line) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try { parsed = JSON.parse(line); } catch {
|
|
103
|
+
const error = new Error("invalid_json");
|
|
104
|
+
error.code = "invalid_json";
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
if (!parsed || parsed.schema_version !== OUTBOX_SCHEMA_VERSION || typeof parsed.checksum !== "string" || !parsed.event) throw new Error("invalid_record_shape");
|
|
108
|
+
const expected = hash(JSON.stringify(parsed.event));
|
|
109
|
+
if (parsed.checksum !== expected) throw new Error("checksum_mismatch");
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function safeErrorClass(error) {
|
|
114
|
+
const raw = String(error?.code ?? error?.name ?? "delivery_failed");
|
|
115
|
+
return raw.replace(/[^a-z0-9_.-]/gi, "_").slice(0, 96) || "delivery_failed";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function createTelemetryOutbox({
|
|
119
|
+
root = join(homedir(), ".botbuddy", "telemetry"), tenant, repository, producerVersion, now = () => new Date(),
|
|
120
|
+
} = {}) {
|
|
121
|
+
if (!validTenant(tenant)) throw new Error("telemetry tenant must be a lowercase slug");
|
|
122
|
+
if (!validRepository(repository)) throw new Error("telemetry repository must be canonical owner/name");
|
|
123
|
+
if (typeof producerVersion !== "string" || !producerVersion) throw new Error("telemetry producer version is required");
|
|
124
|
+
const directory = join(root, tenant, repositoryHash(repository));
|
|
125
|
+
const path = join(directory, "outbox.ndjson");
|
|
126
|
+
const lockPath = join(directory, "outbox.lock");
|
|
127
|
+
const quarantinePath = join(directory, "outbox.quarantine.ndjson");
|
|
128
|
+
const statusPath = join(directory, "status.json");
|
|
129
|
+
const legacyImportPath = join(directory, "legacy-test-runs.imported.json");
|
|
130
|
+
|
|
131
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
132
|
+
await chmod(directory, 0o700);
|
|
133
|
+
|
|
134
|
+
async function acquireLock() {
|
|
135
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
// Write a complete, fsynced owner record first, then atomically publish
|
|
138
|
+
// it with link(2). A crash can leave an unreferenced temp file but can
|
|
139
|
+
// never strand the shared lock name with missing PID metadata.
|
|
140
|
+
const candidate = `${lockPath}.${process.pid}.${Date.now()}.tmp`;
|
|
141
|
+
await durableWrite(candidate, JSON.stringify({ pid: process.pid, created_at: now().toISOString() }));
|
|
142
|
+
try { await link(candidate, lockPath); } finally { await unlink(candidate).catch(() => {}); }
|
|
143
|
+
return async () => { try { await unlink(lockPath); } catch { /* a process may have cleaned its own stale lock */ } };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error?.code !== "EEXIST") throw error;
|
|
146
|
+
let owner = null;
|
|
147
|
+
try { owner = JSON.parse(await readFile(lockPath, "utf8")); } catch { /* incomplete lock is never age-reclaimed */ }
|
|
148
|
+
// A lock may be reclaimed only after proving the same-host PID is gone.
|
|
149
|
+
if (Number.isInteger(owner?.pid) && owner.pid > 0) {
|
|
150
|
+
let alive = true;
|
|
151
|
+
try { process.kill(owner.pid, 0); } catch (probe) { alive = probe?.code !== "ESRCH"; }
|
|
152
|
+
if (!alive) { try { await unlink(lockPath); } catch { /* winner will retry */ } continue; }
|
|
153
|
+
}
|
|
154
|
+
await sleep(LOCK_WAIT_MS);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const err = new Error("telemetry_outbox_lock_busy");
|
|
158
|
+
err.code = "telemetry_outbox_lock_busy";
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readStatus() {
|
|
163
|
+
try { return JSON.parse(await readFile(statusPath, "utf8")); } catch { return {}; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function writeStatus(patch) {
|
|
167
|
+
const current = await readStatus();
|
|
168
|
+
await atomicJson(statusPath, { ...current, ...patch, producer_version: producerVersion, updated_at: now().toISOString() });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function hasCredentialAttestation({ tenant: attestedTenant, fingerprint }) {
|
|
172
|
+
const persisted = await readStatus();
|
|
173
|
+
const attestation = persisted.credential_attestation;
|
|
174
|
+
return attestation?.schema_version === 1
|
|
175
|
+
&& attestation.tenant === attestedTenant
|
|
176
|
+
&& attestation.fingerprint === fingerprint;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function recordCredentialAttestation({ tenant: attestedTenant, fingerprint }) {
|
|
180
|
+
if (!validTenant(attestedTenant) || !/^[a-f0-9]{64}$/.test(fingerprint ?? "")) throw new Error("invalid telemetry credential attestation");
|
|
181
|
+
await writeStatus({ credential_attestation: { schema_version: 1, tenant: attestedTenant, fingerprint, attested_at: now().toISOString() } });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readLines() {
|
|
185
|
+
try { return (await readFile(path, "utf8")).split("\n").filter(Boolean); } catch (error) { if (error?.code === "ENOENT") return []; throw error; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function append(event) {
|
|
189
|
+
const release = await acquireLock();
|
|
190
|
+
try {
|
|
191
|
+
const payload = eventPayload(event);
|
|
192
|
+
const record = {
|
|
193
|
+
schema_version: OUTBOX_SCHEMA_VERSION,
|
|
194
|
+
created_at: now().toISOString(),
|
|
195
|
+
producer_version: producerVersion,
|
|
196
|
+
repository,
|
|
197
|
+
event: payload,
|
|
198
|
+
delivery_attempts: 0,
|
|
199
|
+
checksum: hash(JSON.stringify(payload)),
|
|
200
|
+
};
|
|
201
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
202
|
+
if (Buffer.byteLength(line) > MAX_OUTBOX_LINE_BYTES) {
|
|
203
|
+
const error = new Error("telemetry_event_oversized"); error.code = "telemetry_event_oversized"; throw error;
|
|
204
|
+
}
|
|
205
|
+
// append + fsync completes before the caller gets a durable receipt, which
|
|
206
|
+
// is the required pre-spawn barrier for foreground commands.
|
|
207
|
+
await durableWrite(path, line, { append: true });
|
|
208
|
+
await writeStatus({ last_error_class: null });
|
|
209
|
+
return { durable: true, path, event: payload };
|
|
210
|
+
} finally { await release(); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function quarantine(line, offset, reason) {
|
|
214
|
+
await durableWrite(quarantinePath, `${JSON.stringify({ offset, reason, quarantined_at: now().toISOString(), line: line.slice(0, MAX_OUTBOX_LINE_BYTES) })}\n`, { append: true });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function replay(deliver, { maxEvents = Infinity, deadline = Infinity } = {}) {
|
|
218
|
+
if (typeof deliver !== "function") throw new Error("telemetry replay requires a delivery function");
|
|
219
|
+
const release = await acquireLock();
|
|
220
|
+
try {
|
|
221
|
+
const lines = await readLines();
|
|
222
|
+
const remaining = [];
|
|
223
|
+
let delivered = 0;
|
|
224
|
+
let quarantined = 0;
|
|
225
|
+
let halted = false;
|
|
226
|
+
let attempted = 0;
|
|
227
|
+
let offset = 0;
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
let record;
|
|
230
|
+
try {
|
|
231
|
+
if (Buffer.byteLength(line) > MAX_OUTBOX_LINE_BYTES) throw new Error("line_oversized");
|
|
232
|
+
record = parseRecord(line);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
quarantined += 1;
|
|
235
|
+
await quarantine(line, offset, error.message || "invalid_record");
|
|
236
|
+
offset += Buffer.byteLength(line) + 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (halted || attempted >= maxEvents || Date.now() >= deadline) { halted = true; remaining.push(record); offset += Buffer.byteLength(line) + 1; continue; }
|
|
240
|
+
try {
|
|
241
|
+
attempted += 1;
|
|
242
|
+
const result = await deliver(record.event, record);
|
|
243
|
+
if (!result?.accepted) {
|
|
244
|
+
if (result?.permanent) {
|
|
245
|
+
quarantined += 1;
|
|
246
|
+
await quarantine(line, offset, result.error ?? "delivery_rejected");
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
throw Object.assign(new Error("delivery_rejected"), { code: result?.error ?? "delivery_rejected" });
|
|
250
|
+
}
|
|
251
|
+
delivered += 1;
|
|
252
|
+
await writeStatus({ last_successful_delivery_at: now().toISOString(), last_error_class: null });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
halted = true;
|
|
255
|
+
remaining.push({ ...record, delivery_attempts: Number(record.delivery_attempts ?? 0) + 1, last_error_class: safeErrorClass(error), last_attempt_at: now().toISOString() });
|
|
256
|
+
await writeStatus({ last_error_class: safeErrorClass(error) });
|
|
257
|
+
}
|
|
258
|
+
offset += Buffer.byteLength(line) + 1;
|
|
259
|
+
}
|
|
260
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
261
|
+
await durableWrite(tmp, remaining.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
262
|
+
await rename(tmp, path);
|
|
263
|
+
let dir;
|
|
264
|
+
try { dir = await open(directory, "r"); await dir.sync(); } finally { await dir?.close(); }
|
|
265
|
+
return { delivered, remaining: remaining.length, quarantined };
|
|
266
|
+
} finally { await release(); }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function status() {
|
|
270
|
+
const lines = await readLines();
|
|
271
|
+
let oldest = null;
|
|
272
|
+
let queued = 0;
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
try {
|
|
275
|
+
const record = parseRecord(line);
|
|
276
|
+
queued += 1;
|
|
277
|
+
if (!oldest || record.created_at < oldest) oldest = record.created_at;
|
|
278
|
+
} catch { queued += 1; }
|
|
279
|
+
}
|
|
280
|
+
const persisted = await readStatus();
|
|
281
|
+
return {
|
|
282
|
+
queued_count: queued,
|
|
283
|
+
oldest_queued_at: oldest,
|
|
284
|
+
last_successful_delivery_at: persisted.last_successful_delivery_at ?? null,
|
|
285
|
+
last_error_class: persisted.last_error_class ?? null,
|
|
286
|
+
producer_version: producerVersion,
|
|
287
|
+
path,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The historical hook reporter writes summaries below `.botbuddy/test-runs`.
|
|
292
|
+
// Import them only through deterministic source/event keys; the persisted
|
|
293
|
+
// fingerprint ledger prevents repeated scans from growing the outbox, while
|
|
294
|
+
// server idempotency remains a second safety net if a machine dies mid-write.
|
|
295
|
+
async function importLegacyTestRunReceipts(legacyDirectory, { maxReceipts = Infinity, deadline = Infinity } = {}) {
|
|
296
|
+
let imported = {};
|
|
297
|
+
try { imported = JSON.parse(await readFile(legacyImportPath, "utf8")); } catch { /* first migration */ }
|
|
298
|
+
let names = [];
|
|
299
|
+
try { names = (await readdir(legacyDirectory)).filter((name) => name.endsWith(".json")); } catch (error) { if (error?.code === "ENOENT") return { imported: 0, skipped: 0 }; throw error; }
|
|
300
|
+
let added = 0;
|
|
301
|
+
let skipped = 0;
|
|
302
|
+
for (const name of names.sort()) {
|
|
303
|
+
if (added >= maxReceipts || Date.now() >= deadline) break;
|
|
304
|
+
const file = join(legacyDirectory, name);
|
|
305
|
+
let raw;
|
|
306
|
+
try { raw = await readFile(file, "utf8"); } catch { skipped += 1; continue; }
|
|
307
|
+
const fingerprint = hash(`${name}\0${raw}`);
|
|
308
|
+
if (imported[fingerprint]) { skipped += 1; continue; }
|
|
309
|
+
let summary;
|
|
310
|
+
try { summary = JSON.parse(raw); } catch { skipped += 1; continue; }
|
|
311
|
+
if (!summary || typeof summary !== "object" || typeof summary.runner !== "string") { skipped += 1; continue; }
|
|
312
|
+
const sourceRunId = `legacy-${fingerprint.slice(0, 32)}`;
|
|
313
|
+
const started = typeof summary.started_at === "string" ? summary.started_at : now().toISOString();
|
|
314
|
+
const finished = typeof summary.finished_at === "string" ? summary.finished_at : now().toISOString();
|
|
315
|
+
const passed = summary.verdict === "pass" || summary.exit_code === 0;
|
|
316
|
+
const context = { repo: repository, environment: "local", runner: summary.runner, phase: passed ? "complete" : "failed", health: "uninstrumented" };
|
|
317
|
+
await append({ schema_version: 1, action: "start", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:start`, producer_sequence: 0, occurred_at: started, context });
|
|
318
|
+
await append({ schema_version: 1, action: "terminal", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:terminal`, producer_sequence: 1, occurred_at: finished, context, outcome: passed ? "passed" : "failed" });
|
|
319
|
+
imported[fingerprint] = { name, imported_at: now().toISOString() };
|
|
320
|
+
added += 1;
|
|
321
|
+
}
|
|
322
|
+
await atomicJson(legacyImportPath, imported);
|
|
323
|
+
return { imported: added, skipped };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { directory, path, quarantinePath, statusPath, append, replay, status, hasCredentialAttestation, recordCredentialAttestation, importLegacyTestRunReceipts };
|
|
327
|
+
}
|
package/src/wait.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { VERSION } from "./version.mjs";
|
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
21
21
|
import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
|
|
22
|
+
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
22
23
|
|
|
23
24
|
// A protocol is deliberately distinct from package semver: compatible pinned
|
|
24
25
|
// clients keep working until the server raises this minimum, while a stale
|
|
@@ -36,7 +37,7 @@ const MIN_RECEIPT_MAX_BYTES = 512;
|
|
|
36
37
|
// BOT-1572 bb_sess_ legacy alias, both accepted for one release.
|
|
37
38
|
const SESSION_TOKEN_RE = AGENT_KEY_RE;
|
|
38
39
|
|
|
39
|
-
const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
|
|
40
|
+
export const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
|
|
40
41
|
|
|
41
42
|
USAGE
|
|
42
43
|
botbuddy wait [--any] <condition>... [options]
|
|
@@ -159,6 +160,8 @@ EXAMPLES
|
|
|
159
160
|
# after pushing review fixes, wait for the next review comment or a state change
|
|
160
161
|
botbuddy wait --any 'pr-review:repo=bot-buddy/botbuddy-webapp,pr=425' \\
|
|
161
162
|
'pr-state:repo=bot-buddy/botbuddy-webapp,pr=425' --timeout 3600
|
|
163
|
+
|
|
164
|
+
${SETUP_BLOCK}
|
|
162
165
|
`;
|
|
163
166
|
|
|
164
167
|
function parseArgv(argv) {
|
|
@@ -630,11 +633,11 @@ function emit(receipt, { versioned = false, maxBytes = null } = {}) {
|
|
|
630
633
|
// SAME string is written to stderr and the receipt `recovery` field that /waits
|
|
631
634
|
// renders. Keep each to one line and never embed a secret value — names, slots,
|
|
632
635
|
// and env-var names only.
|
|
633
|
-
const RECOVERY = Object.freeze({
|
|
636
|
+
export const RECOVERY = Object.freeze({
|
|
634
637
|
// BOT-1582: $BOTBUDDY_AGENT_KEY (bb_agent_+64hex, renamed from
|
|
635
638
|
// $BOTBUDDY_SESSION_TOKEN) is minted by register_agent; the harness exports it
|
|
636
639
|
// at session start. The legacy var is still accepted for one release.
|
|
637
|
-
sessionToken: "register_agent
|
|
640
|
+
sessionToken: "the tier-3 session token (bb_agent_) is minted by register_agent — export BOTBUDDY_AGENT_KEY=<session_token> (and BOTBUDDY_SESSION_ID=<session_id>); the harness exports both at session start. See docs/agent-connectivity.md",
|
|
638
641
|
// $BOTBUDDY_SESSION_ID is the work-graph session id returned by register_agent.
|
|
639
642
|
sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
|
|
640
643
|
// A --token / session-token contradiction: the session token is the whole
|
|
@@ -962,7 +965,7 @@ export async function runWait(argv) {
|
|
|
962
965
|
// register a work agent and export its session token.
|
|
963
966
|
if (err.errorCode === "client_key_cannot_wait") {
|
|
964
967
|
process.stderr.write(
|
|
965
|
-
"botbuddy wait: a client key (botbuddy login) cannot arm a wait — register_agent, export BOTBUDDY_AGENT_KEY=<session_token>, then re-run\n",
|
|
968
|
+
"botbuddy wait: a tier-1 client key (bb_cli_, botbuddy login) cannot arm a wait — you need the tier-3 session token: register_agent, export BOTBUDDY_AGENT_KEY=<session_token>, then re-run\n",
|
|
966
969
|
);
|
|
967
970
|
emitReceipt({
|
|
968
971
|
schema_version: 1,
|