@botbuddy/cli 1.64.13 → 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 +10 -2
- 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/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
|
@@ -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);
|
|
@@ -316,11 +321,14 @@ ${bold("DURABLE WORKLOADS")} (${cyan("bb help run")})
|
|
|
316
321
|
Launch a receipt-bearing command under a detached owner
|
|
317
322
|
run --foreground --kind <kind> --environment <env> -- <command>
|
|
318
323
|
Run locally with a durable lifecycle outbox
|
|
324
|
+
run status <run_id> Read status and exact available log or receipt commands
|
|
319
325
|
run inspect|cancel <run_id> Read or stop one command run
|
|
320
326
|
telemetry <status|replay|doctor>
|
|
321
327
|
Inspect, replay, or gate durable lifecycle delivery
|
|
322
328
|
|
|
323
329
|
${bold("EVIDENCE")} (${cyan("bb help evidence")})
|
|
330
|
+
feedback init|submit --file <report.json>
|
|
331
|
+
Prepare or send a BotBuddy issue report
|
|
324
332
|
evidence require <file> Record an exact-head evidence disposition → botbuddy/ready
|
|
325
333
|
evidence publish <file> Publish a local evidence statement → botbuddy/ready check-run
|
|
326
334
|
|
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");
|