@botbuddy/cli 1.60.6 → 1.60.7
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/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/pw/run.mjs +9 -6
- package/src/pw/staging-auth.mjs +26 -4
- package/src/staging-pool/checkouts.mjs +120 -0
- package/src/staging-pool/cli.mjs +110 -44
- package/src/staging-pool/config.mjs +4 -1
- package/src/staging-pool/errors.mjs +84 -30
- package/src/staging-pool/health.mjs +2 -2
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.7",
|
|
3
|
+
"source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
|
|
4
|
+
"source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "1e24ee9c3fa9ac6447ff9f0c8096d80c4e2a98348d0e3b0277d406dfe8109e17"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.7",
|
|
3
|
+
"source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
|
|
4
|
+
"source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "68dfb56ddf1b4647dcd00644d88489fe2022b4136d72443e33f83a86ecb270bb"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.60.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.60.7",
|
|
4
|
+
"source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
|
|
5
|
+
"source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
|
|
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": "1e24ee9c3fa9ac6447ff9f0c8096d80c4e2a98348d0e3b0277d406dfe8109e17",
|
|
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": "68dfb56ddf1b4647dcd00644d88489fe2022b4136d72443e33f83a86ecb270bb",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/pw/run.mjs
CHANGED
|
@@ -190,7 +190,10 @@ function consumeWrapperFlags(list, deps, start = 0) {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
// The non-secret values a staging-auth refusal names in its next command.
|
|
193
|
-
const authVars = (plan
|
|
193
|
+
const authVars = (plan, deps = {}) => {
|
|
194
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
195
|
+
return { lane: plan.lane, persona: plan.persona, cwd, ticketFlag: stagingAuth.ticketFlagFor(cwd) };
|
|
196
|
+
};
|
|
194
197
|
|
|
195
198
|
// `bb pw auth --help` / `bb pw [flags] <lane> auth --help`: auth's own help, before
|
|
196
199
|
// any credential, lock or daemon call.
|
|
@@ -229,8 +232,8 @@ async function runPwInner(argv, deps = {}) {
|
|
|
229
232
|
// local mode — before any credential, lock or issuer call.
|
|
230
233
|
if (plan.mode === "auth") {
|
|
231
234
|
const authEnvironment = resolveEnvironment({ flag: deps.environment ?? null, env });
|
|
232
|
-
if (authEnvironment.error || authEnvironment.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_staging_environment", 2, {}, authVars(plan));
|
|
233
|
-
if (env.BB_PW_NO_LOCK === "1") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_lane_lock", 3, {}, authVars(plan));
|
|
235
|
+
if (authEnvironment.error || authEnvironment.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_staging_environment", 2, {}, authVars(plan, deps));
|
|
236
|
+
if (env.BB_PW_NO_LOCK === "1") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_lane_lock", 3, {}, authVars(plan, deps));
|
|
234
237
|
}
|
|
235
238
|
const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
|
|
236
239
|
if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
|
|
@@ -278,7 +281,7 @@ async function runPwInner(argv, deps = {}) {
|
|
|
278
281
|
// A lane that is provably no longer ours (not a transient verify error):
|
|
279
282
|
// clear the session we installed and revoke its custody (AC-3).
|
|
280
283
|
if (auth.reason === "not_held" || auth.reason === "held_by_other") await stagingAuth.onLaneAuthorityLost(authCtx).catch(() => {});
|
|
281
|
-
if (plan.mode === "auth" && (auth.reason === "not_held" || auth.reason === "held_by_other")) return stagingAuth.refuse({ stdout, stderr }, "lane_not_claimed", 3, { detail: auth.message }, authVars(plan));
|
|
284
|
+
if (plan.mode === "auth" && (auth.reason === "not_held" || auth.reason === "held_by_other")) return stagingAuth.refuse({ stdout, stderr }, "lane_not_claimed", 3, { detail: auth.message }, authVars(plan, deps));
|
|
282
285
|
stderr.write(`${auth.message}\n`); return 3;
|
|
283
286
|
}
|
|
284
287
|
// Report the host the lock service actually stored (alias-collapsed), so the
|
|
@@ -305,10 +308,10 @@ async function runPwInner(argv, deps = {}) {
|
|
|
305
308
|
const me = await authCtx.resolveCallerId().catch(() => null);
|
|
306
309
|
if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
|
|
307
310
|
}
|
|
308
|
-
if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan));
|
|
311
|
+
if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan, deps));
|
|
309
312
|
if (tracker) {
|
|
310
313
|
const browserSession = await tracker.status();
|
|
311
|
-
if (browserSession.active && browserSession.environment && browserSession.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "browser_session_not_staging", 2, {}, authVars(plan));
|
|
314
|
+
if (browserSession.active && browserSession.environment && browserSession.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "browser_session_not_staging", 2, {}, authVars(plan, deps));
|
|
312
315
|
}
|
|
313
316
|
return stagingAuth.runStagingAuth(authCtx);
|
|
314
317
|
}
|
package/src/pw/staging-auth.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// clear that fails blocks the lane until it is resolved.
|
|
16
16
|
// * The ledger (~/.botbuddy/pw-staging-auth/<host>/lane-<n>.json, 0600) holds
|
|
17
17
|
// non-secret install records only.
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
18
19
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
19
20
|
import { homedir } from "node:os";
|
|
20
21
|
import { dirname, join } from "node:path";
|
|
@@ -229,8 +230,24 @@ const GUIDED_FIELDS = ["message", "cause", "actor", "next", "doc", "help", "agen
|
|
|
229
230
|
* `extra` fields already rendered by the pool (an empty pool's health reason) win.
|
|
230
231
|
* `vars` are non-secret only: lane, persona alias, allowed aliases, checkout.
|
|
231
232
|
*/
|
|
233
|
+
/**
|
|
234
|
+
* ` --ticket BOT-123` from the checkout's branch name (`…/bot-123-…`), or "" so a
|
|
235
|
+
* lane-claim command never prints a <ticket> placeholder. Best effort, refusal-only.
|
|
236
|
+
*/
|
|
237
|
+
export function ticketFlagFor(cwd, { exec = execFileSync } = {}) {
|
|
238
|
+
try {
|
|
239
|
+
// symbolic-ref also names an unborn branch; a detached HEAD has no ticket.
|
|
240
|
+
const branch = String(exec("git", ["-C", cwd, "symbolic-ref", "--short", "-q", "HEAD"], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] })).trim();
|
|
241
|
+
const match = /(?:^|[/_-])((?:bot|ent)-\d+)(?=$|[/_-])/i.exec(branch);
|
|
242
|
+
return match ? ` --ticket ${match[1].toUpperCase()}` : "";
|
|
243
|
+
} catch {
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
232
248
|
export function refuse({ stdout, stderr }, code, exitCode = 3, extra = {}, vars = {}) {
|
|
233
|
-
|
|
249
|
+
// A real refusal: never a <placeholder> (AC-8 follow-up).
|
|
250
|
+
const g = guidance(isKnownCode(code) ? code : "pool_take_failed", { consequence: "bb pw auth has no pool to take a session from", ...vars }, { real: true });
|
|
234
251
|
const guided = { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help, agentAction: g.agent_action, remedy: remedyLine(g) };
|
|
235
252
|
for (const field of GUIDED_FIELDS) if (extra[field] !== undefined) guided[field] = extra[field];
|
|
236
253
|
const text = {
|
|
@@ -256,15 +273,20 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
|
|
|
256
273
|
const poolModule = deps.stagingPoolModule ?? await loadPool();
|
|
257
274
|
const poolOptions = deps.stagingPool ?? {};
|
|
258
275
|
const cwd = deps.cwd ?? process.cwd();
|
|
259
|
-
const vars = { lane: plan.lane, persona: plan.persona,
|
|
276
|
+
const vars = { lane: plan.lane, persona: plan.persona, cwd, ticketFlag: ticketFlagFor(cwd) };
|
|
260
277
|
let found;
|
|
261
278
|
try {
|
|
262
279
|
found = await (await loadBinding()).readRepoPoolBinding(cwd);
|
|
263
280
|
} catch (error) {
|
|
264
281
|
return refuse(io, error?.code === "pool_binding_invalid" ? "pool_binding_invalid" : "pool_not_configured", 3, { detail: String(error?.message ?? error) }, vars);
|
|
265
282
|
}
|
|
266
|
-
if (!found)
|
|
267
|
-
|
|
283
|
+
if (!found) {
|
|
284
|
+
// Name a stable checkout that declares a pool, if this Mac has one on record.
|
|
285
|
+
const { findKnownCheckouts } = await import("../staging-pool/checkouts.mjs");
|
|
286
|
+
const [candidate] = await findKnownCheckouts({ ...(poolOptions.home ? { home: poolOptions.home } : {}), ...(poolOptions.isStableCheckout ? { isStable: poolOptions.isStableCheckout } : {}) }).catch(() => []);
|
|
287
|
+
return refuse(io, "pool_not_configured", 3, {}, { ...vars, checkout: candidate?.path });
|
|
288
|
+
}
|
|
289
|
+
Object.assign(vars, { allowed: Object.keys(found.binding.personas ?? {}) });
|
|
268
290
|
const { findPersona } = await loadBinding();
|
|
269
291
|
const persona = findPersona(found.binding, plan.persona);
|
|
270
292
|
if (!persona) return refuse(io, "persona_not_allowlisted", 2, {}, vars);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// BOT-2084 (AC-8 follow-up): which repository checkouts on this Mac declare a
|
|
2
|
+
// staging_session_pool, so a refusal run from the wrong directory (e.g. `~`) can
|
|
3
|
+
// name a concrete `cd <path>` instead of a placeholder.
|
|
4
|
+
//
|
|
5
|
+
// Sources, both non-secret paths only:
|
|
6
|
+
// * every installed pool's config.json `checkout` (written by install);
|
|
7
|
+
// * ~/.botbuddy/staging-session-pool/known-checkouts.json (0600), updated each
|
|
8
|
+
// time a bb staging-pool / bb pw auth command resolves a binding from a cwd.
|
|
9
|
+
// A candidate counts only if it still exists and still declares a pool binding.
|
|
10
|
+
// Node-stdlib-only and self-contained (bundled with the installed job).
|
|
11
|
+
import { chmodSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { join, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { readRepoPoolBinding } from "./binding.mjs";
|
|
16
|
+
|
|
17
|
+
const MAX_KNOWN = 20;
|
|
18
|
+
|
|
19
|
+
const linkedWorktree = (path) => {
|
|
20
|
+
try {
|
|
21
|
+
return statSync(join(path, ".git")).isFile();
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
|
|
27
|
+
// Path segments that mark agent, scratch or disposable checkouts.
|
|
28
|
+
const DISPOSABLE_SEGMENTS = new Set(["worktrees", "scratch-workspaces"]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A checkout a human can be told to `cd` into: not under a temp dir, not an agent
|
|
32
|
+
* or scratch worktree (a `worktrees` / `scratch-workspaces` path segment), and not
|
|
33
|
+
* a linked git worktree (`.git` is a file). Remedies addressed to a human never
|
|
34
|
+
* embed any other path (AC-8 follow-up, found on staging acceptance).
|
|
35
|
+
*/
|
|
36
|
+
export function isStableCheckout(path, { tmp = tmpdir(), gitFileIsWorktree = linkedWorktree } = {}) {
|
|
37
|
+
if (typeof path !== "string" || !path.startsWith("/")) return false;
|
|
38
|
+
const roots = [...TEMP_ROOTS, tmp];
|
|
39
|
+
try {
|
|
40
|
+
roots.push(realpathSync(tmp));
|
|
41
|
+
} catch {
|
|
42
|
+
// tmp may not resolve in a test sandbox
|
|
43
|
+
}
|
|
44
|
+
for (const root of roots) {
|
|
45
|
+
const base = String(root).replace(/\/+$/, "");
|
|
46
|
+
if (base && (path === base || path.startsWith(`${base}/`))) return false;
|
|
47
|
+
}
|
|
48
|
+
if (path.split(sep).some((segment) => DISPOSABLE_SEGMENTS.has(segment))) return false;
|
|
49
|
+
return !gitFileIsWorktree(path);
|
|
50
|
+
}
|
|
51
|
+
const poolsRoot = (home) => join(home, ".botbuddy", "staging-session-pool");
|
|
52
|
+
export const knownCheckoutsPath = (home = homedir()) => join(poolsRoot(home), "known-checkouts.json");
|
|
53
|
+
|
|
54
|
+
function readKnown(home) {
|
|
55
|
+
try {
|
|
56
|
+
const data = JSON.parse(readFileSync(knownCheckoutsPath(home), "utf8"));
|
|
57
|
+
return Array.isArray(data?.checkouts) ? data.checkouts.filter((entry) => typeof entry?.path === "string" && entry.path.startsWith("/")) : [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Best effort: remember a checkout that resolved a binding. Never throws. */
|
|
64
|
+
export function recordCheckout({ home = homedir(), path, poolId, now = Date.now() } = {}) {
|
|
65
|
+
try {
|
|
66
|
+
if (typeof path !== "string" || !path.startsWith("/")) return;
|
|
67
|
+
const entries = [{ path, pool_id: poolId ?? null, seen_at: new Date(now).toISOString() }, ...readKnown(home).filter((entry) => entry.path !== path)].slice(0, MAX_KNOWN);
|
|
68
|
+
mkdirSync(poolsRoot(home), { recursive: true, mode: 0o700 });
|
|
69
|
+
const file = knownCheckoutsPath(home);
|
|
70
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
71
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, checkouts: entries }, null, 2)}\n`, { mode: 0o600 });
|
|
72
|
+
chmodSync(tmp, 0o600);
|
|
73
|
+
renameSync(tmp, file);
|
|
74
|
+
} catch {
|
|
75
|
+
// A refusal's hint must never fail the command that produced it.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function installedCheckouts(home) {
|
|
80
|
+
const found = [];
|
|
81
|
+
let names = [];
|
|
82
|
+
try {
|
|
83
|
+
names = readdirSync(poolsRoot(home));
|
|
84
|
+
} catch {
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
try {
|
|
89
|
+
const config = JSON.parse(readFileSync(join(poolsRoot(home), name, "config.json"), "utf8"));
|
|
90
|
+
if (typeof config?.checkout === "string" && config.checkout.startsWith("/")) found.push({ path: config.checkout, pool_id: name, seen_at: config.installedAt ?? null });
|
|
91
|
+
} catch {
|
|
92
|
+
// not installed, or unreadable: not a candidate
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Checkouts that still declare a staging_session_pool, most relevant first:
|
|
100
|
+
* installed pools' checkouts, then the most recently seen. `poolId` narrows to
|
|
101
|
+
* one pool when it is known.
|
|
102
|
+
*/
|
|
103
|
+
export async function findKnownCheckouts({ home = homedir(), poolId = null, readBinding = readRepoPoolBinding, isStable = isStableCheckout } = {}) {
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const result = [];
|
|
106
|
+
for (const entry of [...installedCheckouts(home), ...readKnown(home)]) {
|
|
107
|
+
if (seen.has(entry.path)) continue;
|
|
108
|
+
seen.add(entry.path);
|
|
109
|
+
if (!isStable(entry.path)) continue;
|
|
110
|
+
try {
|
|
111
|
+
const found = await readBinding(entry.path);
|
|
112
|
+
if (!found || found.repoRoot !== entry.path) continue;
|
|
113
|
+
if (poolId && found.binding.poolId !== poolId) continue;
|
|
114
|
+
result.push({ path: entry.path, poolId: found.binding.poolId });
|
|
115
|
+
} catch {
|
|
116
|
+
// gone, or no longer a valid binding
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
package/src/staging-pool/cli.mjs
CHANGED
|
@@ -34,11 +34,13 @@ import { PoolEmptyError, createSessionPool } from "./pool.mjs";
|
|
|
34
34
|
import { createGoTrueTotpSignIn } from "./signin-gotrue-totp.mjs";
|
|
35
35
|
import { createRedactor } from "./state-file.mjs";
|
|
36
36
|
import { createBotBuddyTotpProvider, probeBotBuddy } from "./totp-botbuddy.mjs";
|
|
37
|
-
import { formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
|
|
37
|
+
import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
|
|
38
|
+
import { findKnownCheckouts, isStableCheckout as defaultIsStableCheckout, recordCheckout } from "./checkouts.mjs";
|
|
38
39
|
import { STAGING_POOL_HELP } from "./help.mjs";
|
|
39
40
|
|
|
40
41
|
export const BUNDLE_FILES = Object.freeze([
|
|
41
42
|
"binding.mjs",
|
|
43
|
+
"checkouts.mjs",
|
|
42
44
|
"cli.mjs",
|
|
43
45
|
"config.mjs",
|
|
44
46
|
"errors.mjs",
|
|
@@ -86,29 +88,50 @@ export function exitCodeFor(error) {
|
|
|
86
88
|
|
|
87
89
|
// Where this invocation's pool lives: the job passes --home; everyone else
|
|
88
90
|
// resolves it from the repo binding (cwd).
|
|
89
|
-
|
|
91
|
+
// The checkout a human-facing remedy may name: this repo root or the one install
|
|
92
|
+
// recorded, if stable; else a stable checkout seen earlier for this pool; else none.
|
|
93
|
+
async function stableCheckoutFor({ repoRoot, installed, poolId, home, readBinding, isStable }) {
|
|
94
|
+
if (repoRoot && isStable(repoRoot)) return repoRoot;
|
|
95
|
+
// The install-recorded checkout may since have moved or been deleted: name it only
|
|
96
|
+
// while it still declares this pool's binding.
|
|
97
|
+
const recorded = installed?.checkout;
|
|
98
|
+
if (recorded && isStable(recorded)) {
|
|
99
|
+
const found = await readBinding(recorded).catch(() => null);
|
|
100
|
+
if (found && found.repoRoot === recorded && found.binding.poolId === poolId) return recorded;
|
|
101
|
+
}
|
|
102
|
+
const [candidate] = await findKnownCheckouts({ home, poolId, readBinding, isStable }).catch(() => []);
|
|
103
|
+
return candidate?.path;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolveContext({ options, cwd, home, readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
|
|
90
107
|
if (options.home) {
|
|
91
108
|
const paths = poolHomePaths({ home, root: options.home });
|
|
92
109
|
const config = readInstalledConfig(paths.config);
|
|
93
|
-
|
|
110
|
+
const stableCheckout = await stableCheckoutFor({ installed: config, poolId: config.poolBinding.poolId, home, readBinding, isStable: isStableCheckout });
|
|
111
|
+
return { paths, poolBinding: config.poolBinding, installed: config, rawBinding: config.binding, stableCheckout };
|
|
94
112
|
}
|
|
95
113
|
const found = await readBinding(cwd);
|
|
96
114
|
if (!found) {
|
|
97
|
-
throw new ConfigError(
|
|
115
|
+
throw new ConfigError(`${cwd} is not inside a repository whose .botbuddy-agent.json declares staging_session_pool.`, "pool_not_configured");
|
|
98
116
|
}
|
|
99
117
|
const paths = poolHomePaths({ home, poolId: found.binding.poolId });
|
|
118
|
+
// Remembered so a later refusal run from the wrong directory can name this checkout.
|
|
119
|
+
recordCheckout({ home, path: found.repoRoot, poolId: found.binding.poolId });
|
|
100
120
|
let installed = null;
|
|
101
121
|
try {
|
|
102
122
|
installed = readInstalledConfig(paths.config);
|
|
103
123
|
} catch (error) {
|
|
104
124
|
if (error?.code !== "config_missing") installed = { error };
|
|
105
125
|
}
|
|
106
|
-
|
|
126
|
+
const stableCheckout = await stableCheckoutFor({ repoRoot: found.repoRoot, installed: installed?.error ? null : installed, poolId: found.binding.poolId, home, readBinding, isStable: isStableCheckout });
|
|
127
|
+
return { paths, poolBinding: found.binding, installed, repoRoot: found.repoRoot, stableCheckout };
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
const shellWord = (value) => (/^[A-Za-z0-9_./:@,=~+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
|
|
110
|
-
|
|
111
|
-
|
|
131
|
+
// No checkout known → undefined, so a real refusal says where to run it instead of
|
|
132
|
+
// printing the binding's `<your … checkout>` placeholder.
|
|
133
|
+
export const installCommandFor = (repoRoot) =>
|
|
134
|
+
repoRoot ? `cd ${shellWord(repoRoot)} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"` : undefined;
|
|
112
135
|
|
|
113
136
|
// The non-secret values every guidance template may name.
|
|
114
137
|
export function guidanceVars(ctx, extra = {}) {
|
|
@@ -116,12 +139,12 @@ export function guidanceVars(ctx, extra = {}) {
|
|
|
116
139
|
return {
|
|
117
140
|
label: ctx.poolBinding.launchdLabel,
|
|
118
141
|
log: ctx.paths.log,
|
|
119
|
-
installCommand: installCommandFor(ctx.
|
|
142
|
+
installCommand: installCommandFor(ctx.stableCheckout),
|
|
120
143
|
docs: ctx.poolBinding.docs,
|
|
121
144
|
configPath: ctx.paths.config,
|
|
122
145
|
poolHome: ctx.paths.root,
|
|
123
146
|
tickCommand: `bb staging-pool tick --home ${shellWord(ctx.paths.root)}`,
|
|
124
|
-
checkout: ctx.
|
|
147
|
+
checkout: ctx.stableCheckout,
|
|
125
148
|
envFile: ctx.poolBinding.passwordEnvFile,
|
|
126
149
|
allowed: Object.keys(ctx.poolBinding.personas ?? {}),
|
|
127
150
|
...extra,
|
|
@@ -196,8 +219,9 @@ export async function takeForLane({
|
|
|
196
219
|
uid = userInfo().uid,
|
|
197
220
|
logger = () => {},
|
|
198
221
|
readBinding = readRepoPoolBinding,
|
|
222
|
+
isStableCheckout = defaultIsStableCheckout,
|
|
199
223
|
} = {}) {
|
|
200
|
-
const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
|
|
224
|
+
const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
|
|
201
225
|
const entry = findPersona(ctx.poolBinding, persona);
|
|
202
226
|
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
203
227
|
if (!entry) assertPersonaAllowed(target, persona);
|
|
@@ -228,6 +252,68 @@ function humanCommandLine(argv) {
|
|
|
228
252
|
return argv.map((token) => (/^[A-Za-z0-9_./:=@-]+$/.test(token) ? token : `'${token.replace(/'/g, "'\\''")}'`)).join(" ");
|
|
229
253
|
}
|
|
230
254
|
|
|
255
|
+
// What a missing pool binding means for each subcommand (AC-8 follow-up: an
|
|
256
|
+
// install refusal must not talk about taking a session).
|
|
257
|
+
const NO_POOL_CONSEQUENCE = Object.freeze({
|
|
258
|
+
install: "there is no pool definition to install",
|
|
259
|
+
uninstall: "there is no pool to remove",
|
|
260
|
+
tick: "there is no pool to refill",
|
|
261
|
+
"mint-one": "there is no pool to sign a persona in for",
|
|
262
|
+
status: "there is no pool to report on",
|
|
263
|
+
probe: "there are no pool personas to probe",
|
|
264
|
+
"print-plist": "there is no pool job to describe",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build the refusal for a failed subcommand: the JSON record and the text block.
|
|
269
|
+
* A human-only subcommand always names Human (Jono) and prints no Agent line. The
|
|
270
|
+
* checkout comes from this directory, else from a checkout an install recorded or
|
|
271
|
+
* one seen earlier; with none, the next step says where to run the command.
|
|
272
|
+
*/
|
|
273
|
+
export async function refusalFor({ argv, command, error, options = {}, cwd = process.cwd(), home = homedir(), readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
|
|
274
|
+
const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
|
|
275
|
+
const humanOnly = HUMAN_ONLY_COMMANDS.has(command);
|
|
276
|
+
let ctx = null;
|
|
277
|
+
try {
|
|
278
|
+
ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
279
|
+
} catch {
|
|
280
|
+
ctx = null;
|
|
281
|
+
}
|
|
282
|
+
if (!ctx) {
|
|
283
|
+
const [candidate] = await findKnownCheckouts({ home, readBinding, isStable: isStableCheckout }).catch(() => []);
|
|
284
|
+
if (candidate) {
|
|
285
|
+
try {
|
|
286
|
+
ctx = await resolveContext({ options: {}, cwd: candidate.path, home, readBinding, isStableCheckout });
|
|
287
|
+
} catch {
|
|
288
|
+
ctx = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const line = humanCommandLine(argv ?? [command]);
|
|
293
|
+
const vars = guidanceVars(ctx, {
|
|
294
|
+
command: line,
|
|
295
|
+
retry: `bb staging-pool ${line}`,
|
|
296
|
+
cwd,
|
|
297
|
+
consequence: NO_POOL_CONSEQUENCE[command] ?? "there is no pool to act on",
|
|
298
|
+
markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
|
|
299
|
+
});
|
|
300
|
+
vars.persona = options.persona ?? error?.persona ?? vars.allowed?.[0];
|
|
301
|
+
const actor = humanOnly ? "human" : null;
|
|
302
|
+
const g = guidance(code, vars, { real: true, actor });
|
|
303
|
+
const record = {
|
|
304
|
+
ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
|
|
305
|
+
agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
|
|
306
|
+
};
|
|
307
|
+
// A pool refusal (pool_empty / unhealthy) already carries its health guidance;
|
|
308
|
+
// an ordinary error's own message stays in `detail`.
|
|
309
|
+
if (typeof error?.reason === "string") for (const field of GUIDANCE_FIELDS) if (error[field] !== undefined) record[field] = error[field];
|
|
310
|
+
record.remedy ??= remedyLine(g);
|
|
311
|
+
if (humanOnly) Object.assign(record, { actor: g.actor_label, agentAction: "report_blocker" });
|
|
312
|
+
// An empty pool is explained by its health reason (e.g. job_not_loaded).
|
|
313
|
+
const textG = typeof error?.reason === "string" && error.reason !== code && isKnownCode(error.reason) ? guidance(error.reason, vars, { real: true, actor }) : g;
|
|
314
|
+
return { record, text: formatGuidance(textG, { agentLine: !humanOnly }), exitCode: exitCodeFor(error) };
|
|
315
|
+
}
|
|
316
|
+
|
|
231
317
|
export async function runStagingPool(argv, deps = {}) {
|
|
232
318
|
const {
|
|
233
319
|
env = process.env,
|
|
@@ -246,6 +332,7 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
246
332
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
247
333
|
readBinding = readRepoPoolBinding,
|
|
248
334
|
readSecretFile = readFileSync,
|
|
335
|
+
isStableCheckout = defaultIsStableCheckout,
|
|
249
336
|
} = deps;
|
|
250
337
|
|
|
251
338
|
const redactor = createRedactor();
|
|
@@ -304,7 +391,7 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
304
391
|
|
|
305
392
|
const commands = {
|
|
306
393
|
async install(options) {
|
|
307
|
-
const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
|
|
394
|
+
const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
|
|
308
395
|
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
309
396
|
assertMintAllowed({ target, env, listAncestors });
|
|
310
397
|
const botbuddyBin = requireBotbuddyBin(options);
|
|
@@ -327,6 +414,8 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
327
414
|
botbuddyBin,
|
|
328
415
|
intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
|
|
329
416
|
existingPersonas: existing?.personas,
|
|
417
|
+
checkout: ctx.repoRoot,
|
|
418
|
+
installedAt: new Date(now()).toISOString(),
|
|
330
419
|
});
|
|
331
420
|
await fs.writeFile(ctx.paths.config, serialiseInstalledConfig(config), { mode: 0o600 });
|
|
332
421
|
await fs.chmod(ctx.paths.config, 0o600);
|
|
@@ -351,7 +440,7 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
351
440
|
},
|
|
352
441
|
|
|
353
442
|
async uninstall(options) {
|
|
354
|
-
const ctx = await resolveContext({ options, cwd, home, readBinding });
|
|
443
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
355
444
|
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
356
445
|
// The job and its sessions are the human's: an agent never stops or empties them.
|
|
357
446
|
assertMintAllowed({ target, env, listAncestors });
|
|
@@ -365,7 +454,7 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
365
454
|
},
|
|
366
455
|
|
|
367
456
|
async tick(options) {
|
|
368
|
-
const ctx = await resolveContext({ options, cwd, home, readBinding });
|
|
457
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
369
458
|
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
370
459
|
assertMintAllowed({ target, env, listAncestors });
|
|
371
460
|
if (!ctx.installed || ctx.installed.error) throw ctx.installed?.error ?? new ConfigError("The pool job is not installed; the human runs the install first.", "pool_not_installed");
|
|
@@ -375,7 +464,7 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
375
464
|
},
|
|
376
465
|
|
|
377
466
|
async "mint-one"(options) {
|
|
378
|
-
const ctx = await resolveContext({ options, cwd, home, readBinding });
|
|
467
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
379
468
|
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
380
469
|
assertMintAllowed({ target, env, listAncestors });
|
|
381
470
|
const entry = findPersona(ctx.poolBinding, options.persona);
|
|
@@ -388,7 +477,7 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
388
477
|
},
|
|
389
478
|
|
|
390
479
|
async status(options) {
|
|
391
|
-
const ctx = await resolveContext({ options, cwd, home, readBinding });
|
|
480
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
392
481
|
const target = stagingTargetFromBinding(ctx.poolBinding);
|
|
393
482
|
const pool = buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep });
|
|
394
483
|
if (options.check) {
|
|
@@ -403,7 +492,7 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
403
492
|
},
|
|
404
493
|
|
|
405
494
|
async probe(options) {
|
|
406
|
-
const ctx = await resolveContext({ options, cwd, home, readBinding });
|
|
495
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
407
496
|
const botbuddyBin = options["botbuddy-bin"] ?? ctx.installed?.botbuddyBin;
|
|
408
497
|
if (!botbuddyBin) throw new ConfigError('probe needs --botbuddy-bin "$(command -v botbuddy)" (or an installed job).', "pool_not_installed");
|
|
409
498
|
const receipt = await probeBotBuddy({
|
|
@@ -420,42 +509,19 @@ export async function runStagingPool(argv, deps = {}) {
|
|
|
420
509
|
},
|
|
421
510
|
|
|
422
511
|
async "print-plist"(options) {
|
|
423
|
-
const ctx = await resolveContext({ options, cwd, home, readBinding });
|
|
512
|
+
const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
|
|
424
513
|
stdout(jobPlist(ctx, Number(options.interval ?? ctx.installed?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS)));
|
|
425
514
|
return 0;
|
|
426
515
|
},
|
|
427
516
|
};
|
|
428
517
|
|
|
518
|
+
// One JSON line (machine-readable) then the same guidance as text, on stderr.
|
|
429
519
|
// One JSON line (machine-readable) then the same guidance as text, on stderr.
|
|
430
520
|
async function fail(command, error, options = {}) {
|
|
431
|
-
const
|
|
432
|
-
let ctx = null;
|
|
433
|
-
try {
|
|
434
|
-
ctx = await resolveContext({ options, cwd, home, readBinding });
|
|
435
|
-
} catch {
|
|
436
|
-
ctx = null;
|
|
437
|
-
}
|
|
438
|
-
const persona = options.persona ?? error?.persona;
|
|
439
|
-
const vars = guidanceVars(ctx, {
|
|
440
|
-
persona,
|
|
441
|
-
command: humanCommandLine(argv),
|
|
442
|
-
markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
|
|
443
|
-
checkout: ctx?.repoRoot ?? cwd,
|
|
444
|
-
retry: `bb staging-pool ${humanCommandLine(argv)}`,
|
|
445
|
-
});
|
|
446
|
-
const g = guidance(code, vars);
|
|
447
|
-
const record = {
|
|
448
|
-
ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
|
|
449
|
-
agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
|
|
450
|
-
};
|
|
451
|
-
// A pool refusal already carries its health guidance (pool_empty / unhealthy).
|
|
452
|
-
for (const field of GUIDANCE_FIELDS) if (error?.[field] !== undefined) record[field] = error[field];
|
|
453
|
-
record.remedy ??= remedyLine(g);
|
|
521
|
+
const { record, text, exitCode } = await refusalFor({ argv, command, error, options, cwd, home, readBinding, isStableCheckout });
|
|
454
522
|
stderr(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
stderr(redactor.redact(formatGuidance(text)));
|
|
458
|
-
return exitCodeFor(error);
|
|
523
|
+
stderr(redactor.redact(text));
|
|
524
|
+
return exitCode;
|
|
459
525
|
}
|
|
460
526
|
|
|
461
527
|
let parsed;
|
|
@@ -87,7 +87,7 @@ export function readInstalledConfig(path, { readFile = readFileSync } = {}) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// rawBinding: { tenant, staging_session_pool } exactly as committed.
|
|
90
|
-
export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas }) {
|
|
90
|
+
export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, checkout, installedAt }) {
|
|
91
91
|
const personas = {};
|
|
92
92
|
for (const persona of Object.values(poolBinding.personas)) {
|
|
93
93
|
personas[persona.email] = Number.isInteger(existingPersonas?.[persona.email]) ? existingPersonas[persona.email] : persona.defaultCount;
|
|
@@ -98,6 +98,9 @@ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, int
|
|
|
98
98
|
personas,
|
|
99
99
|
botbuddyBin,
|
|
100
100
|
intervalSeconds,
|
|
101
|
+
// The checkout install ran from (non-secret), so a refusal elsewhere can name it.
|
|
102
|
+
...(typeof checkout === "string" && checkout.startsWith("/") ? { checkout } : {}),
|
|
103
|
+
...(installedAt ? { installedAt } : {}),
|
|
101
104
|
});
|
|
102
105
|
}
|
|
103
106
|
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
// command and the doc section. `bb help symptom <code>` prints the same entry.
|
|
4
4
|
//
|
|
5
5
|
// Node-stdlib-only and self-contained (the installed job copies this directory).
|
|
6
|
-
// Templates take only non-secret values (lane, persona alias, paths, labels)
|
|
7
|
-
//
|
|
6
|
+
// Templates take only non-secret values (lane, persona alias, paths, labels).
|
|
7
|
+
// A real refusal (`{ real: true }`) never prints a <placeholder>: a missing value
|
|
8
|
+
// in prose becomes plain words, and a `next` command that needs a value it does
|
|
9
|
+
// not have falls back to "run this from inside a repository checkout …". Only
|
|
10
|
+
// `bb help symptom` (the reference view) shows <placeholders>.
|
|
8
11
|
|
|
9
12
|
export const DOC_URL = "https://github.com/bot-buddy/botbuddy-webapp/blob/main/docs/agent-staging-acceptance-mfa.md";
|
|
10
13
|
|
|
@@ -39,15 +42,15 @@ export const ERROR_CODES = Object.freeze({
|
|
|
39
42
|
auth_requires_lane_lock: entry("agent", "take",
|
|
40
43
|
"bb pw auth needs a lock-gated lane, and this run is unlocked.",
|
|
41
44
|
"BB_PW_NO_LOCK=1 is set in this shell.",
|
|
42
|
-
`bb locks -m --lane {lane}
|
|
45
|
+
`bb locks -m --lane {lane}{ticketFlag} && env -u BB_PW_NO_LOCK ${AUTH}`),
|
|
43
46
|
lane_not_claimed: entry("agent", "take",
|
|
44
47
|
"The lane lock is not held by this agent session.",
|
|
45
48
|
"The lane was never claimed, its lock expired, or another agent holds it.",
|
|
46
|
-
`bb locks -m --lane {lane}
|
|
49
|
+
`bb locks -m --lane {lane}{ticketFlag} && ${AUTH}`),
|
|
47
50
|
exact_lane_holder_required: entry("agent", "take",
|
|
48
51
|
"The lane lock is held by another agent session of your owner, not by this session.",
|
|
49
52
|
"The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
|
|
50
|
-
`bb locks -m --lane {lane}
|
|
53
|
+
`bb locks -m --lane {lane}{ticketFlag} # from THIS session, then: ${AUTH}`),
|
|
51
54
|
persona_not_allowlisted: entry("agent", "take",
|
|
52
55
|
"Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
|
|
53
56
|
"A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
|
|
@@ -65,9 +68,9 @@ export const ERROR_CODES = Object.freeze({
|
|
|
65
68
|
"The lane was opened with --persistent or --profile.",
|
|
66
69
|
`${REOPEN} && ${AUTH} # no --persistent / --profile`),
|
|
67
70
|
pool_not_configured: entry("agent", "configure",
|
|
68
|
-
"
|
|
69
|
-
"The command ran outside the repository checkout, or
|
|
70
|
-
"cd
|
|
71
|
+
"{cwd} is not inside a repository whose .botbuddy-agent.json declares staging_session_pool, so {consequence}.",
|
|
72
|
+
"The command ran outside the repository checkout (for example from your home directory), or that repository has not opted in.",
|
|
73
|
+
"cd {checkout} && {retry}"),
|
|
71
74
|
|
|
72
75
|
// ── bb pw … auth: install into the lane (agent retries once, then reports)
|
|
73
76
|
session_install_failed: entry("agent", "take",
|
|
@@ -139,15 +142,15 @@ export const ERROR_CODES = Object.freeze({
|
|
|
139
142
|
mint_backing_off: entry("human", "repair",
|
|
140
143
|
"Staging sign-in for {persona} keeps failing, so the job is backing off.",
|
|
141
144
|
"The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
|
|
142
|
-
"grep mint_failed {log} | tail -1 # then
|
|
145
|
+
"grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
|
|
143
146
|
last_tick_failed: entry("human", "repair",
|
|
144
|
-
"The pool job's last tick
|
|
147
|
+
"The pool job's last tick had sign-in (mint) failures.",
|
|
145
148
|
"The last mint_failed line in {log} names the cause.",
|
|
146
149
|
"grep mint_failed {log} | tail -1 # repair that code, then: {tickCommand}"),
|
|
147
150
|
ticks_failing: entry("human", "repair",
|
|
148
151
|
"Pool ticks run but none has succeeded for 3+ intervals.",
|
|
149
152
|
"A persistent mint failure; {log} names it.",
|
|
150
|
-
"grep mint_failed {log} | tail -1 # then
|
|
153
|
+
"grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
|
|
151
154
|
persona_below_target: entry("human", "repair",
|
|
152
155
|
"The pool has been below its configured size for {persona} for 3+ ticks.",
|
|
153
156
|
"Agents take sessions faster than the job refills them, or mints are partly failing ({log}).",
|
|
@@ -180,12 +183,12 @@ export const ERROR_CODES = Object.freeze({
|
|
|
180
183
|
// ── who may run what
|
|
181
184
|
agent_context: entry("human", "install",
|
|
182
185
|
"This bb staging-pool command is human-only and this shell is inside an agent session ({markers}).",
|
|
183
|
-
`It ran from a Claude or Codex terminal. ${HUMAN_ONLY}`,
|
|
184
|
-
"
|
|
186
|
+
`It ran from a Claude or Codex terminal; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
|
|
187
|
+
"cd {checkout} && bb staging-pool {command}"),
|
|
185
188
|
agent_context_unknown: entry("human", "install",
|
|
186
189
|
"bb staging-pool could not establish that this is not an agent session (ps failed), so it refused.",
|
|
187
|
-
`/bin/ps is unavailable or restricted in this shell. ${HUMAN_ONLY}`,
|
|
188
|
-
"
|
|
190
|
+
`/bin/ps is unavailable or restricted in this shell; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
|
|
191
|
+
"cd {checkout} && bb staging-pool {command}"),
|
|
189
192
|
|
|
190
193
|
// ── configuration (repository binding and installed config)
|
|
191
194
|
config_missing: entry("human", "configure",
|
|
@@ -285,27 +288,62 @@ export const ERROR_CODES = Object.freeze({
|
|
|
285
288
|
"bb staging-pool status # once; report this code, the detail and the CLI version (bb version)", "report_blocker"),
|
|
286
289
|
});
|
|
287
290
|
|
|
291
|
+
// Codes only `bb pw … auth` can raise; every other code can come from `bb staging-pool`.
|
|
292
|
+
export const AUTH_ONLY_CODES = Object.freeze(new Set([
|
|
293
|
+
"auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
|
|
294
|
+
"persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
|
|
295
|
+
"session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
|
|
296
|
+
"session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
|
|
297
|
+
"daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
|
|
298
|
+
]));
|
|
299
|
+
|
|
300
|
+
// `bb staging-pool` subcommands only a human runs. A refusal from one names
|
|
301
|
+
// Human (Jono) whatever the code, and prints no Agent guidance.
|
|
302
|
+
export const HUMAN_ONLY_COMMANDS = Object.freeze(new Set(["install", "uninstall", "tick", "mint-one"]));
|
|
303
|
+
|
|
304
|
+
export const CHECKOUT_SENTENCE = "From inside a repository checkout whose .botbuddy-agent.json declares staging_session_pool (for BotBuddy: your botbuddy-webapp clone on main), run";
|
|
305
|
+
export const BARE_INSTALL = 'bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"';
|
|
306
|
+
|
|
288
307
|
// Vars that are already whole command lines; every other var is a single shell word.
|
|
289
|
-
const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry"]);
|
|
308
|
+
const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry", "ticketFlag"]);
|
|
309
|
+
// Vars where an empty string is a real value (no flag), not a missing one.
|
|
310
|
+
const OPTIONAL_VARS = new Set(["ticketFlag"]);
|
|
290
311
|
const SHELL_SAFE = /^[A-Za-z0-9_./:@,=~+-]+$/;
|
|
291
312
|
// A <placeholder> stays as written: the reader replaces it.
|
|
292
313
|
const shellWord = (value) => (SHELL_SAFE.test(value) || /^<[^<>]+>$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
|
|
293
314
|
|
|
315
|
+
// What a missing value reads as in prose (real refusals only).
|
|
316
|
+
const DESCRIBED = Object.freeze({
|
|
317
|
+
persona: "the persona", lane: "the lane", log: "the pool log", configPath: "the installed pool config",
|
|
318
|
+
label: "the launchd job", envFile: "the persona password file", markers: "agent markers",
|
|
319
|
+
cwd: "This directory", consequence: "there is no pool to act on", command: "this command",
|
|
320
|
+
checkout: "the repository checkout", allowed: "the binding's personas", firstAllowed: "a listed persona",
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const isMissing = (key, value) => value === undefined || value === null || (value === "" && !OPTIONAL_VARS.has(key)) || (Array.isArray(value) && value.length === 0);
|
|
324
|
+
|
|
294
325
|
// In a `next` command a caller-supplied value (a typed persona, a checkout path
|
|
295
326
|
// with spaces) is shell-quoted, so the line stays one safe copy-paste.
|
|
296
|
-
function fill(template, vars, { shell = false } = {}) {
|
|
327
|
+
function fill(template, vars, { shell = false, missing = null, describe = false } = {}) {
|
|
297
328
|
return String(template).replace(/\{([A-Za-z]+)\}/g, (_, key) => {
|
|
298
329
|
const value = vars?.[key];
|
|
299
|
-
if (
|
|
300
|
-
|
|
330
|
+
if (isMissing(key, value)) {
|
|
331
|
+
missing?.add(key);
|
|
332
|
+
return describe ? (DESCRIBED[key] ?? "it") : `<${key}>`;
|
|
333
|
+
}
|
|
334
|
+
if (Array.isArray(value)) return value.map((item) => (shell ? shellWord(String(item)) : String(item))).join(", ");
|
|
301
335
|
return shell && !COMMAND_VARS.has(key) ? shellWord(String(value)) : String(value);
|
|
302
336
|
});
|
|
303
337
|
}
|
|
304
338
|
|
|
305
339
|
export const isKnownCode = (code) => Object.hasOwn(ERROR_CODES, String(code ?? ""));
|
|
306
340
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
341
|
+
/**
|
|
342
|
+
* The guidance for one code, interpolated with non-secret vars.
|
|
343
|
+
* real a refusal someone will act on: no <placeholder> is ever printed.
|
|
344
|
+
* actor "human" for a human-only subcommand, whatever the code's default.
|
|
345
|
+
*/
|
|
346
|
+
export function guidance(code, vars = {}, { real = false, actor = null } = {}) {
|
|
309
347
|
const known = isKnownCode(code);
|
|
310
348
|
const spec = known ? ERROR_CODES[code] : ERROR_CODES.unexpected_error;
|
|
311
349
|
const allowed = Array.isArray(vars.allowed) ? vars.allowed : vars.allowed ? [vars.allowed] : [];
|
|
@@ -313,20 +351,36 @@ export function guidance(code, vars = {}) {
|
|
|
313
351
|
...vars,
|
|
314
352
|
allowed,
|
|
315
353
|
firstAllowed: vars.firstAllowed ?? allowed[0],
|
|
354
|
+
ticketFlag: vars.ticketFlag ?? (real ? "" : " --ticket <ticket>"),
|
|
316
355
|
// Without a resolved pool these still read as runnable commands from the checkout.
|
|
317
356
|
tickCommand: vars.tickCommand ?? "bb staging-pool tick",
|
|
318
|
-
installCommand: vars.installCommand ??
|
|
357
|
+
installCommand: vars.installCommand ?? (vars.checkout ? `cd ${shellWord(String(vars.checkout))} && ${BARE_INSTALL}` : (real ? undefined : `cd <checkout> && ${BARE_INSTALL}`)),
|
|
319
358
|
};
|
|
320
359
|
// What a "go to the right checkout" step re-runs: auth by default.
|
|
321
360
|
filled.retry = vars.retry ?? fill(AUTH, filled, { shell: true });
|
|
361
|
+
let next;
|
|
362
|
+
if (real) {
|
|
363
|
+
const missing = new Set();
|
|
364
|
+
next = fill(spec.next, filled, { shell: true, missing });
|
|
365
|
+
if (missing.size) {
|
|
366
|
+
// No concrete checkout (or a value only a resolved pool knows): say where to
|
|
367
|
+
// run it, and give the command that works from there.
|
|
368
|
+
const bareMissing = new Set();
|
|
369
|
+
const bare = fill(spec.next.replace("cd {checkout} && ", ""), { ...filled, installCommand: BARE_INSTALL }, { shell: true, missing: bareMissing });
|
|
370
|
+
next = `${CHECKOUT_SENTENCE}: ${bareMissing.size ? filled.retry : bare}`;
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
next = fill(spec.next, filled, { shell: true });
|
|
374
|
+
}
|
|
375
|
+
const finalActor = actor ?? spec.actor;
|
|
322
376
|
return {
|
|
323
377
|
code: known ? code : String(code ?? "unexpected_error"),
|
|
324
|
-
message: fill(spec.message, filled),
|
|
325
|
-
cause: fill(spec.cause, filled),
|
|
326
|
-
actor:
|
|
327
|
-
actor_label: ACTOR_LABELS[
|
|
328
|
-
next
|
|
329
|
-
agent_action: spec.agentAction,
|
|
378
|
+
message: fill(spec.message, filled, { describe: real }),
|
|
379
|
+
cause: fill(spec.cause, filled, { describe: real }),
|
|
380
|
+
actor: finalActor,
|
|
381
|
+
actor_label: ACTOR_LABELS[finalActor],
|
|
382
|
+
next,
|
|
383
|
+
agent_action: finalActor === "human" ? "report_blocker" : spec.agentAction,
|
|
330
384
|
doc: `${DOC_URL}#${DOC_SECTIONS[spec.doc]}`,
|
|
331
385
|
help: `bb help symptom ${known ? code : "unexpected_error"}`,
|
|
332
386
|
};
|
|
@@ -339,13 +393,13 @@ export function remedyLine(g) {
|
|
|
339
393
|
}
|
|
340
394
|
|
|
341
395
|
/** Plain-text block for stderr. */
|
|
342
|
-
export function formatGuidance(g, { prefix = "bb staging-pool" } = {}) {
|
|
396
|
+
export function formatGuidance(g, { prefix = "bb staging-pool", agentLine = true } = {}) {
|
|
343
397
|
const lines = [
|
|
344
398
|
`${prefix}: ${g.message} [${g.code}]`,
|
|
345
399
|
` Likely cause: ${g.cause}`,
|
|
346
400
|
` Next (${g.actor_label}${g.actor === "human" ? ", from Terminal.app or iTerm" : ""}): ${g.next}`,
|
|
347
401
|
];
|
|
348
|
-
if (g.actor === "human") lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
|
|
402
|
+
if (g.actor === "human" && agentLine) lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
|
|
349
403
|
lines.push(` Docs: ${g.doc}`, ` Explain: ${g.help}`);
|
|
350
404
|
return lines.join("\n");
|
|
351
405
|
}
|
|
@@ -15,7 +15,7 @@ export const HEALTH_REASON_CODES = Object.freeze([
|
|
|
15
15
|
export function healthReasons({ label, log, installCommand, docs, configPath, tickCommand, checkout, persona }) {
|
|
16
16
|
const vars = { label, log, installCommand, docs, configPath, tickCommand, checkout, persona };
|
|
17
17
|
return Object.freeze(Object.fromEntries(HEALTH_REASON_CODES.map((code) => {
|
|
18
|
-
const g = guidance(code, vars);
|
|
18
|
+
const g = guidance(code, vars, { real: true });
|
|
19
19
|
return [code, Object.freeze({ agentAction: g.agent_action, remedy: remedyLine(g), vars })];
|
|
20
20
|
})));
|
|
21
21
|
}
|
|
@@ -35,7 +35,7 @@ export function assessPoolHealth({ status, personas, intervalSeconds, nowMs, job
|
|
|
35
35
|
const record = { ok: false, reason, agentAction, ...extra };
|
|
36
36
|
if (vars) {
|
|
37
37
|
// Re-rendered with the persona this assessment is about.
|
|
38
|
-
const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona });
|
|
38
|
+
const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona }, { real: true });
|
|
39
39
|
remedy = remedyLine(g);
|
|
40
40
|
Object.assign(record, { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help });
|
|
41
41
|
}
|