@botbuddy/cli 1.64.12 → 1.64.14
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/bin/botbuddy.mjs +14 -2
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/commands.mjs +43 -10
- package/src/docker-hygiene.mjs +2 -1
- package/src/feedback-outbox.mjs +119 -0
- package/src/feedback-reminder.mjs +16 -0
- package/src/feedback-safety.mjs +31 -0
- package/src/feedback.integration.fixture.mjs +103 -0
- package/src/feedback.mjs +269 -0
- package/src/locks.mjs +105 -0
- package/src/pw/run.mjs +18 -6
- package/src/run.mjs +175 -6
- package/src/stack-run-outcome.mjs +3 -1
- package/src/stack.mjs +32 -5
- package/src/test/playwright-reporter.mjs +3 -1
- package/src/test-lane-events.mjs +8 -1
- package/src/wait-feedback-capture.mjs +62 -0
- package/src/wait.mjs +41 -0
package/bin/botbuddy.mjs
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run } from "../src/commands.mjs";
|
|
3
3
|
import { ensureRunDelivery } from "../src/run-delivery.mjs";
|
|
4
|
+
import { feedbackReminder } from "../src/feedback-reminder.mjs";
|
|
5
|
+
import { writeSync } from "node:fs";
|
|
4
6
|
|
|
5
7
|
const argv = process.argv.slice(2);
|
|
8
|
+
// Some commands exit directly; observe the final exit once so those paths
|
|
9
|
+
// receive the same stderr-only guidance without changing their exit status.
|
|
10
|
+
process.once("exit", (code) => {
|
|
11
|
+
const reminder = feedbackReminder(argv, code);
|
|
12
|
+
if (reminder) {
|
|
13
|
+
try { writeSync(2, `${reminder}\n`); } catch { /* preserve the original exit status */ }
|
|
14
|
+
}
|
|
15
|
+
});
|
|
6
16
|
|
|
7
17
|
// BOT-2600: normal `bb` invocations start the single per-machine delivery process
|
|
8
18
|
// when finished `bb run` commands still have an unsent terminal update. A stat
|
|
@@ -15,8 +25,10 @@ const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
|
15
25
|
const readOnly = flags.some((flag) => flag === "--help" || flag === "-h")
|
|
16
26
|
|| argv[0] === "help"
|
|
17
27
|
|| (argv[0] === "stack" && argv[1] === "help")
|
|
18
|
-
|| (argv[0] === "run" && (argv[1] === "inspect" || argv[1] === "help"));
|
|
19
|
-
|
|
28
|
+
|| (argv[0] === "run" && (argv[1] === "status" || argv[1] === "inspect" || argv[1] === "help"));
|
|
29
|
+
// Feedback must not start unrelated delivery while an agent reports a blocker.
|
|
30
|
+
const feedbackCommand = argv[0] === "feedback";
|
|
31
|
+
const delivery = deliveryProcessArgs || readOnly || feedbackCommand ? Promise.resolve() : ensureRunDelivery().catch(() => {});
|
|
20
32
|
|
|
21
33
|
delivery.then(() => run(argv)).then((code) => {
|
|
22
34
|
if (typeof code === "number") process.exitCode = code;
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.14",
|
|
3
|
+
"source_commit": "2bea99f6e692232d6694356e0fc474daebba622e",
|
|
4
|
+
"source_sha256": "abb141ad30727d4a9f32819a3e26be122ab750a919dfc21f7813d6dbd3bcaaa3",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "2ee00d7621d20c09863a0dc96f862a99ed10a3557ba15df595e512f433020a7c"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.14",
|
|
3
|
+
"source_commit": "2bea99f6e692232d6694356e0fc474daebba622e",
|
|
4
|
+
"source_sha256": "abb141ad30727d4a9f32819a3e26be122ab750a919dfc21f7813d6dbd3bcaaa3",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "af268c0f266b387fadc180451ef738760eb95c409c588cc7de62c9ee2b18c8f9"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.64.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.64.14",
|
|
4
|
+
"source_commit": "2bea99f6e692232d6694356e0fc474daebba622e",
|
|
5
|
+
"source_sha256": "abb141ad30727d4a9f32819a3e26be122ab750a919dfc21f7813d6dbd3bcaaa3",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "2ee00d7621d20c09863a0dc96f862a99ed10a3557ba15df595e512f433020a7c",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "af268c0f266b387fadc180451ef738760eb95c409c588cc7de62c9ee2b18c8f9",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { bootstrapHarnessSession, clearHarnessSession, doctorBootstrapState, for
|
|
|
10
10
|
import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
|
|
11
11
|
import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
|
|
12
12
|
import { agentProfileKeyLabel } from "./credential-kinds.mjs";
|
|
13
|
-
import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
|
|
13
|
+
import { buildAcquireResourcesPayload, LocksUsageError, classifyLockResponse, lockOutcomeLines, unlockOutcomeLines, UNLOCK_QUEUED_USAGE } from "./locks.mjs";
|
|
14
14
|
import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
|
|
15
15
|
import { cmdStack } from "./stack.mjs";
|
|
16
16
|
import { cmdManagedAllocation } from "./managed-allocation.mjs";
|
|
@@ -18,6 +18,7 @@ import { cmdDocker } from "./docker-hygiene.mjs";
|
|
|
18
18
|
import { cmdRun } from "./run.mjs";
|
|
19
19
|
import { cmdTest } from "./test-lane.mjs";
|
|
20
20
|
import { cmdEvidence } from "./evidence.mjs";
|
|
21
|
+
import { cmdFeedback } from "./feedback.mjs";
|
|
21
22
|
import { runWait } from "./wait.mjs";
|
|
22
23
|
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
23
24
|
import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
@@ -48,7 +49,7 @@ import { cmdDaemon } from "./daemon/commands.mjs";
|
|
|
48
49
|
export function shouldCheckForUpdates(argv) {
|
|
49
50
|
const [command] = argv;
|
|
50
51
|
// BOT-1982: nor `test-ingest`, which runs at the tail of every test harness.
|
|
51
|
-
if (command === "wait" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
|
|
52
|
+
if (command === "wait" || command === "feedback" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
|
|
52
53
|
if (argv.includes("--json")) return false;
|
|
53
54
|
// BOT-2599: `bb doctor --only <check>` runs inside a git pre-push hook; it must
|
|
54
55
|
// stay local and sub-second, so it never consults the npm registry.
|
|
@@ -61,7 +62,7 @@ export function shouldCheckForUpdates(argv) {
|
|
|
61
62
|
// of the same name. MCP tools are snake_case, so no name here can shadow one.
|
|
62
63
|
export const LOCAL_HELP_COMMANDS = Object.freeze([
|
|
63
64
|
"wait", "stack", "pw", "docker", "run", "mcp", "login", "setup", "doctor", "host",
|
|
64
|
-
"staging-pool", "daemon", "evidence", "usage", "telemetry", "allocation",
|
|
65
|
+
"staging-pool", "daemon", "evidence", "feedback", "usage", "telemetry", "allocation",
|
|
65
66
|
]);
|
|
66
67
|
const HELP_WORDS = new Set(["help", "--help", "-h"]);
|
|
67
68
|
|
|
@@ -99,6 +100,9 @@ export async function run(argv, {
|
|
|
99
100
|
if (command === "bootstrap" && args[0] === "close" && parseBootstrapCloseArgs(args).action !== "close") {
|
|
100
101
|
return bootstrap(args);
|
|
101
102
|
}
|
|
103
|
+
// Creating or reading local feedback guidance must work even when the
|
|
104
|
+
// credential store or network is the reason the agent is blocked.
|
|
105
|
+
if (command === "feedback" && (args[0] === "init" || HELP_WORDS.has(args[0]))) return cmdFeedback(args);
|
|
102
106
|
|
|
103
107
|
await load();
|
|
104
108
|
if (shouldCheckForUpdates([command, ...args])) await warnStale({ version: VERSION });
|
|
@@ -138,6 +142,7 @@ export async function run(argv, {
|
|
|
138
142
|
}
|
|
139
143
|
case "test": return cmdTest(args);
|
|
140
144
|
case "evidence": return cmdEvidence(args);
|
|
145
|
+
case "feedback": return cmdFeedback(args);
|
|
141
146
|
case "test-ingest": return cmdTestIngest(args);
|
|
142
147
|
case "wait": return runWait(args);
|
|
143
148
|
case "pw": return runPw(args);
|
|
@@ -302,18 +307,28 @@ ${bold("STAGING SIGN-IN")} (BOT-2084)
|
|
|
302
307
|
${bold("LOCKS")}
|
|
303
308
|
locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
|
|
304
309
|
lock <resource_name> <type> Acquire one named lock (acquire_lock)
|
|
305
|
-
|
|
310
|
+
locks/lock: exit 0 acquired · 2 queued · 1 refused.
|
|
311
|
+
Use a resource only after exit 0; queued means another
|
|
312
|
+
agent holds it and you are in line for it.
|
|
313
|
+
unlock <resource_name> Release one named lock you hold (release_lock); it
|
|
314
|
+
does not withdraw a queued request
|
|
315
|
+
unlock --queued <resource_id> <resource_lock_id>
|
|
316
|
+
Withdraw your queued request (ids from the queued
|
|
317
|
+
lock result; retire_queued_lock_attempt)
|
|
306
318
|
|
|
307
319
|
${bold("DURABLE WORKLOADS")} (${cyan("bb help run")})
|
|
308
320
|
run --session-id <id> --environment <env> -- <command>
|
|
309
321
|
Launch a receipt-bearing command under a detached owner
|
|
310
322
|
run --foreground --kind <kind> --environment <env> -- <command>
|
|
311
323
|
Run locally with a durable lifecycle outbox
|
|
324
|
+
run status <run_id> Read status and exact available log or receipt commands
|
|
312
325
|
run inspect|cancel <run_id> Read or stop one command run
|
|
313
326
|
telemetry <status|replay|doctor>
|
|
314
327
|
Inspect, replay, or gate durable lifecycle delivery
|
|
315
328
|
|
|
316
329
|
${bold("EVIDENCE")} (${cyan("bb help evidence")})
|
|
330
|
+
feedback init|submit --file <report.json>
|
|
331
|
+
Prepare or send a BotBuddy issue report
|
|
317
332
|
evidence require <file> Record an exact-head evidence disposition → botbuddy/ready
|
|
318
333
|
evidence publish <file> Publish a local evidence statement → botbuddy/ready check-run
|
|
319
334
|
|
|
@@ -1713,23 +1728,41 @@ export async function cmdMcp(args, {
|
|
|
1713
1728
|
die(`Unknown mcp subcommand: ${sub}. Run ${cyan("botbuddy mcp --help")}.`);
|
|
1714
1729
|
}
|
|
1715
1730
|
|
|
1716
|
-
|
|
1731
|
+
// BOT-2994: each lock command returns its exit code (0 acquired · 2 queued ·
|
|
1732
|
+
// 1 refused; auth 3 / transport 5 unchanged) and says on stderr what the caller
|
|
1733
|
+
// holds. stdout keeps the server's result exactly as before.
|
|
1734
|
+
async function finishLock(response, lines) {
|
|
1735
|
+
const outcome = classifyLockResponse(response);
|
|
1736
|
+
for (const line of lines(outcome)) console.error(line);
|
|
1737
|
+
return outcome.exit;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
async function cmdLock(args) {
|
|
1717
1741
|
if (args.length < 2) die("Usage: botbuddy lock <resource_name> <type>");
|
|
1718
|
-
return callTool("acquire_lock", { resource_name: args[0], resource_type: args[1] });
|
|
1742
|
+
return finishLock(await callTool("acquire_lock", { resource_name: args[0], resource_type: args[1] }), lockOutcomeLines);
|
|
1719
1743
|
}
|
|
1720
1744
|
|
|
1721
|
-
function cmdLocks(args) {
|
|
1745
|
+
async function cmdLocks(args) {
|
|
1746
|
+
let payload;
|
|
1722
1747
|
try {
|
|
1723
|
-
|
|
1748
|
+
payload = buildAcquireResourcesPayload(args);
|
|
1724
1749
|
} catch (error) {
|
|
1725
1750
|
if (error instanceof LocksUsageError) die(error.message);
|
|
1726
1751
|
throw error;
|
|
1727
1752
|
}
|
|
1753
|
+
return finishLock(await callTool("acquire_resources", payload), lockOutcomeLines);
|
|
1728
1754
|
}
|
|
1729
1755
|
|
|
1730
|
-
function cmdUnlock(args) {
|
|
1756
|
+
async function cmdUnlock(args) {
|
|
1757
|
+
// BOT-2994: release_lock only releases a lock you HOLD; a queued request is
|
|
1758
|
+
// withdrawn by its exact resource + queued-ledger ids (retire_queued_lock_attempt).
|
|
1759
|
+
if (args[0] === "--queued") {
|
|
1760
|
+
if (args.length !== 3 || !args[1] || !args[2] || args[1].startsWith("-") || args[2].startsWith("-")) die(UNLOCK_QUEUED_USAGE);
|
|
1761
|
+
const response = await callTool("retire_queued_lock_attempt", { resource_id: args[1], resource_lock_id: args[2] });
|
|
1762
|
+
return finishLock(response, (outcome) => unlockOutcomeLines(outcome, { queued: true }));
|
|
1763
|
+
}
|
|
1731
1764
|
if (!args[0]) die("Usage: botbuddy unlock <resource_name>");
|
|
1732
|
-
return callTool("release_lock", { resource_name: args[0] });
|
|
1765
|
+
return finishLock(await callTool("release_lock", { resource_name: args[0] }), (outcome) => unlockOutcomeLines(outcome));
|
|
1733
1766
|
}
|
|
1734
1767
|
|
|
1735
1768
|
function cmdTask(args) {
|
package/src/docker-hygiene.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { callToolJson } from "./api.mjs";
|
|
|
10
10
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
11
11
|
import { VERSION } from "./version.mjs";
|
|
12
12
|
import { machineUuid } from "./machine-id.mjs";
|
|
13
|
+
import { FEEDBACK_GUIDANCE } from "./feedback-reminder.mjs";
|
|
13
14
|
|
|
14
15
|
export const SCHEMA_VERSION = 1;
|
|
15
16
|
// BOT-2206: preflight applies policy v3's network term (BOT-2202). Docker's built-in
|
|
@@ -1311,7 +1312,7 @@ export function cmdDocker(argv) {
|
|
|
1311
1312
|
return;
|
|
1312
1313
|
}
|
|
1313
1314
|
return runDockerCommand(argv).then((result) => {
|
|
1314
|
-
if (result.json) console.log(JSON.stringify(result.receipt));
|
|
1315
|
+
if (result.json) console.log(JSON.stringify(result.exitCode === EXIT.OK ? result.receipt : { ...result.receipt, feedback_guidance: FEEDBACK_GUIDANCE }));
|
|
1315
1316
|
else console.log(formatHumanReceipt(result.receipt));
|
|
1316
1317
|
if (result.exitCode !== EXIT.OK) process.exitCode = result.exitCode;
|
|
1317
1318
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Private, bounded retry storage. Never stores credentials; a cached agent and
|
|
2
|
+
// tenant are only a replay fence and are rechecked against whoami at delivery.
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, readdir, readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { acquireStackLock } from "./stack-file-lock.mjs";
|
|
8
|
+
import { restrictWindowsAcl, restrictWindowsAclDir } from "./agent-state-fs.mjs";
|
|
9
|
+
import { containsFeedbackSecret } from "./feedback-safety.mjs";
|
|
10
|
+
|
|
11
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const TENANT = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
13
|
+
const MAX_ITEMS = 100;
|
|
14
|
+
const MAX_BYTES = 32 * 1024;
|
|
15
|
+
const sensitive = containsFeedbackSecret;
|
|
16
|
+
|
|
17
|
+
async function directory(root) {
|
|
18
|
+
const path = join(root, ".botbuddy", "feedback-outbox");
|
|
19
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
20
|
+
const info = await lstat(path);
|
|
21
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("outbox_directory_invalid");
|
|
22
|
+
if (process.platform === "win32") await restrictWindowsAclDir(path);
|
|
23
|
+
else {
|
|
24
|
+
await chmod(path, 0o700);
|
|
25
|
+
if (((await lstat(path)).mode & 0o077) !== 0) throw new Error("outbox_directory_not_private");
|
|
26
|
+
}
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validRecord(record) {
|
|
31
|
+
return record && record.schema_version === 1 && typeof record.agent_id === "string" && UUID.test(record.agent_id) && typeof record.tenant === "string" && TENANT.test(record.tenant) &&
|
|
32
|
+
record.report && typeof record.report === "object" && !Array.isArray(record.report) && typeof record.report.idempotency_key === "string" && UUID.test(record.report.idempotency_key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function syncDir(path) {
|
|
36
|
+
if (process.platform === "win32") return;
|
|
37
|
+
const handle = await open(path, "r");
|
|
38
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function queueFeedback({ root, agentId, tenant, report }) {
|
|
42
|
+
const record = { schema_version: 1, agent_id: agentId, tenant, created_at: new Date().toISOString(), report };
|
|
43
|
+
if (!validRecord(record)) throw new Error("outbox_identity_unavailable");
|
|
44
|
+
if (sensitive(report)) throw new Error("outbox_sensitive_value");
|
|
45
|
+
const text = `${JSON.stringify(record)}\n`;
|
|
46
|
+
if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("outbox_report_too_large");
|
|
47
|
+
const path = await directory(root);
|
|
48
|
+
const lock = await acquireStackLock(join(path, ".lock"), { timeoutMs: 2000 });
|
|
49
|
+
try {
|
|
50
|
+
const final = join(path, `${report.idempotency_key.toLowerCase()}.json`);
|
|
51
|
+
const names = (await readdir(path)).filter((name) => /^[0-9a-f-]{36}\.json$/i.test(name));
|
|
52
|
+
if (names.length >= MAX_ITEMS && !names.includes(`${report.idempotency_key.toLowerCase()}.json`)) throw new Error("outbox_full");
|
|
53
|
+
const temp = join(path, `.${randomUUID()}.tmp`);
|
|
54
|
+
try {
|
|
55
|
+
const file = await open(temp, "wx", 0o600);
|
|
56
|
+
try { await file.writeFile(text); await file.sync(); } finally { await file.close(); }
|
|
57
|
+
if (process.platform === "win32") await restrictWindowsAcl(temp);
|
|
58
|
+
else await chmod(temp, 0o600);
|
|
59
|
+
try { await link(temp, final); }
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error?.code !== "EEXIST") throw error;
|
|
62
|
+
const existingInfo = await lstat(final);
|
|
63
|
+
if (!existingInfo.isFile() || existingInfo.isSymbolicLink() || existingInfo.size > MAX_BYTES || (process.platform !== "win32" && (existingInfo.mode & 0o077) !== 0)) throw new Error("outbox_record_invalid");
|
|
64
|
+
const existing = JSON.parse(await readFile(final, "utf8"));
|
|
65
|
+
if (!validRecord(existing) || existing.agent_id !== agentId || existing.tenant !== tenant || !isDeepStrictEqual(existing.report, report)) throw new Error("outbox_idempotency_conflict");
|
|
66
|
+
}
|
|
67
|
+
await syncDir(path);
|
|
68
|
+
return { queued: true, idempotency_key: report.idempotency_key, flush_command: "bb feedback flush" };
|
|
69
|
+
} finally { await unlink(temp).catch(() => {}); }
|
|
70
|
+
} finally { lock.release(); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Caller owns the exclusive lock for the whole bounded replay. Corrupt files
|
|
74
|
+
// are retained, and a successful operation is the only authority to delete.
|
|
75
|
+
export async function withFeedbackOutbox(root, operation) {
|
|
76
|
+
const path = await directory(root);
|
|
77
|
+
const lock = await acquireStackLock(join(path, ".lock"), { timeoutMs: 2000 });
|
|
78
|
+
try {
|
|
79
|
+
const names = (await readdir(path)).filter((name) => /^[0-9a-f-]{36}\.json$/i.test(name)).sort();
|
|
80
|
+
return await operation(names.slice(0, 10).map((name) => ({ name, path: join(path, name) })), {
|
|
81
|
+
more: names.length > 10,
|
|
82
|
+
read: async (item) => {
|
|
83
|
+
const info = await lstat(item.path);
|
|
84
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) throw new Error("outbox_record_invalid");
|
|
85
|
+
const record = JSON.parse(await readFile(item.path, "utf8"));
|
|
86
|
+
if (!validRecord(record) || sensitive(record.report) || item.name.toLowerCase() !== `${record.report.idempotency_key.toLowerCase()}.json`) throw new Error("outbox_record_invalid");
|
|
87
|
+
return record;
|
|
88
|
+
},
|
|
89
|
+
exportDraft: async (record) => {
|
|
90
|
+
const final = join(path, `draft-${record.report.idempotency_key.toLowerCase()}.json`);
|
|
91
|
+
const text = `${JSON.stringify(record.report, null, 2)}\n`;
|
|
92
|
+
if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("outbox_report_too_large");
|
|
93
|
+
const temp = join(path, `.${randomUUID()}.tmp`);
|
|
94
|
+
try {
|
|
95
|
+
const file = await open(temp, "wx", 0o600);
|
|
96
|
+
try { await file.writeFile(text); await file.sync(); } finally { await file.close(); }
|
|
97
|
+
if (process.platform === "win32") await restrictWindowsAcl(temp); else await chmod(temp, 0o600);
|
|
98
|
+
try { await link(temp, final); } catch (error) {
|
|
99
|
+
if (error?.code !== "EEXIST") throw error;
|
|
100
|
+
const info = await lstat(final);
|
|
101
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES || (process.platform !== "win32" && (info.mode & 0o077) !== 0) || await readFile(final, "utf8") !== text) throw new Error("outbox_draft_conflict");
|
|
102
|
+
}
|
|
103
|
+
await syncDir(path);
|
|
104
|
+
return final;
|
|
105
|
+
} finally { await unlink(temp).catch(() => {}); }
|
|
106
|
+
},
|
|
107
|
+
remove: async (item) => { await unlink(item.path); await syncDir(path); },
|
|
108
|
+
});
|
|
109
|
+
} finally { lock.release(); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function hasPendingFeedback(root) {
|
|
113
|
+
const path = join(root, ".botbuddy", "feedback-outbox");
|
|
114
|
+
try {
|
|
115
|
+
const info = await lstat(path);
|
|
116
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("outbox_directory_invalid");
|
|
117
|
+
return (await readdir(path)).some((name) => /^[0-9a-f-]{36}\.json$/i.test(name));
|
|
118
|
+
} catch (error) { if (error?.code === "ENOENT") return false; throw error; }
|
|
119
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const FEEDBACK_GUIDANCE = Object.freeze({
|
|
2
|
+
when: "If BotBuddy is blocking your work",
|
|
3
|
+
init_command: "bb feedback init --file bb-feedback.json",
|
|
4
|
+
submit_command: "bb feedback submit --file bb-feedback.json",
|
|
5
|
+
context: "Add your goal, expected and actual outcomes, evidence and why you are blocked. Preserve the original run and the report's idempotency key.",
|
|
6
|
+
});
|
|
7
|
+
let guidanceAlreadyShown = false;
|
|
8
|
+
export function markFeedbackGuidanceShown() { guidanceAlreadyShown = true; }
|
|
9
|
+
|
|
10
|
+
/** Print only guidance, not an automatic incident classification. A failed
|
|
11
|
+
* child test, invalid input or ordinary refusal does not prove a BB defect. */
|
|
12
|
+
export function feedbackReminder(argv, code) {
|
|
13
|
+
if (guidanceAlreadyShown || !Number.isInteger(code) || code === 0 || argv[0] === "feedback" || argv.includes("--json") ||
|
|
14
|
+
(argv[0] === "run" && ["--worker", "--deliver"].includes(argv[1]))) return null;
|
|
15
|
+
return `${FEEDBACK_GUIDANCE.when}, run \`${FEEDBACK_GUIDANCE.init_command}\`, add your goal, expected and actual outcomes, evidence and why you are blocked, then run \`${FEEDBACK_GUIDANCE.submit_command}\`. Preserve the original run and the report's idempotency key.`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { containsSensitive } from "./telemetry-outbox.mjs";
|
|
2
|
+
|
|
3
|
+
// Only non-secret BB receipt queries are shareable; arbitrary query strings
|
|
4
|
+
// and fragments may contain signed credentials and remain forbidden.
|
|
5
|
+
export function isSafeBotBuddyReceiptUrl(value) {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (url.origin !== "https://app.bot-buddy.ai" || url.username || url.password || url.hash) return false;
|
|
9
|
+
const key = url.pathname === "/waits" ? "wait" : url.pathname === "/docker" ? "lease" : null;
|
|
10
|
+
if (!key || [...url.searchParams.keys()].length !== 1) return false;
|
|
11
|
+
const id = url.searchParams.get(key);
|
|
12
|
+
return typeof id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
13
|
+
} catch { return false; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function containsFeedbackSecret(value) {
|
|
17
|
+
// This public numeric setting resembles a bb_ credential. Exempt its exact
|
|
18
|
+
// case-sensitive name only; still scan assigned values and surrounding text.
|
|
19
|
+
if (typeof value === "string") {
|
|
20
|
+
value = value.replace(/\\u([0-9a-f]{4})/gi, (_match, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
21
|
+
return containsSensitive(value.replace(/\bBB_HERMETIC_INTEGRATION_TIMEOUT_SEC\b/g, "PUBLIC_TIMEOUT_SETTING")) ||
|
|
22
|
+
/-----begin[a-z0-9 ]*private key-----/i.test(value) ||
|
|
23
|
+
/\\?["'][a-z0-9_-]*(?:password|passwd|token|api[_-]?key|private[_-]?key|secret|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie)[a-z0-9_-]*\\?["']\s*[:=]\s*(?:"[^"]+"|'[^']+'|[^\s,}\]]+)/i.test(value) ||
|
|
24
|
+
/\b[a-z0-9_-]*(?:password|passwd|token|api[_-]?key|private[_-]?key|secret|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie)[a-z0-9_-]*\s*[:=]\s*(?:"[^"]+"|'[^']+'|[^\s,}\]]+)/i.test(value) ||
|
|
25
|
+
(!isSafeBotBuddyReceiptUrl(value) && /https?:\/\/[^\s<>"']+[?#][^\s<>"']+/i.test(value)) ||
|
|
26
|
+
/\b(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|apikey)\s*:\s*\S+/i.test(value) ||
|
|
27
|
+
/\blin_api_[a-z0-9_-]{12,}|\bsk-(?:proj-|svcacct-)?[a-z0-9_-]{20,}|\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/i.test(value);
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) return value.some(containsFeedbackSecret);
|
|
30
|
+
return value !== null && typeof value === "object" && Object.entries(value).some(([key, item]) => (/^[a-z0-9_-]*(?:password|passwd|token|api[_-]?key|private[_-]?key|secret|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie)[a-z0-9_-]*$/i.test(key) && item != null && item !== "") || containsFeedbackSecret(key) || containsFeedbackSecret(item));
|
|
31
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtemp, readdir } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { cmdFeedback } from "./feedback.mjs";
|
|
7
|
+
import { cmdRun, OUTCOME_CHANNEL } from "./stack.mjs";
|
|
8
|
+
import { harnessSessionIdFromEnv } from "./agent-state.mjs";
|
|
9
|
+
|
|
10
|
+
const endpoint = process.argv[2];
|
|
11
|
+
const out = [], errors = [];
|
|
12
|
+
const evidenceLink = "https://app.bot-buddy.ai/waits?wait=485c66f8-c804-4a0f-a49e-2ceaf06344f8";
|
|
13
|
+
const deps = (foreign = false) => ({
|
|
14
|
+
read: async () => JSON.stringify({ idempotency_key: randomUUID(), kind: "bug", surface: "cli", title: "CLI feedback integration", impact: "slowed", evidence: [{ type: "url", value: evidenceLink }] }),
|
|
15
|
+
resolveAuth: async () => ({ auth: {} }),
|
|
16
|
+
call: async (tool, args) => (await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tool, args, foreign }) })).json(),
|
|
17
|
+
log: (line) => out.push(line), errorLog: (line) => errors.push(line),
|
|
18
|
+
});
|
|
19
|
+
assert.equal(await cmdFeedback(["submit", "--file", "report.json"], deps()), 0);
|
|
20
|
+
const receipt = JSON.parse(out.pop()), id = receipt.feedback_id;
|
|
21
|
+
assert.equal(receipt.status_command, `bb feedback status --feedback-id ${id}`);
|
|
22
|
+
assert.equal(await cmdFeedback(["status", "--feedback-id", id], deps()), 0);
|
|
23
|
+
const status = JSON.parse(out.pop());
|
|
24
|
+
assert.equal(status.feedback_id, id);
|
|
25
|
+
assert.equal(status.report.evidence[0].value, evidenceLink);
|
|
26
|
+
assert.equal(await cmdFeedback(["updates", "--feedback-id", id], deps()), 0);
|
|
27
|
+
const updates = JSON.parse(out.pop());
|
|
28
|
+
assert.ok(updates.items.length > 0);
|
|
29
|
+
const revision = updates.items[0].revision;
|
|
30
|
+
assert.equal(updates.items[0].acknowledge_command, `bb feedback acknowledge --feedback-id ${id} --revision ${revision}`);
|
|
31
|
+
assert.equal(await cmdFeedback(["acknowledge", "--feedback-id", id, "--revision", String(revision)], deps()), 0);
|
|
32
|
+
assert.equal(JSON.parse(out.pop()).revision, revision);
|
|
33
|
+
assert.equal(await cmdFeedback(["updates", "--feedback-id", id], deps()), 0);
|
|
34
|
+
assert.ok(JSON.parse(out.pop()).items[0].acknowledged_at);
|
|
35
|
+
assert.deepEqual(errors, []);
|
|
36
|
+
assert.equal(await cmdFeedback(["status", "--feedback-id", id], deps(true)), 1);
|
|
37
|
+
assert.match(errors[0], /unavailable/);
|
|
38
|
+
const sourceRun = randomUUID();
|
|
39
|
+
const failure = { exitCode: 6, outcome: "failed", code: "TUNNEL_LOST", parkReason: "tunnel_lost", leaseId: randomUUID(), childExitCode: 143, cleanup: { ok: true }, fenced: false };
|
|
40
|
+
Object.defineProperty(failure, OUTCOME_CHANNEL, { value: { auth: {}, callTool: deps().call }, enumerable: false });
|
|
41
|
+
const originalWrite = process.stdout.write;
|
|
42
|
+
let stackReceipt = "", feedbackMessage = "";
|
|
43
|
+
try {
|
|
44
|
+
process.stdout.write = (chunk) => { stackReceipt += chunk; return true; };
|
|
45
|
+
const code = await cmdRun({ repo: "botbuddy-web", ticket: "BOT-1540", runId: sourceRun }, ["true"], { lifecycle: async () => failure, env: { BOTBUDDY_STACK_RUN_OUTCOME: "on" }, feedbackLog: (message) => { feedbackMessage += message; }, markFeedback: () => {} });
|
|
46
|
+
assert.equal(code, 6);
|
|
47
|
+
} finally { process.stdout.write = originalWrite; }
|
|
48
|
+
assert.equal(JSON.parse(stackReceipt).exit_code, 6);
|
|
49
|
+
assert.equal(JSON.parse(stackReceipt).feedback_recovery.recover_command, "bb feedback recover");
|
|
50
|
+
const capturedId = /bb feedback status --feedback-id ([0-9a-f-]+)/i.exec(feedbackMessage)?.[1];
|
|
51
|
+
assert.ok(capturedId, "stack failure must expose the captured feedback ID");
|
|
52
|
+
assert.ok(feedbackMessage.includes(`bb feedback enrich --feedback-id ${capturedId}`));
|
|
53
|
+
assert.equal(await cmdFeedback(["status", "--feedback-id", capturedId], deps()), 0);
|
|
54
|
+
const auto = JSON.parse(out.pop());
|
|
55
|
+
assert.equal(auto.report.correlation_id, sourceRun);
|
|
56
|
+
assert.equal(auto.capture_state, "needs_context");
|
|
57
|
+
assert.equal(auto.failure_attribution, "exact");
|
|
58
|
+
const duplicateDeps = deps();
|
|
59
|
+
duplicateDeps.read = async () => JSON.stringify({ idempotency_key: randomUUID(), kind: "bug", surface: "stack", title: "A separately drafted report", impact: "blocked", correlation_id: sourceRun });
|
|
60
|
+
const duplicateExit = await cmdFeedback(["submit", "--file", "report.json"], duplicateDeps);
|
|
61
|
+
assert.equal(duplicateExit, 1, `duplicate-source receipt: ${out.at(-1)}`);
|
|
62
|
+
const duplicate = JSON.parse(out.pop());
|
|
63
|
+
assert.equal(duplicate.error, "source_already_reported");
|
|
64
|
+
assert.equal(duplicate.feedback_id, capturedId);
|
|
65
|
+
assert.equal(duplicate.submitted_context_saved, false);
|
|
66
|
+
assert.deepEqual(duplicate.enrich_argv, ["bb", "feedback", "enrich", "--feedback-id", capturedId, "--file", "report.json"]);
|
|
67
|
+
assert.equal(await cmdFeedback(["recover"], deps()), 0);
|
|
68
|
+
assert.equal(JSON.parse(out.pop()).complete, true);
|
|
69
|
+
// Lose the first reply after the real MCP handler commits; the private outbox
|
|
70
|
+
// must replay the same key only after real session whoami confirms its owner.
|
|
71
|
+
const root = await mkdtemp(join(tmpdir(), "bb-feedback-live-replay-"));
|
|
72
|
+
const queueKey = randomUUID(), fixtureToken = "fixture-session-token";
|
|
73
|
+
let dropResponse = true;
|
|
74
|
+
const outboxDeps = {
|
|
75
|
+
...deps(), cwd: root, rootFor: async () => root,
|
|
76
|
+
loadState: async () => ({ agent_id: process.argv[3], tenant: "botbuddy", agent_session_token: fixtureToken, harness_session_id: harnessSessionIdFromEnv() }),
|
|
77
|
+
resolveAuth: async () => ({ auth: { "x-agent-api-key": fixtureToken }, sessionToken: true }),
|
|
78
|
+
read: async () => JSON.stringify({ idempotency_key: queueKey, kind: "bug", surface: "cli", title: "Offline replay fixture", impact: "slowed", evidence: [{ type: "url", value: evidenceLink }] }),
|
|
79
|
+
call: async (tool, args) => {
|
|
80
|
+
const response = await (await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tool, args, session: true }) })).json();
|
|
81
|
+
if (tool === "submit_feedback" && dropResponse) { dropResponse = false; return { ok: false, status: null, error: "lost_reply" }; }
|
|
82
|
+
return response;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
assert.equal(await cmdFeedback(["submit", "--file", "report.json"], outboxDeps), 5);
|
|
86
|
+
assert.equal(JSON.parse(out.pop()).queued_locally, true);
|
|
87
|
+
assert.equal(await cmdFeedback(["flush"], outboxDeps), 0);
|
|
88
|
+
assert.equal(JSON.parse(out.pop()).delivered, 1);
|
|
89
|
+
assert.deepEqual(await readdir(join(root, ".botbuddy", "feedback-outbox")), []);
|
|
90
|
+
dropResponse = true;
|
|
91
|
+
const duplicateKey = randomUUID();
|
|
92
|
+
outboxDeps.read = async () => JSON.stringify({ idempotency_key: duplicateKey, kind: "bug", surface: "stack", title: "Queued tunnel report", impact: "blocked", correlation_id: sourceRun });
|
|
93
|
+
assert.equal(await cmdFeedback(["submit", "--file", "report.json"], outboxDeps), 5);
|
|
94
|
+
assert.equal(JSON.parse(out.pop()).queued_locally, true);
|
|
95
|
+
assert.equal(await cmdFeedback(["flush"], outboxDeps), 1);
|
|
96
|
+
const queuedDuplicate = JSON.parse(out.pop());
|
|
97
|
+
assert.equal(queuedDuplicate.error, "source_already_reported");
|
|
98
|
+
assert.equal(queuedDuplicate.feedback_id, capturedId);
|
|
99
|
+
assert.equal(await cmdFeedback(["enrich", "--feedback-id", capturedId, "--file", queuedDuplicate.enrich_argv[6]], outboxDeps), 0);
|
|
100
|
+
out.pop();
|
|
101
|
+
assert.equal(await cmdFeedback(["flush"], outboxDeps), 0);
|
|
102
|
+
assert.equal(JSON.parse(out.pop()).delivered, 1);
|
|
103
|
+
process.stdout.write("feedback CLI round-trip passed\n");
|