@botbuddy/cli 1.32.1 → 1.32.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-doctor.mjs +123 -18
- package/src/agent-session.mjs +0 -0
- package/src/commands.mjs +4 -0
- package/src/pw/args.mjs +62 -2
- package/src/pw/run.mjs +70 -12
- package/src/stack.mjs +248 -44
package/package.json
CHANGED
package/src/agent-doctor.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// BOT-1649 — diagnose and repair a worktree's short-lived agent credential
|
|
2
2
|
// without ever printing the token unless the operator explicitly requests an
|
|
3
3
|
// export snippet for the current shell.
|
|
4
|
+
import { hostname } from "node:os";
|
|
4
5
|
import { readAgentBinding } from "./wait-profile.mjs";
|
|
5
|
-
import { resolveOwnerToken } from "./cli-credentials.mjs";
|
|
6
|
+
import { resolveOwnerToken, CLIENT_KEY_SERVICE, OWNER_TOKEN_SERVICE } from "./cli-credentials.mjs";
|
|
7
|
+
import { readKeychainSecret } from "./agent-credential-store.mjs";
|
|
6
8
|
import { getConfig } from "./config.mjs";
|
|
7
9
|
import { isAgentStateFresh, readAgentState } from "./agent-state.mjs";
|
|
8
10
|
import { selfHealAgentSession } from "./agent-session.mjs";
|
|
11
|
+
import { machineUuid } from "./machine-id.mjs";
|
|
9
12
|
|
|
10
13
|
export function parseDoctorArgs(argv = []) {
|
|
11
14
|
const parsed = { fix: false, printExports: false, json: false, errors: [] };
|
|
@@ -29,54 +32,134 @@ function shellQuote(value) {
|
|
|
29
32
|
* `expired` (not `available`) so `--fix` does not try to repair the cache and the
|
|
30
33
|
* verdict points at `bb login` instead of a bogus binding repair.
|
|
31
34
|
*/
|
|
32
|
-
function clientKeyStatus(owner, nowMs) {
|
|
35
|
+
function clientKeyStatus(owner, nowMs, readFailed = false) {
|
|
36
|
+
// BOT-1750 (Codex R5 P2): a credential-store READ FAILURE (locked/erroring
|
|
37
|
+
// Keychain) is distinct from an absent key — reporting it as `missing` would send
|
|
38
|
+
// the operator to `bb login` when the real remedy is to unlock the store.
|
|
39
|
+
if (readFailed) return { status: "unavailable" };
|
|
33
40
|
if (!owner?.token) return { status: "missing" };
|
|
34
41
|
if (typeof owner.expiresAt === "number" && owner.expiresAt <= nowMs) return { status: "expired" };
|
|
35
42
|
return { status: "available" };
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
/**
|
|
46
|
+
* BOT-1750 (Codex R3/R5 P2): the recovery for a missing/unreadable repair
|
|
47
|
+
* prerequisite, in priority order — a credential-store FAILURE first (unlock it,
|
|
48
|
+
* not `bb login`), then an absent/expired key, then a missing binding. Returns null
|
|
49
|
+
* when every prerequisite is satisfied.
|
|
50
|
+
*/
|
|
51
|
+
function prerequisiteRecovery(report) {
|
|
52
|
+
if (report.client_key.status === "unavailable") {
|
|
53
|
+
return "the client key could not be read (locked or failing credential store); unlock it, then run `bb doctor --fix`";
|
|
54
|
+
}
|
|
55
|
+
if (report.client_key.status === "missing" || report.client_key.status === "expired") {
|
|
56
|
+
return "run `bb login`, then `bb doctor --fix`";
|
|
57
|
+
}
|
|
58
|
+
if (report.binding.status === "missing") {
|
|
59
|
+
return "add a valid .botbuddy-agent.json tenant binding, then run `bb doctor --fix`";
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* BOT-1750 (Codex R6 P2): recovery for a self-heal that THREW during the repair pass
|
|
66
|
+
* (the Keychain can lock/error between the first probe and the register read). A
|
|
67
|
+
* credential-store failure must point at unlocking the store — not `bb login` or a
|
|
68
|
+
* binding repair — matching prerequisiteRecovery's wording.
|
|
69
|
+
*/
|
|
70
|
+
function repairFailureRecovery(code) {
|
|
71
|
+
if (code === "client_key_unavailable") {
|
|
72
|
+
return "the client key could not be read (locked or failing credential store); unlock it, then run `bb doctor --fix`";
|
|
73
|
+
}
|
|
74
|
+
// BOT-1750 (Codex R9 P2): `session_mint_unauthorized` means a locally-unexpired
|
|
75
|
+
// client key was revoked/rejected server-side — only a fresh login replaces it, so
|
|
76
|
+
// point at `bb login`, not a binding repair (the binding is valid).
|
|
77
|
+
if (code === "client_key_required" || code === "session_mint_unauthorized") return "run `bb login`, then `bb doctor --fix`";
|
|
78
|
+
// BOT-1750 (Codex R10 P2): a transport/server mint failure (or a malformed mint
|
|
79
|
+
// response) is not a binding problem — the binding was verified available before
|
|
80
|
+
// repair began. Point at retrying rather than editing a valid .botbuddy-agent.json.
|
|
81
|
+
if (code === "session_mint_failed" || code === "session_mint_invalid") return "the BotBuddy service could not mint a session (transient/server error); retry `bb doctor --fix`";
|
|
82
|
+
return "repair .botbuddy-agent.json, then run `bb doctor --fix`";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* BOT-1750 (AC-4): fold the server's stack-lease capability verdict (from a mint)
|
|
87
|
+
* into the report. `established` clears the line; anything else records the reason
|
|
88
|
+
* and points the operator at the missing attestation.
|
|
89
|
+
*/
|
|
90
|
+
function applyStackLeaseCapability(report, capability) {
|
|
91
|
+
if (!capability || typeof capability.status !== "string") return;
|
|
92
|
+
report.stack_lease.capability = capability.status;
|
|
93
|
+
if (capability.reason) report.stack_lease.reason = capability.reason;
|
|
94
|
+
if (capability.status !== "established" && !report.recovery) {
|
|
95
|
+
report.recovery = `stack leases need a device-consented Helper on this machine: ${capability.reason ?? "worktree not attested"}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
38
99
|
/** Inspect a worktree credential. Only `exports` ever contains the secret. */
|
|
39
100
|
export async function doctorAgentAuth({
|
|
40
101
|
cwd = process.cwd(), env = process.env, fix = false, printExports = false,
|
|
41
102
|
readState = readAgentState, isFresh = isAgentStateFresh, now = () => Date.now(),
|
|
42
|
-
binding = readAgentBinding,
|
|
103
|
+
binding = readAgentBinding,
|
|
104
|
+
// BOT-1750 (Codex R5 P2): read the client key STRICTLY here too, so doctor's own
|
|
105
|
+
// probe distinguishes a locked/failing credential store (throws → `unavailable`)
|
|
106
|
+
// from a genuinely absent key (`missing`) — the strict read must not live only
|
|
107
|
+
// inside selfHeal, which the `--fix` gate skips when it reports the key missing.
|
|
108
|
+
ownerToken = () => resolveOwnerToken({
|
|
109
|
+
getConfig,
|
|
110
|
+
keychainRead: () => readKeychainSecret(CLIENT_KEY_SERVICE, { strict: true }),
|
|
111
|
+
keychainReadLegacy: () => readKeychainSecret(OWNER_TOKEN_SERVICE, { strict: true }),
|
|
112
|
+
}),
|
|
43
113
|
selfHeal = selfHealAgentSession,
|
|
114
|
+
hostFn = hostname, machineUuidFn = machineUuid,
|
|
44
115
|
} = {}) {
|
|
45
|
-
|
|
46
|
-
|
|
116
|
+
let owner = null;
|
|
117
|
+
let ownerReadFailed = false;
|
|
118
|
+
const [state, bound] = await Promise.all([
|
|
119
|
+
readState(cwd), binding(cwd).catch(() => null),
|
|
47
120
|
]);
|
|
121
|
+
try { owner = await ownerToken(); } catch { ownerReadFailed = true; }
|
|
48
122
|
const fresh = Boolean(state && isFresh(state, new Date()));
|
|
49
123
|
const report = {
|
|
50
124
|
schema_version: 1,
|
|
51
125
|
worktree: cwd,
|
|
52
126
|
binding: bound?.tenant ? { status: "available", tenant: bound.tenant } : { status: "missing" },
|
|
53
|
-
client_key: clientKeyStatus(owner, now()),
|
|
127
|
+
client_key: clientKeyStatus(owner, now(), ownerReadFailed),
|
|
54
128
|
session: fresh
|
|
55
129
|
? { status: "fresh", agent_id: state.agent_id, session_id: state.session_id, expires_at: state.expires_at }
|
|
56
130
|
: { status: state ? "stale" : "missing" },
|
|
131
|
+
// BOT-1750 (AC-3/AC-4): the worktree's stack-lease readiness. `machine_id_readable`
|
|
132
|
+
// only reports that a hardware id could be read (true on almost any host) — it is
|
|
133
|
+
// NOT attestation (Codex P2): a machine is only attested when a device-consented
|
|
134
|
+
// Helper backs it, which the server's `capability` verdict reflects. The verdict
|
|
135
|
+
// is known only after a register call, so a plain `bb doctor` reports `unverified`
|
|
136
|
+
// and `--fix` re-registers to (re)establish it (`established` | `unattested`).
|
|
137
|
+
stack_lease: {
|
|
138
|
+
host: hostFn(),
|
|
139
|
+
machine_id_readable: Boolean(machineUuidFn()),
|
|
140
|
+
capability: "unverified",
|
|
141
|
+
},
|
|
57
142
|
};
|
|
58
143
|
let credential = fresh
|
|
59
144
|
? { token: state.agent_session_token, sessionId: state.session_id, agentId: state.agent_id, source: "cache" }
|
|
60
145
|
: null;
|
|
61
|
-
|
|
146
|
+
// BOT-1750 (Codex P1): repair on `--fix` even when the cache is FRESH — otherwise
|
|
147
|
+
// `bb doctor --fix` (the remedy the stack refusal points at) would reuse a fresh but
|
|
148
|
+
// never-attested session and never re-establish the capability. `force` makes the
|
|
149
|
+
// self-heal run a registration/attestation round regardless of cache freshness.
|
|
150
|
+
if (fix && report.binding.status === "available" && report.client_key.status === "available") {
|
|
62
151
|
try {
|
|
63
|
-
credential = await selfHeal({ cwd, env });
|
|
152
|
+
credential = await selfHeal({ cwd, env, force: true });
|
|
64
153
|
report.session = { status: credential.source === "minted" ? "minted" : credential.source, agent_id: credential.agentId ?? null, session_id: credential.sessionId ?? null };
|
|
154
|
+
applyStackLeaseCapability(report, credential.stackLeaseCapability);
|
|
65
155
|
} catch (error) {
|
|
66
156
|
report.session = { status: "repair_failed", error: error?.code ?? "agent_session_unavailable" };
|
|
67
|
-
report.recovery = error?.code
|
|
68
|
-
? "run `bb login`, then `bb doctor --fix`"
|
|
69
|
-
: "repair .botbuddy-agent.json, then run `bb doctor --fix`";
|
|
157
|
+
report.recovery = repairFailureRecovery(error?.code);
|
|
70
158
|
return { exitCode: 3, report, exports: null };
|
|
71
159
|
}
|
|
72
160
|
}
|
|
73
161
|
if (!credential) {
|
|
74
|
-
|
|
75
|
-
report.recovery = needsLogin
|
|
76
|
-
? "run `bb login`, then `bb doctor --fix`"
|
|
77
|
-
: report.binding.status === "missing"
|
|
78
|
-
? "add a valid .botbuddy-agent.json tenant binding, then run `bb doctor --fix`"
|
|
79
|
-
: "run `bb doctor --fix` to mint a replacement session";
|
|
162
|
+
report.recovery = prerequisiteRecovery(report) ?? "run `bb doctor --fix` to mint a replacement session";
|
|
80
163
|
return { exitCode: 3, report, exports: null };
|
|
81
164
|
}
|
|
82
165
|
const exports = printExports
|
|
@@ -85,9 +168,31 @@ export async function doctorAgentAuth({
|
|
|
85
168
|
...(credential.sessionId ? [`export BOTBUDDY_SESSION_ID=${shellQuote(credential.sessionId)}`] : []),
|
|
86
169
|
].join("\n")
|
|
87
170
|
: null;
|
|
171
|
+
// BOT-1750 (Codex R3 P2): when `--fix` could not even attempt a repair because a
|
|
172
|
+
// prerequisite is missing (no tenant binding, or no/expired client key) yet a fresh
|
|
173
|
+
// cached session left `credential` populated, report THAT prerequisite — not the
|
|
174
|
+
// Helper/capability message below, since registration never ran and the real remedy
|
|
175
|
+
// is `bb login` or restoring the binding.
|
|
176
|
+
if (fix && (report.binding.status !== "available" || report.client_key.status !== "available")) {
|
|
177
|
+
report.recovery = prerequisiteRecovery(report) ?? "run `bb doctor --fix` to mint a replacement session";
|
|
178
|
+
return { exitCode: 3, report, exports };
|
|
179
|
+
}
|
|
180
|
+
// BOT-1750 (Codex R2 P1): `bb doctor --fix` is the advertised remedy for a stack-
|
|
181
|
+
// lease refusal, so it must not report success while the worktree's capability is
|
|
182
|
+
// still not `established` — including an `unattested` verdict or an omitted verdict
|
|
183
|
+
// from an older server (which leaves it `unverified`). The session is still minted
|
|
184
|
+
// and usable for wait/run/pw, but the exit fails closed so the lease-refusal remedy
|
|
185
|
+
// never falsely reports fixed. A plain `bb doctor` (no --fix) stays exit 0: it is a
|
|
186
|
+
// read-only report that has not attempted to establish anything.
|
|
187
|
+
if (fix && report.stack_lease.capability !== "established") {
|
|
188
|
+
if (!report.recovery) {
|
|
189
|
+
report.recovery = "session minted, but this worktree's stack-lease capability is not established — no device-consented Helper attests this machine, so `bb stack` will still be refused.";
|
|
190
|
+
}
|
|
191
|
+
return { exitCode: 3, report, exports };
|
|
192
|
+
}
|
|
88
193
|
return { exitCode: 0, report, exports };
|
|
89
194
|
}
|
|
90
195
|
|
|
91
196
|
export function doctorHelp() {
|
|
92
|
-
return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding,
|
|
197
|
+
return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session, and this worktree's stack-lease capability (registered host + attested machine). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports.";
|
|
93
198
|
}
|
package/src/agent-session.mjs
CHANGED
|
Binary file
|
package/src/commands.mjs
CHANGED
|
@@ -227,6 +227,10 @@ export async function cmdDoctor(args, { log = (line) => console.log(line), error
|
|
|
227
227
|
log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
|
|
228
228
|
log(`client key: ${result.report.client_key.status}`);
|
|
229
229
|
log(`agent session: ${result.report.session.status}`);
|
|
230
|
+
if (result.report.stack_lease) {
|
|
231
|
+
const sl = result.report.stack_lease;
|
|
232
|
+
log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
|
|
233
|
+
}
|
|
230
234
|
if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
|
|
231
235
|
}
|
|
232
236
|
if (result.exports) log(result.exports);
|
package/src/pw/args.mjs
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { TARGET_VERBS, parseTargetFlags, hasTargetFlags, buildLocatorTarget, classifyTarget, isSnapshotRef } from "./targets.mjs";
|
|
2
2
|
import { TRANSLATE_VERBS, planTranslateVerb, MAX_EXEC_ARG } from "./translate.mjs";
|
|
3
3
|
export const GLOBAL_VERBS = new Set(["list", "close-all", "kill-all", "reap"]);
|
|
4
|
+
// BOT-1682: the bb pw credential/session flags. They are accepted ONLY before the
|
|
5
|
+
// lane (consumed in run.mjs). Once one reaches planInvocation it is after the lane
|
|
6
|
+
// — the verb slot or the workload — which upstream @playwright/cli would reject
|
|
7
|
+
// with an "Unknown option" + help dump; catch it here and name the fix (move it
|
|
8
|
+
// before the lane) instead. `--tenant` after the lane also cannot be safely
|
|
9
|
+
// honored for session/lane auth, another reason to refuse rather than forward it.
|
|
10
|
+
export const PW_CREDENTIAL_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
|
|
11
|
+
const credentialFlagOf = (token) => PW_CREDENTIAL_FLAGS.find((flag) => token === flag || String(token).startsWith(`${flag}=`)) ?? null;
|
|
12
|
+
function misplacedCredentialFlag(token) {
|
|
13
|
+
return new Error(`bb-pw: ${credentialFlagOf(token)} is a bb pw credential flag, not a Playwright argument — put it before the lane: bb-pw [--tenant <slug>] <lane> <verb> …`);
|
|
14
|
+
}
|
|
15
|
+
// BOT-1682: verbs that once existed under bb pw / an older @playwright/cli and now
|
|
16
|
+
// map to a differently-named upstream command. On these, hint the rename in one
|
|
17
|
+
// line instead of dumping ~110 lines of upstream usage. `console` and `status`
|
|
18
|
+
// are NOT here — they are live commands.
|
|
19
|
+
export const RETIRED_VERBS = { network: "use 'requests' (list) or 'request-headers <n>' / 'request <n>'" };
|
|
20
|
+
// BOT-1682: everything after a bare `--` is a positional Playwright argument (the
|
|
21
|
+
// upstream CLI treats `fill e1 -- --tenant` as literal text), so misplaced-flag
|
|
22
|
+
// detection must not look past it. Mirrors legacyAgentAliasHint's own `--` cutoff.
|
|
23
|
+
const beforeSeparator = (tokens) => { const sep = tokens.indexOf("--"); return sep === -1 ? tokens : tokens.slice(0, sep); };
|
|
4
24
|
const secret = /^@ENV:(.+)$/;
|
|
5
25
|
export function resolveRef(value, env = process.env) {
|
|
6
26
|
const match = secret.exec(String(value));
|
|
@@ -12,16 +32,51 @@ export const normaliseLane = (token) => String(token).replace(/^agent-0*/, "").r
|
|
|
12
32
|
function validLane(lane) { return /^[1-9]\d*$/.test(lane); }
|
|
13
33
|
export function planInvocation(argv, env = process.env) {
|
|
14
34
|
if (!argv.length) throw new Error("bb-pw: usage: bb-pw <lane> <verb> [args…] (or: bb-pw <reap|list|close-all|kill-all>)");
|
|
15
|
-
if (GLOBAL_VERBS.has(argv[0]))
|
|
35
|
+
if (GLOBAL_VERBS.has(argv[0])) {
|
|
36
|
+
// BOT-1682: a credential flag after a global verb (`bb-pw list --tenant …`)
|
|
37
|
+
// has no lane to sit before, and would otherwise be forwarded to upstream —
|
|
38
|
+
// which rejects it with its own "Unknown option" dump. Refuse it here too.
|
|
39
|
+
// Global verbs (list/close-all/kill-all/reap) take NO positional workload, so
|
|
40
|
+
// unlike lane verbs there is no `--` exemption — a credential-shaped token in
|
|
41
|
+
// ANY position (incl. after `--`) is misplaced, never a literal value that
|
|
42
|
+
// upstream would accept (Codex: `list -- --tenant` otherwise reaches upstream,
|
|
43
|
+
// which errors "too many arguments").
|
|
44
|
+
const strayGlobal = argv.slice(1).find((value) => credentialFlagOf(value));
|
|
45
|
+
if (strayGlobal) throw misplacedCredentialFlag(strayGlobal);
|
|
46
|
+
return argv[0] === "reap" ? { scope: "global", verb: "reap", mode: "reap" } : { scope: "global", verb: argv[0], mode: "exec", execArgv: argv };
|
|
47
|
+
}
|
|
16
48
|
const lane = normaliseLane(argv[0]), verb = argv[1];
|
|
17
49
|
if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be a positive integer and include a verb`);
|
|
18
|
-
|
|
50
|
+
// BOT-1682: a credential flag in the verb slot (`<lane> --tenant …`) or a
|
|
51
|
+
// retired verb — caught before status/target handling so neither leaks upstream.
|
|
52
|
+
if (credentialFlagOf(verb)) throw misplacedCredentialFlag(verb);
|
|
53
|
+
if (Object.hasOwn(RETIRED_VERBS, verb)) throw new Error(`bb-pw: '${verb}' was retired — ${RETIRED_VERBS[verb]}`);
|
|
54
|
+
if (verb === "status") {
|
|
55
|
+
// `status` has no workload, so — like the global verbs — there is no `--`
|
|
56
|
+
// exemption: a credential flag in ANY position (incl. after `--`) is misplaced
|
|
57
|
+
// and must be refused, or `status -- --tenant` would return success while
|
|
58
|
+
// silently ignoring it and still leaving the id readable downstream (Codex).
|
|
59
|
+
const strayStatus = argv.slice(2).find((value) => credentialFlagOf(value));
|
|
60
|
+
if (strayStatus) throw misplacedCredentialFlag(strayStatus);
|
|
61
|
+
return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "status" };
|
|
62
|
+
}
|
|
19
63
|
const rest = argv.slice(2); let forwarded = rest, target = null, fresh = false;
|
|
20
64
|
// BOT-1702: bb-pw OWNS upload/route/unroute and translates them to upstream
|
|
21
65
|
// `run-code` (see translate.mjs). A resolved `@ENV:` secret in any value routes
|
|
22
66
|
// the generated code through the socket path so it never lands in argv, and is
|
|
23
67
|
// tracked for redaction — mirroring the sibling verbs' guarantee.
|
|
24
68
|
if (TRANSLATE_VERBS.has(verb)) {
|
|
69
|
+
// BOT-1682: run the misplaced-credential-flag guard BEFORE dispatching a
|
|
70
|
+
// translated verb (upload/route/unroute/route-list), so `route … stall --tenant …`
|
|
71
|
+
// gets the one-line "put it before the lane" refusal rather than an unrelated
|
|
72
|
+
// translate-parse error (Codex). But a translated verb's parsers consume the
|
|
73
|
+
// token AFTER an option as that option's VALUE (`upload --label --tenant`,
|
|
74
|
+
// `route … fulfill --body --session-token`), so a credential-shaped token whose
|
|
75
|
+
// predecessor is an option (`--x`) is a legitimate value and is left alone;
|
|
76
|
+
// only one in a non-value position is misplaced (Codex R17). `--` still exempts.
|
|
77
|
+
const scanT = beforeSeparator(rest);
|
|
78
|
+
const strayTranslate = scanT.find((value, i) => credentialFlagOf(value) && !(i > 0 && /^--[a-z]/.test(String(scanT[i - 1]))));
|
|
79
|
+
if (strayTranslate) throw misplacedCredentialFlag(strayTranslate);
|
|
25
80
|
const { code, secretValues } = planTranslateVerb(verb, rest, { env, resolveRef });
|
|
26
81
|
// Use the daemon socket (JSON, no argv limit) for a resolved secret OR a
|
|
27
82
|
// payload too large for a single child-process argument (Codex R2 P2: a big
|
|
@@ -37,6 +92,11 @@ export function planInvocation(argv, env = process.env) {
|
|
|
37
92
|
target = forwarded.find((value) => !String(value).startsWith("--")) ?? null;
|
|
38
93
|
if (fresh && target && isSnapshotRef(target)) throw new Error("bb-pw: --fresh cannot target a snapshot ref; use a stable locator");
|
|
39
94
|
}
|
|
95
|
+
// BOT-1682: a credential flag surviving into the workload (`<lane> <verb> …
|
|
96
|
+
// --tenant …`) is misplaced — target-flag VALUES have already been consumed by
|
|
97
|
+
// parseTargetFlags, so only a genuine standalone credential-flag token remains.
|
|
98
|
+
const stray = beforeSeparator(forwarded).find((value) => credentialFlagOf(value));
|
|
99
|
+
if (stray) throw misplacedCredentialFlag(stray);
|
|
40
100
|
const resolved = forwarded.map((value) => resolveRef(value, env));
|
|
41
101
|
const sensitive = resolved.some((value) => value.secret);
|
|
42
102
|
return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: sensitive ? "socket" : "exec", execArgv: sensitive ? null : [`-s=lane-${lane}`, verb, ...forwarded], socketArgs: sensitive ? [verb, ...resolved.map((value) => value.value)] : null, telemetryUrl: ["goto", "open", "go-back", "go-forward", "reload"].includes(verb) ? (resolved[0]?.secret ? resolved[0].ref : forwarded[0] ?? null) : null, secretValues: resolved.filter((value) => value.secret).map((value) => String(value.value)), rollup: verb === "close", target, targetKind: target === null ? null : classifyTarget(target), fresh };
|
package/src/pw/run.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
|
-
import { planInvocation } from "./args.mjs";
|
|
2
|
+
import { planInvocation, GLOBAL_VERBS } from "./args.mjs";
|
|
3
3
|
import { actionTypeFromMethod, NAV } from "./readiness.mjs";
|
|
4
4
|
import { isStaleRefError, staleRefRemediation } from "./targets.mjs";
|
|
5
5
|
import { createSessionTokenCoordinator } from "./coordinator.mjs";
|
|
@@ -13,7 +13,7 @@ import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredent
|
|
|
13
13
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
14
14
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
15
15
|
const hostFor = (env) => canonicalizeHostString(env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname());
|
|
16
|
-
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] [--agent-session-token <token>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN);\n --agent-key / --session-token are one-release legacy aliases.\n--tenant <slug>
|
|
16
|
+
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] [--agent-session-token <token>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN);\n --agent-key / --session-token are one-release legacy aliases.\n--tenant <slug> accepted before the lane but currently INERT — the tenant\n comes from your resolved credential, so bb pw does not act on\n this flag for session/lane auth yet (server-validated tenant\n selection is tracked in BOT-1732).\n\nCredential flags (--tenant / --session-id / --agent-session-token) go BEFORE the\nlane. Placed after the lane they are refused with guidance (they are not\nPlaywright arguments, and a --tenant after the lane cannot be safely honored).\n\nFile upload (BOT-1702) — works on a hidden <input type=file>:\n bb-pw <lane> upload <selector> <path...>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,size=<bytes>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,bytes=@base64:<b64>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,text=<str>\n --inline repeats; builds synthetic files in memory (no workstation FS).\n <selector> also accepts --testid/--role/--label/--text like click/fill.\n\nNetwork mocking (BOT-1702) — routes persist on the lane across invocations:\n bb-pw <lane> route <url-glob> stall hold the request pending\n (screenshot the loading state)\n bb-pw <lane> route <url-glob> abort [--error <code>] fail the request (default: failed)\n bb-pw <lane> route <url-glob> fulfill --status <n> [--content-type <ct>]\n [--body <str|@base64:..|@file:PATH>]\n bb-pw <lane> unroute [<url-glob>] release one route, or all\n bb-pw <lane> route-list list the routes owned on this lane\n (offline mode already exists upstream: bb-pw <lane> network-state-set offline)\n"); }
|
|
17
17
|
// Redact LONGEST secrets first: replacing a short value that is a substring of a
|
|
18
18
|
// longer secret (e.g. a filename that also appears inside the payload's base64)
|
|
19
19
|
// would otherwise break the longer match and leave the remainder recoverable
|
|
@@ -114,14 +114,58 @@ export async function runPw(argv, deps = {}) {
|
|
|
114
114
|
// while-loop in runPwInner). Everything after the first non-option (the lane) is a
|
|
115
115
|
// Playwright workload argument and is never a BotBuddy credential.
|
|
116
116
|
const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
|
|
117
|
+
const selfHealErrorLine = (error) => `bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`;
|
|
118
|
+
// The leading credential options, NORMALIZED to split `flag value` pairs so an
|
|
119
|
+
// inline `--agent-key=<token>` is recognized by legacyAgentAliasHint (which
|
|
120
|
+
// matches exact flag tokens) exactly like the split form. Credential flags are
|
|
121
|
+
// only accepted BEFORE the lane (BOT-1682 P1: `--tenant` after the lane cannot be
|
|
122
|
+
// safely honored for session/lane auth and is refused, see planInvocation), so
|
|
123
|
+
// this leading scan is the whole surface the alias hint needs to consider.
|
|
117
124
|
function leadingPwOptions(args) {
|
|
118
125
|
const lead = [];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
let rest = [...args];
|
|
127
|
+
let match;
|
|
128
|
+
while ((match = matchWrapperFlag(rest[0]))) {
|
|
129
|
+
lead.push(match.flag);
|
|
130
|
+
if (match.inline !== null) { lead.push(match.inline); rest = rest.slice(1); }
|
|
131
|
+
else { if (rest[1] === undefined) break; lead.push(rest[1]); rest = rest.slice(2); }
|
|
122
132
|
}
|
|
123
133
|
return lead;
|
|
124
134
|
}
|
|
135
|
+
// BOT-1682: match a leading credential flag in either `--flag value` or
|
|
136
|
+
// `--flag=value` form. Returns the base flag + its inline value (null when the
|
|
137
|
+
// value is the NEXT token), or null when the token is not a credential flag.
|
|
138
|
+
function matchWrapperFlag(token) {
|
|
139
|
+
for (const flag of PW_LEADING_FLAGS) {
|
|
140
|
+
if (token === flag) return { flag, inline: null };
|
|
141
|
+
if (typeof token === "string" && token.startsWith(`${flag}=`)) return { flag, inline: token.slice(flag.length + 1) };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const applyWrapperFlag = (deps, flag, value) => flag === "--tenant"
|
|
146
|
+
? { ...deps, tenant: value }
|
|
147
|
+
: ["--agent-session-token", "--agent-key", "--session-token"].includes(flag)
|
|
148
|
+
? { ...deps, sessionToken: value }
|
|
149
|
+
: { ...deps, sessionId: value };
|
|
150
|
+
// Consume a run of credential flags from `list` starting at `start`, folding each
|
|
151
|
+
// into deps. Returns { deps, rest } on success, or { error: flag } when a flag
|
|
152
|
+
// has no value. `rest` is the tokens after the consumed run.
|
|
153
|
+
function consumeWrapperFlags(list, deps, start = 0) {
|
|
154
|
+
let rest = list.slice(start);
|
|
155
|
+
let match;
|
|
156
|
+
while ((match = matchWrapperFlag(rest[0]))) {
|
|
157
|
+
let value;
|
|
158
|
+
if (match.inline !== null) { value = match.inline; rest = rest.slice(1); }
|
|
159
|
+
else { if (rest[1] === undefined) return { error: match.flag }; value = rest[1]; rest = rest.slice(2); }
|
|
160
|
+
// An empty value (`--flag=` or `--flag ""`) is a usage error, not a silent
|
|
161
|
+
// no-op: an empty session token would make `!deps.sessionToken` true and fall
|
|
162
|
+
// back to another credential, executing under a different identity (Codex P2).
|
|
163
|
+
// Matches the old `if (!args[1])` guard this helper replaced.
|
|
164
|
+
if (value === "") return { error: match.flag };
|
|
165
|
+
deps = applyWrapperFlag(deps, match.flag, value);
|
|
166
|
+
}
|
|
167
|
+
return { deps, rest };
|
|
168
|
+
}
|
|
125
169
|
|
|
126
170
|
async function runPwInner(argv, deps = {}) {
|
|
127
171
|
const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
|
|
@@ -137,7 +181,14 @@ async function runPwInner(argv, deps = {}) {
|
|
|
137
181
|
// session identity alongside --session-id, so a token-armed session need not
|
|
138
182
|
// pass an id. Holder matching still rides on the server's owner_is_caller and
|
|
139
183
|
// the resolved agent ids (gate()).
|
|
140
|
-
|
|
184
|
+
// Credential flags are accepted ONLY as LEADING options (before the lane). A
|
|
185
|
+
// `--tenant` after the lane cannot be safely honored — bb pw would authenticate
|
|
186
|
+
// with the resolved session/key regardless of the named tenant, so a tenant B
|
|
187
|
+
// request could run under tenant A's cached session (Codex P1). Anything
|
|
188
|
+
// credential-flag-shaped still present once we reach the verb is refused by
|
|
189
|
+
// planInvocation with guidance to move it before the lane; a real, server-pinned
|
|
190
|
+
// `--tenant` lane fallback is BOT-1732.
|
|
191
|
+
{ const consumed = consumeWrapperFlags(args, deps, 0); if (consumed.error) { stderr.write(`bb-pw: ${consumed.error} needs a value\n`); return 2; } deps = consumed.deps; args = consumed.rest; }
|
|
141
192
|
let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
|
|
142
193
|
const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
|
|
143
194
|
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); }
|
|
@@ -146,12 +197,11 @@ async function runPwInner(argv, deps = {}) {
|
|
|
146
197
|
let managedCredential = null;
|
|
147
198
|
if (env.BB_PW_NO_LOCK !== "1" && !deps.sessionToken && !readAgentSessionTokenEnv(env) && (!deps.coordinator || deps.resolveCredential)) {
|
|
148
199
|
try {
|
|
149
|
-
const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd() });
|
|
200
|
+
const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd(), sessionId: deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null, flagToken: null });
|
|
150
201
|
managedCredential = credential;
|
|
151
202
|
deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
|
|
152
203
|
} catch (error) {
|
|
153
|
-
stderr.write(
|
|
154
|
-
return 3;
|
|
204
|
+
stderr.write(selfHealErrorLine(error)); return 3;
|
|
155
205
|
}
|
|
156
206
|
}
|
|
157
207
|
const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env }); const host = deps.host ?? hostFor(env);
|
|
@@ -161,9 +211,17 @@ async function runPwInner(argv, deps = {}) {
|
|
|
161
211
|
// allowed:false with NO errorCode and must never rotate the session (Codex round-5).
|
|
162
212
|
if (!auth.allowed && !deps.coordinator && auth.errorCode != null && isRejectedCachedMcpSession({ auth: true, code: auth.errorCode }, managedCredential?.source, 0)) {
|
|
163
213
|
await clearAgentState(deps.cwd ?? process.cwd());
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
214
|
+
try {
|
|
215
|
+
const credential = await (deps.resolveCredential ?? resolveAgentSessionCredential)({ argv, env, cwd: deps.cwd ?? process.cwd(), sessionId: deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null, flagToken: null });
|
|
216
|
+
managedCredential = credential;
|
|
217
|
+
// Keep an explicit --session-id holder authoritative over the freshly minted
|
|
218
|
+
// one, same precedence as the initial resolution (Codex).
|
|
219
|
+
deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null };
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// BOT-1682: the renewal after a revoked cache can also fail self-heal; fail
|
|
222
|
+
// closed with a clear message rather than letting the error escape to exit 1.
|
|
223
|
+
stderr.write(selfHealErrorLine(error)); return 3;
|
|
224
|
+
}
|
|
167
225
|
auth = await gate({ env, host, lane: plan.lane, deps });
|
|
168
226
|
}
|
|
169
227
|
if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
|
package/src/stack.mjs
CHANGED
|
@@ -30,7 +30,9 @@ import { randomUUID } from "crypto";
|
|
|
30
30
|
import { callToolJson } from "./api.mjs";
|
|
31
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
32
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
33
|
-
import { AGENT_KEY_RE,
|
|
33
|
+
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
34
|
+
import { resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
35
|
+
import { clearAgentState } from "./agent-state.mjs";
|
|
34
36
|
import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
|
|
35
37
|
import { projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
36
38
|
import { machineUuid } from "./machine-id.mjs";
|
|
@@ -347,30 +349,176 @@ export function parseSupabaseStatus(text) {
|
|
|
347
349
|
|
|
348
350
|
// ── runtime (network / process) ──────────────────────────────────────────────
|
|
349
351
|
|
|
350
|
-
// BOT-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
352
|
+
// BOT-1750: a stack lease is a host-bound operation, so it must resolve its
|
|
353
|
+
// session credential through the SAME shared resolver `bb wait`/`run`/`pw` use —
|
|
354
|
+
// exported token, then the fresh per-worktree `.botbuddy-agent.json` cache, then
|
|
355
|
+
// ONE self-heal that re-establishes this worktree's host + machine attestation.
|
|
356
|
+
// That is what makes the lease resolve to the agent row carrying `host`, instead
|
|
357
|
+
// of the hostless tier-2 MCP key that produced `AGENT_HOST_REQUIRED` (BOT-1599
|
|
358
|
+
// fixed only the case where the token was already exported in the environment).
|
|
355
359
|
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
// When the resolver has nothing to work with (no client key or no tenant binding
|
|
361
|
+
// to self-heal from — e.g. an operator running `stack status` with only a tier-2
|
|
362
|
+
// MCP key), fall through to the BOT-1520 Keychain-backed OAuth/MCP-key path. That
|
|
363
|
+
// credential is valid for operator commands but is NOT the lease path; the lease
|
|
364
|
+
// RPC will refuse it with an actionable AGENT_HOST_REQUIRED message (see cmdUp).
|
|
365
|
+
export async function stackAuthHeader({
|
|
366
|
+
argv = process.argv.slice(2),
|
|
367
|
+
env = process.env,
|
|
368
|
+
cwd = process.cwd(),
|
|
369
|
+
resolveCredential = resolveAgentSessionCredential,
|
|
370
|
+
resolveAgentKeyFn = resolveAgentKey,
|
|
371
|
+
resolveOwnerTokenFn = () => resolveOwnerToken({ getConfig }),
|
|
372
|
+
} = {}) {
|
|
373
|
+
let credential = null;
|
|
374
|
+
try {
|
|
375
|
+
credential = await resolveCredential({ argv, env, cwd });
|
|
376
|
+
} catch (error) {
|
|
377
|
+
// BOT-1750 (Codex P2): only the "nothing to self-heal from" cases fall through
|
|
378
|
+
// to the operator credential path — no durable client key, or no tenant binding.
|
|
379
|
+
// A transport/server/tenant/malformed self-heal failure must FAIL CLOSED, not
|
|
380
|
+
// silently switch principals: for `up` that would turn a real error into a
|
|
381
|
+
// misleading host-registration refusal, and for owner-scoped `status`/`touch`/
|
|
382
|
+
// `done` it would run as a different agent that cannot see the original lease.
|
|
383
|
+
if (error?.code !== "client_key_required" && error?.code !== "tenant_binding_required") throw error;
|
|
384
|
+
credential = null;
|
|
385
|
+
}
|
|
386
|
+
if (credential) {
|
|
387
|
+
// BOT-1750 (Codex R3 P2): a resolver that RETURNED a credential must yield a
|
|
388
|
+
// well-formed session token. A nonempty-but-malformed token (a corrupt cache or
|
|
389
|
+
// an incompatible server response) must FAIL CLOSED — not skip the session header
|
|
390
|
+
// and silently authenticate as the OAuth/MCP-key principal, which is the exact
|
|
391
|
+
// principal switch the catch above guards against.
|
|
392
|
+
if (typeof credential.token === "string" && AGENT_KEY_RE.test(credential.token)) {
|
|
393
|
+
return withSource({ "x-agent-api-key": credential.token }, credential.source);
|
|
394
|
+
}
|
|
395
|
+
throw Object.assign(new Error(`resolved agent session token has an unexpected shape (source: ${credential.source ?? "unknown"}); re-run \`bb doctor --fix\``), { code: "session_token_malformed" });
|
|
396
|
+
}
|
|
397
|
+
// Only reached when a missing prerequisite (no client key / no tenant binding)
|
|
398
|
+
// routed us here — the operator credential path for status/touch/done.
|
|
399
|
+
const agentKey = await resolveAgentKeyFn();
|
|
400
|
+
const owner = await resolveOwnerTokenFn();
|
|
363
401
|
if (owner) {
|
|
364
402
|
if (owner.expiresAt && Date.now() >= owner.expiresAt) {
|
|
365
|
-
if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
|
|
403
|
+
if (agentKey) return withSource({ Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey }, "operator");
|
|
366
404
|
return null;
|
|
367
405
|
}
|
|
368
|
-
return { Authorization: `Bearer ${owner.token}`, "x-agent-api-key": agentKey || "" };
|
|
406
|
+
return withSource({ Authorization: `Bearer ${owner.token}`, "x-agent-api-key": agentKey || "" }, "operator");
|
|
369
407
|
}
|
|
370
|
-
if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
|
|
408
|
+
if (agentKey) return withSource({ Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey }, "operator");
|
|
371
409
|
return null;
|
|
372
410
|
}
|
|
373
411
|
|
|
412
|
+
// BOT-1750 (Codex R7 P2): carry the credential SOURCE on the returned headers as a
|
|
413
|
+
// NON-enumerable property, so callers can clear+self-heal+retry once on a
|
|
414
|
+
// server-rejected cached session (parity with run/test/pw/wait) without the source
|
|
415
|
+
// ever leaking into the spread fetch headers.
|
|
416
|
+
function withSource(headers, source) {
|
|
417
|
+
Object.defineProperty(headers, "__source", { value: source ?? null, enumerable: false });
|
|
418
|
+
return headers;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* BOT-1750 (Codex R7 P2): a locally-fresh cache whose token was revoked server-side
|
|
423
|
+
* (e.g. a concurrent re-registration rotated the session) is rejected at the lease
|
|
424
|
+
* RPC. Mirror the other consumers: when the FIRST auth came from the per-worktree
|
|
425
|
+
* cache and the RPC returns an auth rejection, clear the cache and self-heal once,
|
|
426
|
+
* then let the caller retry with the fresh headers. Returns the fresh headers, or
|
|
427
|
+
* null when no retry applies (not cache-sourced, or the refresh could not authenticate).
|
|
428
|
+
*/
|
|
429
|
+
export async function refreshRejectedCacheAuth(auth, {
|
|
430
|
+
cwd = process.cwd(), authProvider = stackAuthHeader, clear = clearAgentState,
|
|
431
|
+
} = {}) {
|
|
432
|
+
if (auth?.__source !== "cache") return null;
|
|
433
|
+
await clear(cwd);
|
|
434
|
+
const fresh = await authProvider();
|
|
435
|
+
// BOT-1750 (Codex R8 P2): retry ONLY with a genuinely self-healed session. If the
|
|
436
|
+
// client key or binding vanished after clearing the cache, authProvider falls
|
|
437
|
+
// through to the OAuth/MCP operator credential — a DIFFERENT principal whose token
|
|
438
|
+
// also differs, so a bare "different token" check would retry as the wrong agent.
|
|
439
|
+
// Require source "minted" AND a well-formed, changed session token.
|
|
440
|
+
if (fresh?.__source !== "minted") return null;
|
|
441
|
+
const token = fresh["x-agent-api-key"];
|
|
442
|
+
if (typeof token !== "string" || !AGENT_KEY_RE.test(token) || token === auth["x-agent-api-key"]) return null;
|
|
443
|
+
return fresh;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* BOT-1750 (Codex R10 P2): a stack RPC caller that transparently recovers from a
|
|
448
|
+
* server-rejected cached session ONCE — the owner-scoped management commands
|
|
449
|
+
* (`status`/`touch`/`done`) need the same recovery as the lease-creating paths, or a
|
|
450
|
+
* revoked-but-locally-fresh cache leaves an active lease unreleasable. `init()`
|
|
451
|
+
* resolves auth; `call()` runs the RPC and, on the FIRST auth rejection, clears +
|
|
452
|
+
* self-heals + retries with the fresh credential (every later `call` reuses it).
|
|
453
|
+
*/
|
|
454
|
+
export function retryingStackCall({
|
|
455
|
+
callTool = callToolJson, authProvider = stackAuthHeader,
|
|
456
|
+
refresh = refreshRejectedCacheAuth, cwd = () => process.cwd(),
|
|
457
|
+
auth: seedAuth,
|
|
458
|
+
} = {}) {
|
|
459
|
+
// A pre-resolved `auth` seed lets a caller that already resolved the credential
|
|
460
|
+
// (runStackLifecycle short-circuits its own await) skip init() — avoiding an extra
|
|
461
|
+
// await point before the first RPC is dispatched.
|
|
462
|
+
let auth = seedAuth;
|
|
463
|
+
let refreshed = false;
|
|
464
|
+
return {
|
|
465
|
+
async init() { if (auth === undefined) auth = await authProvider(); return auth; },
|
|
466
|
+
async call(name, args, callOptions = {}) {
|
|
467
|
+
let r = await callTool(name, args, { ...callOptions, auth });
|
|
468
|
+
if (!refreshed && !r?.ok && r?.auth) {
|
|
469
|
+
refreshed = true;
|
|
470
|
+
const fresh = await refresh(auth, { cwd: cwd() });
|
|
471
|
+
if (fresh) { auth = fresh; r = await callTool(name, args, { ...callOptions, auth }); }
|
|
472
|
+
}
|
|
473
|
+
return r;
|
|
474
|
+
},
|
|
475
|
+
get auth() { return auth; },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// BOT-1750 (Codex R7 P2): map a thrown auth/self-heal failure to the documented exit
|
|
480
|
+
// taxonomy so automation sees AUTH/BACKEND, not a CLI-defect INTERNAL. Credential
|
|
481
|
+
// problems are AUTH (3); a transport/server mint failure is BACKEND (5); anything
|
|
482
|
+
// unrecognized stays INTERNAL (7).
|
|
483
|
+
const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized"]);
|
|
484
|
+
// BOT-1750 (Codex R11 P2): `session_mint_invalid` is an incompatible/malformed SERVER
|
|
485
|
+
// response, not a credential problem — classify it with the transport/server failures.
|
|
486
|
+
const BACKEND_ERROR_CODES = new Set(["session_mint_failed", "session_mint_invalid"]);
|
|
487
|
+
export function classifyStackAuthError(error) {
|
|
488
|
+
const code = error?.code;
|
|
489
|
+
if (AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
|
|
490
|
+
if (BACKEND_ERROR_CODES.has(code)) return EXIT.BACKEND;
|
|
491
|
+
return EXIT.INTERNAL;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// BOT-1750 (AC-2): name the credential kind a refused lease was authenticated with,
|
|
495
|
+
// so the AGENT_HOST_REQUIRED / WORKTREE_NOT_REGISTERED hint tells the operator what
|
|
496
|
+
// they actually sent (a hostless MCP key vs. a real session token).
|
|
497
|
+
export function describeStackCredentialKind(auth) {
|
|
498
|
+
if (!auth) return "no credential";
|
|
499
|
+
const key = auth["x-agent-api-key"];
|
|
500
|
+
if (typeof key === "string" && AGENT_KEY_RE.test(key)) return "agent session token";
|
|
501
|
+
const bearer = typeof auth.Authorization === "string" ? auth.Authorization.replace(/^Bearer\s+/, "") : null;
|
|
502
|
+
// The MCP-key-only fallback sends the SAME key as bearer AND x-agent-api-key; a
|
|
503
|
+
// real OAuth client credential sends a distinct owner token as the bearer.
|
|
504
|
+
if (bearer && key && bearer === key) return "tier-2 MCP key";
|
|
505
|
+
if (bearer) return "OAuth client credential";
|
|
506
|
+
if (key) return "tier-2 MCP key";
|
|
507
|
+
return "unknown credential";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// BOT-1750 (AC-2): the server refuses a lease from a worktree it can't attest with
|
|
511
|
+
// one of these codes. They are actionable in the same way — register this worktree's
|
|
512
|
+
// host + machine so the lease resolves to a host-bearing agent.
|
|
513
|
+
const WORKTREE_UNREGISTERED_CODES = new Set(["AGENT_HOST_REQUIRED", "WORKTREE_NOT_REGISTERED"]);
|
|
514
|
+
|
|
515
|
+
/** One-line, operator-facing fix for an unregistered-worktree lease refusal (or null). */
|
|
516
|
+
export function worktreeRegistrationHint(code, auth) {
|
|
517
|
+
if (!WORKTREE_UNREGISTERED_CODES.has(code)) return null;
|
|
518
|
+
return `${yellow("⚠")} stack: this worktree is not registered for stack leases (sent ${describeStackCredentialKind(auth)}). `
|
|
519
|
+
+ `Run \`bb doctor --fix\` here to register its host + machine id, then retry.`;
|
|
520
|
+
}
|
|
521
|
+
|
|
374
522
|
// The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
|
|
375
523
|
// the event-stream relay requires that same token in its bearer form too. Keep
|
|
376
524
|
// the lease RPC shape unchanged and derive the relay-compatible form only at
|
|
@@ -954,6 +1102,7 @@ export async function cmdUp(opts, {
|
|
|
954
1102
|
emitResult = emit,
|
|
955
1103
|
machineUuidFn = machineUuid,
|
|
956
1104
|
assertIsolated = validateLocalExecTarget,
|
|
1105
|
+
refreshAuth = refreshRejectedCacheAuth,
|
|
957
1106
|
} = {}) {
|
|
958
1107
|
let slot;
|
|
959
1108
|
try { slot = deriveSlot(opts); } catch (e) {
|
|
@@ -994,9 +1143,9 @@ export async function cmdUp(opts, {
|
|
|
994
1143
|
process.stderr.write(`${yellow("⚠")} stack: ${engine} preflight warns of network pressure; ${remedy}.\n`);
|
|
995
1144
|
}
|
|
996
1145
|
}
|
|
997
|
-
|
|
1146
|
+
let auth = await authProvider();
|
|
998
1147
|
if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
999
|
-
|
|
1148
|
+
let call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
|
|
1000
1149
|
|
|
1001
1150
|
// BOT-1585: co-location dispatches the lease to the Helper enrolled for THIS
|
|
1002
1151
|
// physical machine (hardware id), since a hostname is not machine-unique. The
|
|
@@ -1005,7 +1154,7 @@ export async function cmdUp(opts, {
|
|
|
1005
1154
|
if (!hardwareUuid) {
|
|
1006
1155
|
return emitResult(buildReceipt({ command: "up", outcome: "error", code: "MACHINE_UUID_REQUIRED", error: "could not determine this machine's hardware id (needed to dispatch the stack lease to the right machine)" }), opts, EXIT.BACKEND);
|
|
1007
1156
|
}
|
|
1008
|
-
const
|
|
1157
|
+
const leaseArgs = {
|
|
1009
1158
|
slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
|
|
1010
1159
|
ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
|
|
1011
1160
|
pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
@@ -1013,7 +1162,20 @@ export async function cmdUp(opts, {
|
|
|
1013
1162
|
stack_path: execution.stackPath,
|
|
1014
1163
|
worktree_root: execution.worktreeRoot,
|
|
1015
1164
|
machine_uuid: hardwareUuid,
|
|
1016
|
-
}
|
|
1165
|
+
};
|
|
1166
|
+
let req = await call("request_stack_lease", leaseArgs);
|
|
1167
|
+
if (!req.ok && req.auth) {
|
|
1168
|
+
// BOT-1750 (Codex R7 P2): a locally-fresh cached session whose token was revoked
|
|
1169
|
+
// server-side (e.g. a concurrent re-registration) is rejected here. Clear the
|
|
1170
|
+
// cache and self-heal once, then retry — parity with run/test/pw/wait, so `bb
|
|
1171
|
+
// stack` recovers on its own instead of bricking until a manual cache repair.
|
|
1172
|
+
const fresh = await refreshAuth(auth, { cwd: process.cwd() });
|
|
1173
|
+
if (fresh) {
|
|
1174
|
+
auth = fresh;
|
|
1175
|
+
call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
|
|
1176
|
+
req = await call("request_stack_lease", leaseArgs);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1017
1179
|
if (!req.ok) {
|
|
1018
1180
|
return req.auth
|
|
1019
1181
|
? emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
|
|
@@ -1021,6 +1183,8 @@ export async function cmdUp(opts, {
|
|
|
1021
1183
|
}
|
|
1022
1184
|
const d = req.data;
|
|
1023
1185
|
if (!d.success) {
|
|
1186
|
+
const hint = worktreeRegistrationHint(d.code, auth);
|
|
1187
|
+
if (hint) process.stderr.write(`${hint}\n`);
|
|
1024
1188
|
return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
|
|
1025
1189
|
}
|
|
1026
1190
|
let leaseId = d.lease_id;
|
|
@@ -1208,10 +1372,10 @@ export async function cmdUp(opts, {
|
|
|
1208
1372
|
}), opts, EXIT.OK);
|
|
1209
1373
|
}
|
|
1210
1374
|
|
|
1211
|
-
async function cmdStatus(leaseId, opts) {
|
|
1212
|
-
const auth = await
|
|
1375
|
+
async function cmdStatus(leaseId, opts, { rpc = retryingStackCall() } = {}) {
|
|
1376
|
+
const auth = await rpc.init();
|
|
1213
1377
|
if (!auth) return emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
1214
|
-
const got = await
|
|
1378
|
+
const got = await rpc.call("get_stack_lease", { lease_id: leaseId });
|
|
1215
1379
|
if (!got.ok) return got.auth
|
|
1216
1380
|
? emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
|
|
1217
1381
|
: emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
@@ -1224,10 +1388,10 @@ async function cmdStatus(leaseId, opts) {
|
|
|
1224
1388
|
}), opts, EXIT.OK);
|
|
1225
1389
|
}
|
|
1226
1390
|
|
|
1227
|
-
async function cmdTouch(leaseId, opts) {
|
|
1228
|
-
const auth = await
|
|
1391
|
+
async function cmdTouch(leaseId, opts, { rpc = retryingStackCall() } = {}) {
|
|
1392
|
+
const auth = await rpc.init();
|
|
1229
1393
|
if (!auth) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
1230
|
-
const r = await
|
|
1394
|
+
const r = await rpc.call("touch_stack_lease", { lease_id: leaseId });
|
|
1231
1395
|
if (!r.ok) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
1232
1396
|
if (!r.data.success) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
|
|
1233
1397
|
return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
|
|
@@ -1240,10 +1404,15 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1240
1404
|
proveLegacyTarget = proveLegacyLocalExecTarget,
|
|
1241
1405
|
localTeardownFn = localTeardown,
|
|
1242
1406
|
emitResult = emit,
|
|
1407
|
+
refreshAuth = refreshRejectedCacheAuth,
|
|
1243
1408
|
} = {}) {
|
|
1244
|
-
|
|
1409
|
+
// BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
|
|
1410
|
+
// its own — otherwise an active lease can't be released until the cache is manually
|
|
1411
|
+
// repaired. The retrying caller clears+self-heals+retries the FIRST auth rejection.
|
|
1412
|
+
const rpc = retryingStackCall({ callTool, authProvider, refresh: refreshAuth });
|
|
1413
|
+
const auth = await rpc.init();
|
|
1245
1414
|
if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
1246
|
-
const call =
|
|
1415
|
+
const call = rpc.call;
|
|
1247
1416
|
let dockerTarget = null;
|
|
1248
1417
|
let legacyTargetProof = null;
|
|
1249
1418
|
let teardownDir = process.cwd();
|
|
@@ -1475,12 +1644,29 @@ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connect
|
|
|
1475
1644
|
* without Docker or a live BotBuddy service.
|
|
1476
1645
|
*/
|
|
1477
1646
|
export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
1478
|
-
|
|
1647
|
+
// BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
|
|
1648
|
+
// long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
|
|
1649
|
+
// can be rotated server-side at ANY of those points, so EVERY RPC — not just the
|
|
1650
|
+
// initial request — must be able to clear + self-heal + retry once. Route them all
|
|
1651
|
+
// through one retryingStackCall so a single self-heal re-authenticates the rest
|
|
1652
|
+
// (heartbeat, get, cleanup release), instead of fencing a healthy child or leaking
|
|
1653
|
+
// an unreleasable lease. Retry only when we own the credential or a test provides an
|
|
1654
|
+
// explicit refreshAuth — never for an injected api double without one.
|
|
1655
|
+
const refreshAuth = adapters.refreshAuth ?? (adapters.auth ? async () => null : refreshRejectedCacheAuth);
|
|
1656
|
+
// Resolve the initial credential with the same short-circuit as before (an injected
|
|
1657
|
+
// auth skips the network resolve), then seed the retrying caller so no extra await
|
|
1658
|
+
// point is introduced before the first RPC.
|
|
1659
|
+
const initialAuth = adapters.auth ?? await stackAuthHeader();
|
|
1660
|
+
const rpc = retryingStackCall({
|
|
1661
|
+
callTool: adapters.callTool ?? callToolJson,
|
|
1662
|
+
refresh: refreshAuth,
|
|
1663
|
+
auth: initialAuth,
|
|
1664
|
+
});
|
|
1479
1665
|
const api = adapters.api ?? {
|
|
1480
|
-
request: (args) =>
|
|
1481
|
-
get: (leaseId) =>
|
|
1482
|
-
touch: (leaseId) =>
|
|
1483
|
-
release: (leaseId, signal) =>
|
|
1666
|
+
request: (args) => rpc.call("request_stack_lease", args),
|
|
1667
|
+
get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
|
|
1668
|
+
touch: (leaseId) => rpc.call("touch_stack_lease", { lease_id: leaseId }),
|
|
1669
|
+
release: (leaseId, signal) => rpc.call("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
|
|
1484
1670
|
};
|
|
1485
1671
|
const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
|
|
1486
1672
|
const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
|
|
@@ -1526,7 +1712,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1526
1712
|
return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
|
|
1527
1713
|
}
|
|
1528
1714
|
if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1529
|
-
const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth });
|
|
1715
|
+
const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth: rpc.auth });
|
|
1530
1716
|
if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1531
1717
|
return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
|
|
1532
1718
|
})();
|
|
@@ -1558,7 +1744,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1558
1744
|
}
|
|
1559
1745
|
|
|
1560
1746
|
try {
|
|
1561
|
-
if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy login (agents: botbuddy mcp setup)" };
|
|
1747
|
+
if (!rpc.auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy login (agents: botbuddy mcp setup)" };
|
|
1562
1748
|
// BOT-1585: request_stack_lease requires this machine's hardware id so the lease
|
|
1563
1749
|
// dispatches to the machine the worktree was registered on. `stack run` builds its
|
|
1564
1750
|
// own payload (separate from `cmdUp`), so it must send it too.
|
|
@@ -1568,15 +1754,22 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1568
1754
|
}
|
|
1569
1755
|
const slot = deriveSlot(opts);
|
|
1570
1756
|
const execution = resolveStackPath(process.cwd(), opts.stackPath);
|
|
1571
|
-
const
|
|
1757
|
+
const leaseArgs = {
|
|
1572
1758
|
slot, host_key: opts.host || undefined, repo: opts.repo, ticket_id: opts.ticket,
|
|
1573
1759
|
ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
1574
1760
|
purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
|
|
1575
1761
|
stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
|
|
1576
1762
|
machine_uuid: hardwareUuid,
|
|
1577
|
-
}
|
|
1763
|
+
};
|
|
1764
|
+
// The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
|
|
1765
|
+
// FIRST auth rejection among any of request/get/touch/release.
|
|
1766
|
+
const request = await api.request(leaseArgs);
|
|
1578
1767
|
if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
|
|
1579
|
-
if (!request.data?.success)
|
|
1768
|
+
if (!request.data?.success) {
|
|
1769
|
+
const hint = worktreeRegistrationHint(request.data?.code, rpc.auth);
|
|
1770
|
+
if (hint) process.stderr.write(`${hint}\n`);
|
|
1771
|
+
return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
|
|
1772
|
+
}
|
|
1580
1773
|
leaseId = request.data.lease_id;
|
|
1581
1774
|
if (request.data.reused) {
|
|
1582
1775
|
return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
|
|
@@ -1589,7 +1782,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1589
1782
|
}
|
|
1590
1783
|
if (state === "queued") {
|
|
1591
1784
|
activeWaitAbort = new AbortController();
|
|
1592
|
-
const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth, signal: activeWaitAbort.signal });
|
|
1785
|
+
const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
|
|
1593
1786
|
activeWaitAbort = null;
|
|
1594
1787
|
if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
1595
1788
|
if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
|
|
@@ -1598,7 +1791,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1598
1791
|
}
|
|
1599
1792
|
if (state !== "active") {
|
|
1600
1793
|
activeWaitAbort = new AbortController();
|
|
1601
|
-
const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth, signal: activeWaitAbort.signal });
|
|
1794
|
+
const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth: rpc.auth, signal: activeWaitAbort.signal });
|
|
1602
1795
|
activeWaitAbort = null;
|
|
1603
1796
|
if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
1604
1797
|
if (active?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup }; }
|
|
@@ -1657,7 +1850,12 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1657
1850
|
return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing };
|
|
1658
1851
|
} catch (error) {
|
|
1659
1852
|
const cleanup = await requestCleanup();
|
|
1660
|
-
|
|
1853
|
+
// BOT-1750 (Codex R10 P2): a thrown auth/self-heal failure (e.g. a refresh that
|
|
1854
|
+
// re-minted and was rejected) must keep the documented exit taxonomy — AUTH for a
|
|
1855
|
+
// credential problem, BACKEND for a transport/server mint failure — instead of a
|
|
1856
|
+
// blanket INTERNAL. A genuine cleanup failure still wins (it is the louder signal).
|
|
1857
|
+
const exitCode = cleanup?.ok === false ? EXIT.CLEANUP_FAILED : classifyStackAuthError(error);
|
|
1858
|
+
return { exitCode, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
|
|
1661
1859
|
} finally {
|
|
1662
1860
|
if (heartbeat) clock.clearInterval(heartbeat);
|
|
1663
1861
|
if (hardTimer) clock.clearTimeout(hardTimer);
|
|
@@ -1689,9 +1887,9 @@ export async function cmdStack(argv) {
|
|
|
1689
1887
|
process.exitCode = code;
|
|
1690
1888
|
return code;
|
|
1691
1889
|
}
|
|
1692
|
-
const sessionToken =
|
|
1890
|
+
const sessionToken = readAgentSessionTokenEnv();
|
|
1693
1891
|
if (sessionToken && !AGENT_KEY_RE.test(sessionToken)) {
|
|
1694
|
-
process.stderr.write(`${yellow("⚠")} stack: $
|
|
1892
|
+
process.stderr.write(`${yellow("⚠")} stack: $BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex>; re-register and export a fresh session token.\n`);
|
|
1695
1893
|
const code = emit(buildReceipt({ command, outcome: "error", error: "invalid_session_token" }), opts, EXIT.INVALID);
|
|
1696
1894
|
process.exitCode = code;
|
|
1697
1895
|
return code;
|
|
@@ -1709,7 +1907,13 @@ export async function cmdStack(argv) {
|
|
|
1709
1907
|
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
|
|
1710
1908
|
}
|
|
1711
1909
|
} catch (err) {
|
|
1712
|
-
|
|
1910
|
+
// BOT-1750 (Codex R7 P2): a thrown auth/self-heal failure carries a typed code —
|
|
1911
|
+
// map it to the documented exit taxonomy (AUTH for credential problems, BACKEND
|
|
1912
|
+
// for a transport/server mint failure) so automation never reads a routine
|
|
1913
|
+
// outage as a CLI defect. Only a genuinely unrecognized error stays INTERNAL.
|
|
1914
|
+
const exit = classifyStackAuthError(err);
|
|
1915
|
+
const message = exit === EXIT.INTERNAL ? `internal: ${err?.message ?? err}` : (err?.message ?? String(err));
|
|
1916
|
+
code = emit(buildReceipt({ command: command || "?", outcome: "error", code: err?.code, error: message }), opts, exit);
|
|
1713
1917
|
}
|
|
1714
1918
|
process.exitCode = code;
|
|
1715
1919
|
return code;
|