@botbuddy/cli 1.45.6 → 1.46.1
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-session.mjs +0 -0
- package/src/run.mjs +77 -35
- package/src/stack.mjs +29 -35
package/package.json
CHANGED
package/src/agent-session.mjs
CHANGED
|
Binary file
|
package/src/run.mjs
CHANGED
|
@@ -15,7 +15,9 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
import { callToolJson } from "./api.mjs";
|
|
16
16
|
import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
|
|
17
17
|
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
18
|
-
import {
|
|
18
|
+
import { hasAgentSessionRuntime, isRejectedCachedMcpSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
19
|
+
import { isAgentStateFresh, readAgentState, worktreeRoot } from "./agent-state.mjs";
|
|
20
|
+
import { readAgentBinding } from "./wait-profile.mjs";
|
|
19
21
|
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
20
22
|
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
21
23
|
import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
|
|
@@ -231,49 +233,82 @@ function workerInvocation(context) {
|
|
|
231
233
|
return [process.execPath, [bin, "run", "--worker", context]];
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
function sessionFailure(code) { return Object.assign(new Error(code), { code }); }
|
|
237
|
+
|
|
238
|
+
// A selected ID is metadata, not a credential. Require positive private-cache
|
|
239
|
+
// authority before resolving it; never let the API's ambient owner key stand in.
|
|
240
|
+
async function resolveRunCredential({ cwd, env, opts, resolveCredential }) {
|
|
241
|
+
const cached = await readAgentState(cwd);
|
|
242
|
+
const explicitId = opts.sessionId;
|
|
243
|
+
const validateBinding = async (identity) => {
|
|
244
|
+
const bound = await readAgentBinding(cwd);
|
|
245
|
+
if (!bound?.tenant || identity?.tenant !== bound.tenant || !identity?.worktree
|
|
246
|
+
|| await worktreeRoot(identity.worktree) !== await worktreeRoot(cwd)) {
|
|
247
|
+
throw sessionFailure("run_session_binding_mismatch");
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
if (explicitId && !cached) throw sessionFailure("run_session_authority_missing");
|
|
251
|
+
if (cached) {
|
|
252
|
+
if (explicitId && cached.session_id !== explicitId) throw sessionFailure("run_session_mismatch");
|
|
253
|
+
await validateBinding(cached);
|
|
254
|
+
if (explicitId && !isAgentStateFresh(cached, new Date())) throw sessionFailure("run_session_expired");
|
|
255
|
+
} else if (await hasAgentSessionRuntime({ cwd })) {
|
|
256
|
+
throw sessionFailure("run_session_authority_missing");
|
|
257
|
+
}
|
|
258
|
+
const credential = await (resolveCredential ?? resolveAgentSessionCredential)({ cwd, env,
|
|
259
|
+
sessionId: explicitId, flagToken: null });
|
|
260
|
+
// The resolver reads again. A concurrent replacement must satisfy the same
|
|
261
|
+
// binding and explicit ID rather than silently adopting unrelated authority.
|
|
262
|
+
if (credential.source === "cache") {
|
|
263
|
+
await validateBinding(credential.identity);
|
|
264
|
+
if (explicitId && credential.identity?.sessionId !== explicitId) throw sessionFailure("run_session_mismatch");
|
|
265
|
+
}
|
|
266
|
+
return credential;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rejectedSessionReceipt(error, runId) {
|
|
270
|
+
const code = error?.code ?? "agent_session_unavailable";
|
|
271
|
+
const exitCode = ["session_mint_failed", "session_mint_invalid"].includes(code) ? EXIT.BACKEND : EXIT.AUTH;
|
|
272
|
+
return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected",
|
|
273
|
+
...(runId ? { run_id: runId } : {}), error: code,
|
|
274
|
+
recovery: code === "client_key_required"
|
|
275
|
+
? "run `bb login` to install the durable client key, then retry"
|
|
276
|
+
: "run `bb doctor --fix` and select the current worktree session, then retry",
|
|
277
|
+
exit_code: exitCode } };
|
|
278
|
+
}
|
|
279
|
+
|
|
234
280
|
export async function launchRun(argv, {
|
|
235
281
|
cwd = process.cwd(), spawnImpl = spawn, call = null, childEnv = null, testRun = null,
|
|
236
|
-
resolveCredential = null, env = process.env,
|
|
282
|
+
resolveCredential = null, renewCredential = selfHealAgentSession, env = process.env,
|
|
237
283
|
} = {}) {
|
|
238
|
-
|
|
239
|
-
let parsed = parseRunArgs(effectiveArgv, env);
|
|
284
|
+
const parsed = parseRunArgs(argv, env);
|
|
240
285
|
let managedCredential = null;
|
|
241
286
|
// A local test double owns its credential boundary; production invocations
|
|
242
287
|
// self-heal when the session credential was not supplied by flag or env.
|
|
243
288
|
// Keep all other usage failures local and deterministic before minting.
|
|
244
289
|
const onlyMissingIdentity = parsed.errors.length === 1 && parsed.errors[0].includes("session-id");
|
|
245
|
-
if (!parsed.opts.foreground && !parsed.opts.
|
|
246
|
-
&&
|
|
290
|
+
if (!parsed.opts.foreground && !parsed.opts.sessionToken
|
|
291
|
+
&& (parsed.errors.length === 0 || onlyMissingIdentity)
|
|
292
|
+
&& (resolveCredential || call === null || call === callToolJson)) {
|
|
247
293
|
try {
|
|
248
|
-
const credential = await (
|
|
294
|
+
const credential = await resolveRunCredential({ cwd, env, opts: parsed.opts, resolveCredential });
|
|
249
295
|
managedCredential = credential;
|
|
250
|
-
|
|
251
|
-
|
|
296
|
+
parsed.opts.sessionToken = credential.token;
|
|
297
|
+
if (!SESSION_TOKEN_RE.test(credential.token)) throw sessionFailure("session_mint_invalid");
|
|
298
|
+
if (onlyMissingIdentity) parsed.errors.length = 0;
|
|
252
299
|
} catch (error) {
|
|
253
|
-
return
|
|
254
|
-
exitCode: EXIT.AUTH,
|
|
255
|
-
receipt: {
|
|
256
|
-
schema_version: RUN_SCHEMA_VERSION,
|
|
257
|
-
outcome: "rejected",
|
|
258
|
-
error: error?.code ?? "agent_session_unavailable",
|
|
259
|
-
recovery: error?.code === "client_key_required"
|
|
260
|
-
? "run `bb login` to install the durable client key, then retry"
|
|
261
|
-
: "run `bb doctor --fix` to repair agent authentication, then retry",
|
|
262
|
-
exit_code: EXIT.AUTH,
|
|
263
|
-
},
|
|
264
|
-
};
|
|
300
|
+
return rejectedSessionReceipt(error);
|
|
265
301
|
}
|
|
266
302
|
}
|
|
267
303
|
const { opts, command, errors } = parsed;
|
|
268
304
|
if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
|
|
269
305
|
// BOT-1572 (AC-8): when a session token is present it is the credential — pin it
|
|
270
306
|
// so register_command authenticates as the session's agent and derives session_id
|
|
271
|
-
// from the token.
|
|
272
|
-
const effectiveCall = call ?? (
|
|
273
|
-
?
|
|
274
|
-
: callToolJson);
|
|
307
|
+
// from the token. Read the current token on each call, including after renewal.
|
|
308
|
+
const effectiveCall = (t, a) => (call ?? callToolJson)(t, a,
|
|
309
|
+
opts.sessionToken ? { auth: { "x-agent-api-key": opts.sessionToken } } : {});
|
|
275
310
|
const runId = randomUUID();
|
|
276
|
-
const registerArgs = {
|
|
311
|
+
const registerArgs = () => ({
|
|
277
312
|
// Omit session_id entirely when only a token is present — the backend derives
|
|
278
313
|
// it. A plain id is still sent (and honoured) when supplied.
|
|
279
314
|
...(opts.sessionId ? { session_id: opts.sessionId } : {}),
|
|
@@ -281,18 +316,22 @@ export async function launchRun(argv, {
|
|
|
281
316
|
command_hash: commandHash(command), environment_hash: environmentHash(cwd, opts.environment),
|
|
282
317
|
environment: opts.environment, command_kind: opts.kind, expected_duration_seconds: opts.expectedDuration,
|
|
283
318
|
rerun_reason: opts.rerunReason,
|
|
284
|
-
};
|
|
285
|
-
let registration = await effectiveCall("register_command", registerArgs);
|
|
319
|
+
});
|
|
320
|
+
let registration = await effectiveCall("register_command", registerArgs());
|
|
286
321
|
// A cached session can be revoked server-side before its local expiry. The
|
|
287
322
|
// managed cache is ours to replace once; explicit flag/env credentials belong
|
|
288
323
|
// to the caller and are never silently rotated.
|
|
289
324
|
if (isRejectedCachedMcpSession(registration, managedCredential?.source, 0)) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
325
|
+
try {
|
|
326
|
+
const credential = await renewCredential({ cwd, env, force: true, rejectedCredential: {
|
|
327
|
+
token: managedCredential.token, identity: managedCredential.identity,
|
|
328
|
+
} });
|
|
329
|
+
managedCredential = credential;
|
|
330
|
+
opts.sessionToken = credential.token;
|
|
331
|
+
opts.sessionId = credential.identity?.sessionId ?? credential.sessionId;
|
|
332
|
+
if (!SESSION_TOKEN_RE.test(opts.sessionToken) || !opts.sessionId) throw sessionFailure("session_mint_invalid");
|
|
333
|
+
registration = await effectiveCall("register_command", registerArgs());
|
|
334
|
+
} catch (error) { return rejectedSessionReceipt(error, runId); }
|
|
296
335
|
}
|
|
297
336
|
if (!registration.ok || registration.isError || !registration.data?.accepted || !registration.data?.run_credential) {
|
|
298
337
|
return { exitCode: EXIT.BACKEND, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", run_id: runId, error: registration.data?.error ?? registration.error ?? "durable_owner_not_established", exit_code: EXIT.BACKEND } };
|
|
@@ -310,7 +349,10 @@ export async function launchRun(argv, {
|
|
|
310
349
|
await effectiveCall("update_command_run", { run_id: runId, run_credential: registration.data.run_credential, status: "owner_lost", invalidated_reason: `launcher_failed:${error instanceof Error ? error.message : String(error)}` });
|
|
311
350
|
return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "owner_lost", run_id: runId, exit_code: EXIT.INTERNAL } };
|
|
312
351
|
}
|
|
313
|
-
|
|
352
|
+
const knownSession = opts.sessionId ?? managedCredential?.identity?.sessionId ?? managedCredential?.sessionId;
|
|
353
|
+
return { exitCode: EXIT.OK, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "launched", run_id: runId,
|
|
354
|
+
...(knownSession ? { session_id: knownSession } : { session_binding: "server_derived" }),
|
|
355
|
+
durable_owner: "detached_worker", receipt_path: receipt, reentry: "update_command_run publishes the terminal callback for this run_id", exit_code: EXIT.OK } };
|
|
314
356
|
}
|
|
315
357
|
|
|
316
358
|
export async function runWorker(contextFile, { spawnImpl = spawn, call = callToolJson } = {}) {
|
package/src/stack.mjs
CHANGED
|
@@ -33,8 +33,7 @@ import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
|
|
|
33
33
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
34
34
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
35
35
|
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
36
|
-
import { resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
37
|
-
import { clearAgentState } from "./agent-state.mjs";
|
|
36
|
+
import { resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
38
37
|
import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
|
|
39
38
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
40
39
|
import { assertLeasedStackIsolated, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
@@ -828,7 +827,7 @@ export async function stackAuthHeader({
|
|
|
828
827
|
// and silently authenticate as the OAuth/MCP-key principal, which is the exact
|
|
829
828
|
// principal switch the catch above guards against.
|
|
830
829
|
if (typeof credential.token === "string" && AGENT_KEY_RE.test(credential.token)) {
|
|
831
|
-
return withSource({ "x-agent-api-key": credential.token }, credential.source);
|
|
830
|
+
return withSource({ "x-agent-api-key": credential.token }, credential.source, credential.identity);
|
|
832
831
|
}
|
|
833
832
|
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" });
|
|
834
833
|
}
|
|
@@ -848,37 +847,39 @@ export async function stackAuthHeader({
|
|
|
848
847
|
}
|
|
849
848
|
|
|
850
849
|
// BOT-1750 (Codex R7 P2): carry the credential SOURCE on the returned headers as a
|
|
851
|
-
// NON-enumerable property, so callers can
|
|
850
|
+
// NON-enumerable property, so callers can renew+retry once on a
|
|
852
851
|
// server-rejected cached session (parity with run/test/pw/wait) without the source
|
|
853
852
|
// ever leaking into the spread fetch headers.
|
|
854
|
-
function withSource(headers, source) {
|
|
853
|
+
function withSource(headers, source, identity = null) {
|
|
855
854
|
Object.defineProperty(headers, "__source", { value: source ?? null, enumerable: false });
|
|
855
|
+
Object.defineProperty(headers, "__identity", { value: identity, enumerable: false });
|
|
856
856
|
return headers;
|
|
857
857
|
}
|
|
858
858
|
|
|
859
859
|
/**
|
|
860
860
|
* BOT-1750 (Codex R7 P2): a locally-fresh cache whose token was revoked server-side
|
|
861
861
|
* (e.g. a concurrent re-registration rotated the session) is rejected at the lease
|
|
862
|
-
* RPC.
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
* null when no retry applies (not cache-sourced, or the refresh could not authenticate).
|
|
862
|
+
* RPC. Renew once without deleting the cache or losing lease ownership identity.
|
|
863
|
+
* The shared lock re-reads and verifies the cache; a concurrent renewal wins.
|
|
864
|
+
* Failure leaves the existing cache untouched and propagates its typed error.
|
|
866
865
|
*/
|
|
867
866
|
export async function refreshRejectedCacheAuth(auth, {
|
|
868
|
-
cwd = process.cwd(),
|
|
867
|
+
cwd = process.cwd(), renew = selfHealAgentSession,
|
|
869
868
|
} = {}) {
|
|
870
869
|
if (auth?.__source !== "cache") return null;
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
|
|
870
|
+
return renewStackSession(auth, { cwd, renew });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function renewStackSession(auth, { cwd, renew }) {
|
|
874
|
+
const credential = await renew({ cwd, force: true,
|
|
875
|
+
rejectedCredential: { token: auth["x-agent-api-key"], identity: auth.__identity } });
|
|
876
|
+
// BOT-1750 (Codex R8 P2): the renewal adapter must never yield an operator
|
|
877
|
+
// principal. "cache" is permitted
|
|
878
|
+
// only for a newer same-identity session verified inside the renewal lock.
|
|
879
|
+
if (credential?.source !== "minted" && credential?.source !== "cache") return null;
|
|
880
|
+
const token = credential.token;
|
|
880
881
|
if (typeof token !== "string" || !AGENT_KEY_RE.test(token) || token === auth["x-agent-api-key"]) return null;
|
|
881
|
-
return
|
|
882
|
+
return withSource({ "x-agent-api-key": token }, credential.source, credential.identity);
|
|
882
883
|
}
|
|
883
884
|
|
|
884
885
|
/**
|
|
@@ -886,8 +887,8 @@ export async function refreshRejectedCacheAuth(auth, {
|
|
|
886
887
|
* server-rejected cached session ONCE — the owner-scoped management commands
|
|
887
888
|
* (`status`/`touch`/`done`) need the same recovery as the lease-creating paths, or a
|
|
888
889
|
* revoked-but-locally-fresh cache leaves an active lease unreleasable. `init()`
|
|
889
|
-
* resolves auth; `call()` runs the RPC and, on the FIRST auth rejection,
|
|
890
|
-
*
|
|
890
|
+
* resolves auth; `call()` runs the RPC and, on the FIRST auth rejection, renews +
|
|
891
|
+
* retries with the fresh credential (every later `call` reuses it).
|
|
891
892
|
*/
|
|
892
893
|
export function retryingStackCall({
|
|
893
894
|
callTool = callToolJson, authProvider = stackAuthHeader,
|
|
@@ -1362,25 +1363,18 @@ function abortableSleep(ms, signal) {
|
|
|
1362
1363
|
* BOT-2099: re-establish the credential a parked lease waits with. On a transport failure
|
|
1363
1364
|
* the credential is fine — keep it. On an auth rejection, first re-read (another process in
|
|
1364
1365
|
* this worktree may already have re-minted, and minting again would supersede it), then
|
|
1365
|
-
*
|
|
1366
|
+
* renew under the shared cache lock without removing recovery identity. Only a well-formed agent SESSION token
|
|
1366
1367
|
* replaces a session credential: the operator fallback is a different principal that
|
|
1367
1368
|
* cannot see the lease (BOT-1750 R8), so it is never adopted. Throws the mint error, which
|
|
1368
1369
|
* the park treats as one more failed attempt.
|
|
1369
1370
|
*/
|
|
1370
1371
|
export async function reauthenticateParkedLease(current, kind, {
|
|
1371
|
-
cwd = process.cwd(),
|
|
1372
|
+
cwd = process.cwd(), renew = selfHealAgentSession,
|
|
1372
1373
|
} = {}) {
|
|
1373
1374
|
if (kind !== "auth") return current;
|
|
1374
1375
|
const source = current?.__source ?? null;
|
|
1375
1376
|
if (source !== "cache" && source !== "minted") return current;
|
|
1376
|
-
|
|
1377
|
-
&& typeof h["x-agent-api-key"] === "string" && AGENT_KEY_RE.test(h["x-agent-api-key"])
|
|
1378
|
-
&& h["x-agent-api-key"] !== current["x-agent-api-key"];
|
|
1379
|
-
const reread = await authProvider();
|
|
1380
|
-
if (isFreshSession(reread)) return reread;
|
|
1381
|
-
await clear(cwd);
|
|
1382
|
-
const minted = await authProvider();
|
|
1383
|
-
return isFreshSession(minted) ? minted : current;
|
|
1377
|
+
return (await renewStackSession(current, { cwd, renew })) ?? current;
|
|
1384
1378
|
}
|
|
1385
1379
|
|
|
1386
1380
|
/**
|
|
@@ -2482,7 +2476,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2482
2476
|
} = {}) {
|
|
2483
2477
|
// BOT-1750 (Codex R10 P2): `done` must recover a server-rejected cached session on
|
|
2484
2478
|
// its own — otherwise an active lease can't be released until the cache is manually
|
|
2485
|
-
// repaired. The retrying caller
|
|
2479
|
+
// repaired. The retrying caller renews+retries the FIRST auth rejection.
|
|
2486
2480
|
const rpc = retryingStackCall({ callTool, authProvider, refresh: refreshAuth });
|
|
2487
2481
|
const auth = await rpc.init();
|
|
2488
2482
|
if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
@@ -2839,7 +2833,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
2839
2833
|
// BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
|
|
2840
2834
|
// long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
|
|
2841
2835
|
// can be rotated server-side at ANY of those points, so EVERY RPC — not just the
|
|
2842
|
-
// initial request — must be able to
|
|
2836
|
+
// initial request — must be able to renew + retry once. Route them all
|
|
2843
2837
|
// through one retryingStackCall so a single self-heal re-authenticates the rest
|
|
2844
2838
|
// (heartbeat, get, cleanup release), instead of fencing a healthy child or leaking
|
|
2845
2839
|
// an unreleasable lease. Retry only when we own the credential or a test provides an
|
|
@@ -3110,7 +3104,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3110
3104
|
...(bundle?.included?.length ? { bundle_included: bundle.included } : {}),
|
|
3111
3105
|
...(opts.callbackPort != null ? { callback_port: opts.callbackPort } : {}),
|
|
3112
3106
|
};
|
|
3113
|
-
// The retrying caller (api → rpc.call) transparently
|
|
3107
|
+
// The retrying caller (api → rpc.call) transparently renews+retries the
|
|
3114
3108
|
// FIRST auth rejection among any of request/get/touch/release.
|
|
3115
3109
|
// BOT-2024: a stale-capacity refusal prints phase-aware guidance; with the opt-in
|
|
3116
3110
|
// --wait-capacity a TRANSIENT one parks on the server's capacity wait and re-admits once.
|