@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.
@@ -0,0 +1,269 @@
1
+ // BOT-2887: explicit feedback submission. The caller's file is the durable
2
+ // retry source; the idempotency key must be inside it before the first send.
3
+ import { lstat, mkdir, open, readFile, unlink, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { dirname, join } from "node:path";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { CALL_EXIT, callToolJson, resolveCallAuth } from "./api.mjs";
8
+ import { containsFeedbackSecret } from "./feedback-safety.mjs";
9
+ import { restrictWindowsAcl, restrictWindowsAclDir, sessionPathIsConfidential } from "./agent-state-fs.mjs";
10
+ import { cacheBelongsToHarness, findAgentStateRoot, harnessSessionIdFromEnv, readAgentState } from "./agent-state.mjs";
11
+ import { hasPendingFeedback, queueFeedback, withFeedbackOutbox } from "./feedback-outbox.mjs";
12
+ import { CONDITION_TYPES } from "./wait-core.mjs";
13
+
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
16
+ const OPTIONAL_TEXT = new Set(["goal", "steps", "expected", "actual", "blocked_reason", "blocking_step", "safe_next_action", "environment", "operation", "component_version", "error_code", "correlation_id", "workaround", "cause_confidence", "ticket_ref"]);
17
+ const USAGE = `Usage: bb feedback init --file <report.json>
18
+ bb feedback init --file <report.json> --from-run <run_id>
19
+ bb feedback submit --file <report.json>
20
+ bb feedback enrich --feedback-id <id> --file <report.json>
21
+ bb feedback status --feedback-id <id>
22
+ bb feedback updates --feedback-id <id> [--after <revision>]
23
+ bb feedback acknowledge --feedback-id <id> --revision <revision>
24
+ bb feedback flush
25
+ bb feedback recover
26
+
27
+ init creates a private starter file without overwriting an existing file.
28
+ --from-run adds an exact status command and a bounded status observation from
29
+ the original command run. It never copies raw logs or restarts the workload.
30
+ Fill in the fields before submitting it. The JSON report must contain a stable
31
+ UUID idempotency_key, kind, surface, title and impact. Set kind to bug,
32
+ feature_request, improvement, friction, docs_gap or unknown; surface to mcp, cli, stack,
33
+ wait, web, helper, docs or other; and impact to blocked, workaround, slowed,
34
+ cosmetic or unknown. Unknown values keep the report in needs_context. Include your goal, steps, expected and actual outcomes,
35
+ evidence, why you are blocked and what you can safely do next. Keep the file
36
+ and its idempotency_key unchanged if delivery is uncertain; retry using the
37
+ same reporter and tenant. Tenant identity sharing follows the server's policy;
38
+ a report cannot override it.
39
+
40
+ An unavailable service does not mean a report was recorded. A failed send is
41
+ queued privately only when the original agent and tenant can be bound to the
42
+ cached session. Run bb feedback flush to replay those drafts after reconnecting.
43
+ Unbound drafts stay in their original file for a deliberate retry.`;
44
+
45
+ export async function cmdFeedback(args, { call = callToolJson, resolveAuth = resolveCallAuth, read = readFile, write = writeFile, makeDir = mkdir, uuid = randomUUID, inspectStatus = async (id) => (await import("./run.mjs")).statusRun(id), cwd = process.cwd(), loadState = readAgentState, rootFor = findAgentStateRoot, queue = queueFeedback, outbox = withFeedbackOutbox, hasPending = hasPendingFeedback, waitTypes = CONDITION_TYPES, platform = process.platform, restrictAcl = restrictWindowsAcl, restrictAclDir = restrictWindowsAclDir, confidential = sessionPathIsConfidential, log = console.log, errorLog = console.error } = {}) {
46
+ if (args.length === 1 && ["help", "--help", "-h"].includes(args[0])) { log(USAGE); return CALL_EXIT.OK; }
47
+ const cachedBinding = async (resolved) => {
48
+ const state = await loadState(cwd);
49
+ if (!state || !cacheBelongsToHarness(state, harnessSessionIdFromEnv()) || resolved?.sessionToken !== true ||
50
+ resolved.auth?.["x-agent-api-key"] !== state.agent_session_token || !UUID.test(state.agent_id ?? "") ||
51
+ !/^[a-z0-9][a-z0-9-]{0,63}$/.test(state.tenant ?? "")) return null;
52
+ return { agentId: state.agent_id, tenant: state.tenant };
53
+ };
54
+ if (args.length === 1 && args[0] === "flush") {
55
+ let delivered = 0;
56
+ const receipts = [];
57
+ const remember = (id) => { delivered++; receipts.push({ feedback_id: id, status_command: `bb feedback status --feedback-id ${id}`, updates_command: `bb feedback updates --feedback-id ${id}` }); };
58
+ try {
59
+ const resolved = await resolveAuth({ toolName: "submit_feedback", preferSession: true });
60
+ const binding = await cachedBinding(resolved);
61
+ if (!binding) { errorLog("Feedback replay requires the original worktree's bound agent session and tenant. Drafts remain queued."); return CALL_EXIT.AUTH; }
62
+ const identity = await call("whoami", {}, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
63
+ if (!identity?.ok || identity.isError || identity.data?.credential_kind !== "agent_session" || identity.data?.agent?.id !== binding.agentId || identity.data?.tenant_id !== binding.tenant) {
64
+ errorLog("Feedback replay could not verify the original reporter and tenant. Drafts remain queued."); return CALL_EXIT.AUTH;
65
+ }
66
+ const root = await rootFor(cwd);
67
+ const result = await outbox(root, async (items, io) => {
68
+ for (const item of items) {
69
+ const record = await io.read(item);
70
+ if (record.agent_id !== binding.agentId || record.tenant !== binding.tenant) return { ok: false, error: "outbox_identity_mismatch", delivered, receipts, remaining: true };
71
+ const response = await call("submit_feedback", record.report, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
72
+ const data = response?.data;
73
+ if (response?.ok && !response.isError && (response.status == null || (response.status >= 200 && response.status < 300)) && data?.ok === true && data.source_duplicate === true && typeof data.feedback_id === "string" && UUID.test(data.feedback_id)) {
74
+ const current = await call("get_feedback", { feedback_id: data.feedback_id }, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
75
+ const intended = Object.entries(record.report).filter(([key]) => key !== "idempotency_key");
76
+ if (current?.ok && !current.isError && (current.status == null || (current.status >= 200 && current.status < 300)) && current.data?.ok === true && current.data.feedback_id === data.feedback_id &&
77
+ current.data.report && intended.every(([key, value]) => isDeepStrictEqual(current.data.report[key], value))) {
78
+ remember(data.feedback_id); await io.remove(item); continue;
79
+ }
80
+ const draft = await io.exportDraft(record);
81
+ const argv = ["bb", "feedback", "enrich", "--feedback-id", data.feedback_id, "--file", draft];
82
+ return { ok: false, error: "source_already_reported", delivered, receipts, remaining: true, feedback_id: data.feedback_id,
83
+ status_command: `bb feedback status --feedback-id ${data.feedback_id}`, enrich_argv: argv,
84
+ enrich_command: argv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") };
85
+ }
86
+ if (!response?.ok || response.isError || (response.status != null && (response.status < 200 || response.status >= 300)) || data?.ok !== true ||
87
+ typeof data.feedback_id !== "string" || !UUID.test(data.feedback_id) || typeof data.occurrence_id !== "string" || !UUID.test(data.occurrence_id) || !["complete", "needs_context"].includes(data.capture_state)) {
88
+ return { ok: false, error: "delivery_unconfirmed", delivered, receipts, remaining: true,
89
+ ...(typeof data?.feedback_id === "string" && UUID.test(data.feedback_id) ? { feedback_id: data.feedback_id, status_command: `bb feedback status --feedback-id ${data.feedback_id}` } : {}) };
90
+ }
91
+ remember(data.feedback_id);
92
+ await io.remove(item);
93
+ }
94
+ return { ok: true, delivered, receipts, remaining: io.more, ...(io.more ? { continue_command: "bb feedback flush" } : {}) };
95
+ });
96
+ log(JSON.stringify(result));
97
+ if (!result.ok) errorLog("Feedback replay stopped without deleting the unconfirmed draft.");
98
+ return result.ok ? CALL_EXIT.OK : CALL_EXIT.ERROR;
99
+ } catch {
100
+ log(JSON.stringify({ ok: false, error: "replay_unavailable", delivered, receipts, remaining: true, retry_command: "bb feedback flush" }));
101
+ errorLog("Feedback replay stopped. Check the receipts and retry: bb feedback flush");
102
+ return CALL_EXIT.ERROR;
103
+ }
104
+ }
105
+ if (args.length === 1 && args[0] === "recover") {
106
+ let outboxRecovery = null;
107
+ try {
108
+ if (await hasPending(await rootFor(cwd))) {
109
+ let receipt = null;
110
+ const code = await cmdFeedback(["flush"], { call, resolveAuth, read, write, makeDir, uuid, inspectStatus, cwd, loadState, rootFor, queue, outbox, hasPending,
111
+ log: (line) => { try { receipt = JSON.parse(line); } catch { /* retain unknown */ } }, errorLog: () => {} });
112
+ outboxRecovery = { ok: code === 0 && receipt?.ok === true, delivered: receipt?.delivered ?? null, receipts: receipt?.receipts ?? null,
113
+ remaining: code !== 0 || receipt?.remaining === true, ...(code !== 0 || receipt?.remaining === true ? { retry_command: "bb feedback flush" } : {}),
114
+ ...(receipt?.error === "source_already_reported" ? { error: receipt.error, feedback_id: receipt.feedback_id, status_command: receipt.status_command, enrich_argv: receipt.enrich_argv, enrich_command: receipt.enrich_command } : {}) };
115
+ }
116
+ } catch { outboxRecovery = { ok: false, delivered: null, receipts: null, remaining: true, retry_command: "bb feedback flush" }; }
117
+ let result;
118
+ try {
119
+ const resolved = await resolveAuth({ toolName: "recover_feedback_sources", preferSession: true });
120
+ result = resolved?.error || !resolved?.auth ? { ok: false, auth: true } : await call("recover_feedback_sources", {}, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
121
+ } catch { result = { ok: false, status: null }; }
122
+ const data = result?.data;
123
+ if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || data?.ok !== true || !Number.isInteger(data.captured) || data.captured < 0 || data.captured > 5 || typeof data.complete !== "boolean" || !["none", "more_sources", "retry_later"].includes(data.remaining) || data.complete !== (data.remaining === "none")) {
124
+ if (outboxRecovery) log(JSON.stringify({ ok: false, cloud_recovery: "unconfirmed", outbox_recovery: outboxRecovery, all_complete: false, retry_command: "bb feedback recover" }));
125
+ errorLog("Feedback recovery was not confirmed. Preserve the original run; retry bb feedback recover using the same reporter and tenant.");
126
+ return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
127
+ }
128
+ log(JSON.stringify({ ...data, ...(outboxRecovery ? { outbox_recovery: outboxRecovery, all_complete: data.complete && outboxRecovery.ok && !outboxRecovery.remaining } : {}), list_command: "bb call list_feedback", ...(data.remaining === "more_sources" ? { continue_command: "bb feedback recover" } : {}), ...(data.remaining === "retry_later" ? { retry_command: "bb feedback recover" } : {}) }));
129
+ return outboxRecovery && !outboxRecovery.ok ? CALL_EXIT.ERROR : CALL_EXIT.OK;
130
+ }
131
+ if (["status", "updates", "acknowledge"].includes(args[0])) {
132
+ const kind = args[0];
133
+ const validId = args[1] === "--feedback-id" && UUID.test(args[2] ?? "");
134
+ const cursorText = args[4];
135
+ const validCursor = typeof cursorText === "string" && /^(0|[1-9][0-9]*)$/.test(cursorText) && Number.isSafeInteger(Number(cursorText)) && Number(cursorText) <= 2147483647;
136
+ const valid = validId && (kind === "status" ? args.length === 3 : kind === "updates" ? args.length === 3 || (args.length === 5 && args[3] === "--after" && validCursor) : args.length === 5 && args[3] === "--revision" && validCursor && Number(cursorText) > 0);
137
+ if (!valid) { errorLog(USAGE); return 2; }
138
+ const tool = kind === "status" ? "get_feedback" : kind === "updates" ? "list_feedback_updates" : "acknowledge_feedback_update";
139
+ const input = { feedback_id: args[2], ...(kind === "updates" && args.length === 5 ? { after_revision: Number(cursorText) } : {}), ...(kind === "acknowledge" ? { revision: Number(cursorText) } : {}) };
140
+ let result;
141
+ try {
142
+ const resolved = await resolveAuth({ toolName: tool, preferSession: true });
143
+ result = resolved?.error || !resolved?.auth ? { ok: false, auth: true } : await call(tool, input, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
144
+ } catch { result = { ok: false, status: null }; }
145
+ const data = result?.data;
146
+ const receiptValid = data?.ok === true && typeof data.feedback_id === "string" && data.feedback_id.toLowerCase() === args[2].toLowerCase() && (kind === "status" || (kind === "updates" ? Array.isArray(data.items) && data.items.every((item) => Number.isInteger(item?.revision) && item.revision > 0 && item.revision <= 2147483647) && typeof data.has_more === "boolean" && Number.isInteger(data.next_after_revision) && data.next_after_revision >= 0 && data.next_after_revision <= 2147483647 : data.revision === Number(cursorText) && typeof data.acknowledged_at === "string"));
147
+ if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || !receiptValid) {
148
+ errorLog(kind === "acknowledge" ? "Acknowledgement was not confirmed; check updates before retrying." : "Feedback status is unavailable; retry using the same reporter and tenant.");
149
+ return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
150
+ }
151
+ if (kind === "updates") {
152
+ const items = data.items.map((item) => ({ ...item, acknowledge_command: `bb feedback acknowledge --feedback-id ${args[2]} --revision ${item.revision}` }));
153
+ const next = data.has_more ? `bb feedback updates --feedback-id ${args[2]} --after ${data.next_after_revision}` : null;
154
+ const waitCommand = !data.has_more && waitTypes.includes("feedback") ? `bb wait 'feedback:id=${args[2].toLowerCase()},after=${data.next_after_revision}'` : null;
155
+ log(JSON.stringify({ ...data, items, next_command: next, ...(waitCommand ? { after_consuming_wait_command: waitCommand } : {}) }));
156
+ } else log(JSON.stringify(data));
157
+ return CALL_EXIT.OK;
158
+ }
159
+ const enrich = args.length === 5 && args[0] === "enrich" && args[1] === "--feedback-id" && UUID.test(args[2] ?? "") && args[3] === "--file";
160
+ const fromRun = args.length === 5 && args[0] === "init" && args[1] === "--file" && args[3] === "--from-run" && RUN_ID.test(args[4] ?? "");
161
+ const filename = enrich ? args[4] : args[2];
162
+ if ((!enrich && !fromRun && (args.length !== 3 || !["init", "submit"].includes(args[0]) || args[1] !== "--file")) || !filename || filename.startsWith("-")) {
163
+ errorLog(USAGE); return 2;
164
+ }
165
+ if (args[0] === "init") {
166
+ const template = { idempotency_key: uuid(), kind: "", surface: "", title: "", impact: "", goal: "", steps: "", expected: "", actual: "", blocked_reason: "", blocking_step: "", can_continue: null, safe_next_action: "", ticket_ref: "", evidence: [] };
167
+ if (fromRun) {
168
+ let status;
169
+ try { status = await inspectStatus(args[4]); } catch { errorLog("The original run status is unavailable; no feedback file was created. Preserve the original run and retry."); return CALL_EXIT.TRANSPORT; }
170
+ const allowed = ["running", "completed", "completed_locally_remote_conflict", "completed_locally_remote_stale", "completed_locally_remote_unknown", "unknown"];
171
+ if (!status || status.run_id !== args[4] || !allowed.includes(status.classification)) { errorLog("The original run status could not be verified; no feedback file was created."); return CALL_EXIT.ERROR; }
172
+ template.surface = "cli";
173
+ template.operation = "bb run";
174
+ template.correlation_id = args[4];
175
+ const local = status.local;
176
+ const localOutcome = status.source_bound === true && ["success", "failure", "timed_out", "interrupted", "cancelled"].includes(local?.status)
177
+ ? ` Local outcome: ${local.status}${Number.isSafeInteger(local.exit_code) && local.exit_code >= 0 && local.exit_code <= 255 ? ` (exit ${local.exit_code})` : ""}.`
178
+ : "";
179
+ template.actual = `Original command run status: ${status.classification}.${localOutcome}`;
180
+ template.evidence = [{ type: "command", value: `bb run status ${args[4]}` }];
181
+ const counts = status.test_counts;
182
+ if (counts && typeof counts === "object" && [counts.passed, counts.failed, counts.remaining].every((value) => value === null || (Number.isSafeInteger(value) && value >= 0)) && (counts.passed !== null || counts.failed !== null)) {
183
+ template.evidence.push({ type: "text", value: `Observed test counts: passed=${counts.passed ?? "unknown"}, failed=${counts.failed ?? "unknown"}, remaining=${counts.remaining ?? "unknown"}. Counts are observations and retries may change them.` });
184
+ }
185
+ }
186
+ if (platform === "win32") {
187
+ // A mode bit does not protect Windows files. The parent must be private
188
+ // before path-based ACL checks, so another user cannot replace the file.
189
+ // Do not change ACLs of an existing user-selected directory.
190
+ let handle, created = false;
191
+ try {
192
+ const parent = dirname(filename);
193
+ const made = await makeDir(parent, { recursive: true, mode: 0o700 });
194
+ if (made) await restrictAclDir(parent);
195
+ const info = await lstat(parent);
196
+ if (!info.isDirectory() || info.isSymbolicLink() || !(await confidential(parent, info.mode, { platform }))) throw new Error("directory_not_private");
197
+ handle = await open(filename, "wx", 0o600); created = true;
198
+ await restrictAcl(filename);
199
+ const fileInfo = await handle.stat();
200
+ if (!(await confidential(filename, fileInfo.mode, { platform }))) throw new Error("file_not_private");
201
+ await handle.writeFile(JSON.stringify(template, null, 2) + "\n");
202
+ await handle.sync();
203
+ await handle.close(); handle = null;
204
+ } catch {
205
+ if (handle) await handle.close().catch(() => {});
206
+ if (created) await unlink(filename).catch(() => {});
207
+ const retryPath = join(".botbuddy", `feedback-${template.idempotency_key}`, "report.json");
208
+ const retry = `bb feedback init --file "${retryPath}"${fromRun ? ` --from-run ${args[4]}` : ""}`;
209
+ errorLog(`Could not create a private feedback file. Existing files are never overwritten. Retry in a new private directory: ${retry}`); return 2;
210
+ }
211
+ } else {
212
+ try { await makeDir(dirname(filename), { recursive: true, mode: 0o700 }); await write(filename, JSON.stringify(template, null, 2) + "\n", { flag: "wx", mode: 0o600 }); }
213
+ catch { errorLog("Could not create the feedback file; an existing file is never overwritten."); return 2; }
214
+ }
215
+ const argv = ["bb", "feedback", "submit", "--file", filename];
216
+ log(JSON.stringify({ ok: true, file: filename, state: "needs_context", submit_argv: argv, submit_command: argv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") }));
217
+ return CALL_EXIT.OK;
218
+ }
219
+ let report;
220
+ try { report = JSON.parse(await read(filename, "utf8")); }
221
+ catch { errorLog("Could not read the feedback file as JSON; the file was not submitted."); return 2; }
222
+ if (!report || typeof report !== "object" || Array.isArray(report) || typeof report.idempotency_key !== "string" || !UUID.test(report.idempotency_key)) {
223
+ errorLog("The feedback file must contain a stable UUID idempotency_key; the file was not submitted."); return 2;
224
+ }
225
+ // Blank template slots represent missing context. The server accepts omitted
226
+ // fields for partial capture, but rejects explicitly blank strings.
227
+ report = Object.fromEntries(Object.entries(report).filter(([key, value]) => !(OPTIONAL_TEXT.has(key) && typeof value === "string" && !value.trim()) && !(key === "can_continue" && value === null)));
228
+ if (containsFeedbackSecret(report)) {
229
+ errorLog("The feedback file contains a possible credential, sensitive header or signed URL; remove it before sending.");
230
+ return 2;
231
+ }
232
+ let result, resolved;
233
+ const tool = enrich ? "enrich_feedback" : "submit_feedback";
234
+ const patch = { ...report };
235
+ delete patch.idempotency_key;
236
+ try {
237
+ resolved = await resolveAuth({ toolName: tool, preferSession: true });
238
+ result = resolved?.error || !resolved?.auth ? { ok: false, auth: true, error: "not_authenticated" } : await call(tool, enrich ? { feedback_id: args[2], patch } : report, { auth: resolved.auth, signal: AbortSignal.timeout(15_000) });
239
+ }
240
+ catch { result = { ok: false, status: null, error: "transport_unavailable" }; }
241
+ if (!result?.ok || (result.status != null && (result.status < 200 || result.status >= 300)) || result.isError || result.data?.ok !== true || typeof result.data.feedback_id !== "string" || !UUID.test(result.data.feedback_id) ||
242
+ typeof result.data.occurrence_id !== "string" || !UUID.test(result.data.occurrence_id) || !["complete", "needs_context"].includes(result.data.capture_state)) {
243
+ const reported = result?.auth ? "authentication_failed" : result?.data?.error ?? result?.code ?? result?.error;
244
+ const code = typeof reported === "string" && /^[A-Za-z0-9_.:-]{1,80}$/.test(reported) ? reported : "feedback_receipt_unavailable";
245
+ const retryArgv = enrich ? null : ["bb", "feedback", "submit", "--file", filename];
246
+ const retryCommand = retryArgv ? retryArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") : null;
247
+ const existingId = code === "source_already_reported" && typeof result?.data?.existing_feedback_id === "string" && UUID.test(result.data.existing_feedback_id) ? result.data.existing_feedback_id : null;
248
+ const existingEnrichArgv = existingId ? ["bb", "feedback", "enrich", "--feedback-id", existingId, "--file", filename] : null;
249
+ let queued = null, outboxReason = null;
250
+ if (!enrich && !result?.auth && !result?.isError && (result?.status == null || result.status >= 500)) {
251
+ try { const binding = await cachedBinding(resolved); if (binding) queued = await queue({ root: await rootFor(cwd), ...binding, report }); else outboxReason = "reporter_identity_unavailable"; }
252
+ catch (error) { outboxReason = typeof error?.message === "string" && /^outbox_[a-z_]+$/.test(error.message) ? error.message : "outbox_unavailable"; }
253
+ }
254
+ log(JSON.stringify({ ok: false, error: code, recorded: existingId ? "not_changed" : "unknown", retry_source: filename, retry_argv: retryArgv, retry_command: retryCommand,
255
+ ...(queued ? { queued_locally: true, flush_command: queued.flush_command } : {}),
256
+ ...(outboxReason ? { queued_locally: false, outbox_reason: outboxReason } : {}),
257
+ ...(existingEnrichArgv ? { existing_feedback_id: existingId, enrich_argv: existingEnrichArgv, enrich_command: existingEnrichArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") } : {}),
258
+ ...(result?.auth ? { diagnostic_argv: ["bb", "doctor"], diagnostic_command: "bb doctor" } : {}) }));
259
+ errorLog(existingId ? "This source already has another report. The update was not applied; use the exact enrich command in the receipt to update that report." : enrich ? "The update was not confirmed. Read the feedback before deciding whether to apply it again." : "Feedback was not confirmed. Preserve the file and idempotency_key; retry using the same reporter and tenant.");
260
+ return result?.auth ? CALL_EXIT.AUTH : !result?.ok && result?.status == null ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
261
+ }
262
+ const sourceDuplicate = result.data.source_duplicate === true && result.data.submitted_context_saved === false;
263
+ const enrichArgv = result.data.capture_state === "needs_context" || sourceDuplicate ? ["bb", "feedback", "enrich", "--feedback-id", result.data.feedback_id, "--file", filename] : null;
264
+ const statusArgv = ["bb", "feedback", "status", "--feedback-id", result.data.feedback_id];
265
+ const updatesArgv = ["bb", "feedback", "updates", "--feedback-id", result.data.feedback_id];
266
+ log(JSON.stringify({ ...result.data, ...(sourceDuplicate ? { ok: false, error: "source_already_reported" } : {}), status_argv: statusArgv, status_command: statusArgv.join(" "), updates_argv: updatesArgv, updates_command: updatesArgv.join(" "), ...(enrichArgv ? { enrich_argv: enrichArgv, enrich_command: enrichArgv.map((value) => `'${value.replaceAll("'", "'\\''")}'`).join(" ") } : {}) }));
267
+ if (sourceDuplicate) errorLog("This source already has feedback. The new file was not applied; use the exact enrich command in the receipt to add its context to the existing report.");
268
+ return sourceDuplicate ? CALL_EXIT.ERROR : CALL_EXIT.OK;
269
+ }
package/src/locks.mjs CHANGED
@@ -152,3 +152,108 @@ export function buildAcquireResourcesPayload(args, options = {}) {
152
152
  ...(environment ? { environment } : {}),
153
153
  };
154
154
  }
155
+
156
+ // BOT-2994: `bb lock`, `bb locks` and `bb unlock` exit by what the server did, not
157
+ // by whether it answered. Before this every one of them exited 0, so a shell loop
158
+ // that trusted the exit code drove another agent's Playwright lane (ENT-6416).
159
+ // 0 = acquired (or released / withdrawn); 2 = queued behind a holder (you hold
160
+ // nothing yet; wait for the grant or withdraw); 1 = refused or not understood.
161
+ // Auth (3) and transport (5) exits from callTool pass through unchanged.
162
+ export const LOCK_EXIT = Object.freeze({ ACQUIRED: 0, REFUSED: 1, QUEUED: 2 });
163
+
164
+ function parseJson(text) {
165
+ try { return JSON.parse(text); } catch { return undefined; }
166
+ }
167
+
168
+ function toolText(response) {
169
+ const content = response?.result?.content;
170
+ if (!Array.isArray(content)) return null;
171
+ const text = content.map((part) => part?.text ?? "").join("\n");
172
+ return text || null;
173
+ }
174
+
175
+ /**
176
+ * Classify a lock tool response (acquire_lock, acquire_resources, release_lock,
177
+ * retire_queued_lock_attempt) as returned by callTool: the JSON-RPC body, or a
178
+ * numeric exit when callTool already classified an auth/transport failure.
179
+ * Fails closed: an error flag, `success: false`, or an unreadable result is
180
+ * never "acquired".
181
+ */
182
+ export function classifyLockResponse(response) {
183
+ if (typeof response === "number") return { exit: response, results: [] };
184
+ const isError = response?.result?.isError === true;
185
+ const text = toolText(response);
186
+ if (text === null) return { exit: LOCK_EXIT.REFUSED, results: [] };
187
+ const parsed = parseJson(text);
188
+ let results;
189
+ if (parsed && Array.isArray(parsed.results)) {
190
+ results = parsed.results.map((r) => ({ ...r, success: r?.success === true }));
191
+ } else if (parsed && typeof parsed === "object" && "success" in parsed) {
192
+ results = [{ ...parsed, resource_name: parsed.resource_name ?? parsed.resource, success: parsed.success === true && !isError }];
193
+ } else {
194
+ // The human success string (acquire_lock / release_lock) or a plain error text.
195
+ results = [{ success: !isError, message: text }];
196
+ }
197
+ const failed = results.filter((r) => !r.success);
198
+ let exit = LOCK_EXIT.ACQUIRED;
199
+ if (failed.some((r) => r.queued !== true)) exit = LOCK_EXIT.REFUSED;
200
+ else if (failed.length) exit = LOCK_EXIT.QUEUED;
201
+ else if (isError || !results.length) exit = LOCK_EXIT.REFUSED;
202
+ return { exit, results };
203
+ }
204
+
205
+ function holderLabel(holder) {
206
+ if (!holder || typeof holder !== "object") return null;
207
+ const who = holder.agent ?? holder.agent_name ?? holder.name ?? null;
208
+ if (!who) return null;
209
+ const ticket = holder.ticket_id ? `, ${holder.ticket_id}` : "";
210
+ return `${who}${ticket}`;
211
+ }
212
+
213
+ // `bb wait 'lock:…,claim=true'` only addresses typed resources
214
+ // (`<subtype>:<host>:<slot>`); an untyped name gets no wait line.
215
+ function claimWaitFor(name) {
216
+ const parts = typeof name === "string" ? name.split(":") : [];
217
+ if (parts.length !== 3 || parts.some((p) => !p)) return null;
218
+ const [subtype, host, slot] = parts;
219
+ return `bb wait 'lock:subtype=${subtype},host=${host},slot=${slot},claim=true' --heartbeat`;
220
+ }
221
+
222
+ /** The stderr lines that tell the caller what it holds and what to do next. */
223
+ export function lockOutcomeLines({ exit, results }) {
224
+ if (exit === LOCK_EXIT.ACQUIRED || exit > LOCK_EXIT.QUEUED) return [];
225
+ const lines = [`✗ Lock not acquired (exit ${exit}: ${exit === LOCK_EXIT.QUEUED ? "queued" : "refused"}). Do not use ${results.length > 1 ? "these resources" : "this resource"} until a lock command exits 0.`];
226
+ for (const r of results.filter((x) => !x.success)) {
227
+ const name = r.resource_name ?? r.resource ?? "resource";
228
+ const holder = holderLabel(r.holder);
229
+ if (r.queued === true) {
230
+ lines.push(` ${name}: queued${holder ? ` behind ${holder}` : ""}${r.queue_depth != null ? ` (queue depth ${r.queue_depth})` : ""}.`);
231
+ const wait = claimWaitFor(name);
232
+ if (wait) lines.push(` wait to be handed it: ${wait}`);
233
+ if (r.resource_id && r.resource_lock_id) lines.push(` or withdraw the request: bb unlock --queued ${r.resource_id} ${r.resource_lock_id}`);
234
+ } else {
235
+ lines.push(` ${name}: refused${holder ? ` (held by ${holder})` : ""}${r.message ? ` — ${r.message}` : ""}`);
236
+ }
237
+ }
238
+ const held = results.filter((r) => r.success && r.resource_name);
239
+ if (held.length && results.length > 1) {
240
+ lines.push(" Acquired in this batch (release what you will not use):");
241
+ for (const r of held) lines.push(` bb unlock ${r.resource_name}`);
242
+ }
243
+ return lines;
244
+ }
245
+
246
+ /** `bb unlock` refused: say how a queued caller withdraws instead. */
247
+ export function unlockOutcomeLines({ exit, results }, { queued = false } = {}) {
248
+ if (exit === LOCK_EXIT.ACQUIRED || exit > LOCK_EXIT.QUEUED) return [];
249
+ // The first line is the server's reason; later lines are generic guidance.
250
+ const why = results.find((r) => r.message)?.message?.split("\n")[0];
251
+ if (queued) return [`✗ Queued request not withdrawn${why ? `: ${why}` : ""}.`];
252
+ return [
253
+ `✗ Lock not released${why ? `: ${why}` : ""}.`,
254
+ " bb unlock <name> releases a lock you hold; it does not withdraw a queued request.",
255
+ " To withdraw one, use the ids from the queued lock result: bb unlock --queued <resource_id> <resource_lock_id>",
256
+ ];
257
+ }
258
+
259
+ export const UNLOCK_QUEUED_USAGE = "Usage: botbuddy unlock --queued <resource_id> <resource_lock_id>";
package/src/pw/run.mjs CHANGED
@@ -92,7 +92,7 @@ async function gate({ env, host, lane, deps }) {
92
92
  // callerIds/holder let staging-auth cleanup touch only what this caller installed.
93
93
  const callerIds = [...selfAgentIds];
94
94
  if (status.held && selfAgentIds.has(status.heldBy)) return { allowed: true, coordinator, canonicalHost, holderMatch: "self", holder: status.heldBy, callerIds };
95
- if (status.held && status.ownerIsCaller === true) return { allowed: true, coordinator, canonicalHost, holderMatch: "owner", holder: status.heldBy ?? null, callerIds };
95
+ if (status.held && status.ownerIsCaller === true) return { allowed: true, coordinator, canonicalHost, holderMatch: "owner", holder: status.heldBy ?? null, holderName: status.heldByName ?? null, callerIds };
96
96
  // Distinguish an expired/free lane from one another operator holds so the
97
97
  // operator knows whether to re-acquire vs. wait for a handover (BOT-660). Name it
98
98
  // by the server's canonical host so a not-held message matches the dashboard even
@@ -296,6 +296,23 @@ async function runPwInner(argv, deps = {}) {
296
296
  // Report the host the lock service actually stored (alias-collapsed), so the
297
297
  // status view and lane telemetry match the server, not the local string (AC-4).
298
298
  const effHost = auth.canonicalHost ?? host;
299
+ // BOT-2994: a lane is driven only by the agent that holds it. The gate also
300
+ // passes a same-owner holder (BOT-1490 owner_is_caller), and every agent one
301
+ // person runs shares that owner, so an agent whose `bb lock` had only queued
302
+ // drove another agent's browser (ENT-6416, 2026-11-03). Resolve an owner-only
303
+ // match to the exact agent once (server whoami); only read-only `status` may
304
+ // proceed without it. `auth` keeps its own staging refusal receipt below.
305
+ let holderMatch = auth.holderMatch;
306
+ if (holderMatch === "owner" && auth.holder && authCtx.resolveCallerId) {
307
+ const me = await authCtx.resolveCallerId().catch(() => null);
308
+ if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
309
+ }
310
+ if (holderMatch === "owner" && plan.mode !== "status" && plan.mode !== "auth") {
311
+ const lane = `playwright_lane:${effHost}:${plan.lane}`;
312
+ const who = `${auth.holder ?? "unknown"}${auth.holderName ? ` (${auth.holderName})` : ""}`;
313
+ stderr.write(`bb-pw: lane lock ${lane} is held by ${who}, another agent session of your owner, not this agent session. A lane is driven only by the agent that holds it. Claim a lane from this session with bb locks -m --lane <n> --ticket <ticket> and use it only after that exits 0, or wait to be handed this one: bb wait 'lock:subtype=playwright_lane,host=${effHost},slot=${plan.lane},claim=true' --heartbeat\n`);
314
+ return 3;
315
+ }
299
316
  // BOT-1318: resolve the lane's browser-acceptance tracker AFTER the gate (a
300
317
  // refused/unverified lane writes no lifecycle) and never for unlocked local mode.
301
318
  let tracker = null;
@@ -312,11 +329,6 @@ async function runPwInner(argv, deps = {}) {
312
329
  if (tracker && envResolution.error) { stderr.write(`${envResolution.error}\n`); return 2; }
313
330
  const environment = envResolution.environment ?? null;
314
331
  if (plan.mode === "auth") {
315
- let holderMatch = auth.holderMatch;
316
- if (holderMatch === "owner" && auth.holder && authCtx.resolveCallerId) {
317
- const me = await authCtx.resolveCallerId().catch(() => null);
318
- if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
319
- }
320
332
  if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan, deps));
321
333
  if (tracker) {
322
334
  const browserSession = await tracker.status();