@botbuddy/cli 1.31.0 → 1.31.2
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-cleanup.mjs +150 -0
- package/src/agent-key.mjs +57 -1
- package/src/agent-session.mjs +0 -0
- package/src/agent-state-fs.mjs +90 -0
- package/src/agent-state.mjs +78 -11
- package/src/codex-bridge.mjs +364 -15
- package/src/commands.mjs +153 -0
- package/src/credential-kinds.mjs +42 -0
- package/src/pw/run.mjs +21 -2
- package/src/run.mjs +7 -2
- package/src/session-bootstrap.mjs +375 -0
- package/src/setup-block.mjs +18 -25
- package/src/test-lane.mjs +6 -2
- package/src/wait.mjs +17 -3
package/src/credential-kinds.mjs
CHANGED
|
@@ -30,6 +30,48 @@ export const CREDENTIAL_PREFIXES = [
|
|
|
30
30
|
{ prefix: "bb_", kind: "legacy", label: "legacy personal key" },
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
+
// BOT-1701 — the ONE canonical per-tier credential contract. The same noun,
|
|
34
|
+
// prefix, CLI flag, env var, and `credential_kind` for each tier, named the same
|
|
35
|
+
// way EVERYWHERE it is met: the setup-block help text, the connectivity guide's
|
|
36
|
+
// tier table, the `bb wait/run/test/pw --help` flags, the connectivity error
|
|
37
|
+
// strings, and the server's `whoami credential_kind`. A drift test
|
|
38
|
+
// (credential-naming-contract.test.mjs) fails when any of those surfaces names a
|
|
39
|
+
// tier-3 credential by anything other than the canonical noun/flag/env below.
|
|
40
|
+
// Decided by BOT-1649 — do not re-decide here. Additive only: legacy aliases
|
|
41
|
+
// (TIER3_LEGACY) keep working for one release and are listed separately so a
|
|
42
|
+
// surface can name them ONLY on a line that says "alias"/"legacy".
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {{ tier: 1|2|3, noun: string, prefix: string, flag: string|null, env: string, kind: string }} CredentialTier
|
|
45
|
+
* @type {ReadonlyArray<CredentialTier>}
|
|
46
|
+
*/
|
|
47
|
+
export const CREDENTIAL_TIERS = Object.freeze([
|
|
48
|
+
Object.freeze({ tier: 1, noun: "client key", prefix: "bb_cli_", flag: null, env: "BOTBUDDY_CLIENT_KEY", kind: "cli" }),
|
|
49
|
+
Object.freeze({ tier: 2, noun: "MCP key", prefix: "bb_mcp_", flag: null, env: "BOTBUDDY_MCP_KEY", kind: "mcp" }),
|
|
50
|
+
Object.freeze({ tier: 3, noun: "agent session token", prefix: "bb_sess_", flag: "--agent-session-token", env: "BOTBUDDY_AGENT_SESSION_TOKEN", kind: "agent_session" }),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The tier-3 legacy aliases, accepted for one release. Kept apart from the
|
|
55
|
+
* canonical table so a surface can name them only on an "alias"/"legacy" line and
|
|
56
|
+
* the CLI can nudge an operator toward the canonical flag/env (BOT-1701 AC-6).
|
|
57
|
+
*/
|
|
58
|
+
export const TIER3_LEGACY = Object.freeze({
|
|
59
|
+
flags: Object.freeze(["--agent-key", "--session-token"]),
|
|
60
|
+
envs: Object.freeze(["BOTBUDDY_AGENT_KEY", "BOTBUDDY_SESSION_TOKEN"]),
|
|
61
|
+
prefix: "bb_agent_",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The canonical tier row for a tier number (1|2|3), or undefined. */
|
|
65
|
+
export function credentialTier(tier) {
|
|
66
|
+
return CREDENTIAL_TIERS.find((t) => t.tier === tier);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The canonical tier row whose prefix a secret carries, or undefined. */
|
|
70
|
+
export function credentialTierByPrefix(secret) {
|
|
71
|
+
if (typeof secret !== "string") return undefined;
|
|
72
|
+
return CREDENTIAL_TIERS.find((t) => secret.startsWith(t.prefix));
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
/**
|
|
34
76
|
* Classify a raw secret by its prefix.
|
|
35
77
|
* @param {unknown} secret
|
package/src/pw/run.mjs
CHANGED
|
@@ -7,13 +7,13 @@ import { canonicalizeHostString } from "./host.mjs";
|
|
|
7
7
|
import { resolveAgentBinding } from "../wait-profile.mjs";
|
|
8
8
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
9
9
|
import { VERSION } from "../version.mjs";
|
|
10
|
-
import { readAgentSessionTokenEnv, AGENT_KEY_RE } from "../agent-key.mjs";
|
|
10
|
+
import { readAgentSessionTokenEnv, AGENT_KEY_RE, legacyAgentAliasHint } from "../agent-key.mjs";
|
|
11
11
|
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
|
|
12
12
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
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>] <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--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
|
|
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> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
|
|
17
17
|
function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
|
|
18
18
|
// BOT-1488: the register_agent identity for this machine, persisted by
|
|
19
19
|
// `botbuddy register` into ~/.botbuddy/config.json. This is the SESSION agent
|
|
@@ -99,9 +99,28 @@ export async function runPw(argv, deps = {}) {
|
|
|
99
99
|
if (code !== 0) stderr.write(`${versionLine()} — if your checkout's cli/package.json is newer, this global is stale: npm i -g @botbuddy/cli@latest, or run node cli/bin/bb-pw.mjs from the repo.\n`);
|
|
100
100
|
return code;
|
|
101
101
|
}
|
|
102
|
+
// The leading option/value pairs bb pw consumes before the lane/verb (mirrors the
|
|
103
|
+
// while-loop in runPwInner). Everything after the first non-option (the lane) is a
|
|
104
|
+
// Playwright workload argument and is never a BotBuddy credential.
|
|
105
|
+
const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
|
|
106
|
+
function leadingPwOptions(args) {
|
|
107
|
+
const lead = [];
|
|
108
|
+
for (let i = 0; i < args.length && PW_LEADING_FLAGS.includes(args[i]); i += 2) {
|
|
109
|
+
lead.push(args[i]);
|
|
110
|
+
if (i + 1 < args.length) lead.push(args[i + 1]);
|
|
111
|
+
}
|
|
112
|
+
return lead;
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
async function runPwInner(argv, deps = {}) {
|
|
103
116
|
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; }
|
|
104
117
|
if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
|
|
118
|
+
// BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy flag/env alias. bb pw
|
|
119
|
+
// only recognizes credential flags as LEADING options (the while-loop below);
|
|
120
|
+
// a `--agent-key` after the lane/verb is a Playwright workload argument, not a
|
|
121
|
+
// BotBuddy credential, so scan only the leading options (Codex R9).
|
|
122
|
+
const aliasHint = legacyAgentAliasHint({ argv: leadingPwOptions(args), env });
|
|
123
|
+
if (aliasHint) stderr.write(`bb pw: ${aliasHint}\n`);
|
|
105
124
|
// BOT-1649: --agent-session-token / $BOTBUDDY_AGENT_SESSION_TOKEN is canonical;
|
|
106
125
|
// legacy flags and env aliases remain accepted as a
|
|
107
126
|
// session identity alongside --session-id, so a token-armed session need not
|
package/src/run.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
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
|
-
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
17
|
+
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
18
18
|
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
19
19
|
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
20
20
|
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
@@ -532,9 +532,14 @@ export async function cmdRun(args) {
|
|
|
532
532
|
return;
|
|
533
533
|
}
|
|
534
534
|
if (args[0] === "help" || args[0] === "--help") {
|
|
535
|
-
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>");
|
|
535
|
+
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
|
|
536
536
|
return;
|
|
537
537
|
}
|
|
538
|
+
// BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
|
|
539
|
+
// (stderr only, so the stdout receipt stays byte-identical). A cached bb_agent_
|
|
540
|
+
// token is nudged inside resolveAgentSessionCredential.
|
|
541
|
+
const aliasHint = legacyAgentAliasHint({ argv: args, env: process.env });
|
|
542
|
+
if (aliasHint) process.stderr.write(`botbuddy run: ${aliasHint}\n`);
|
|
538
543
|
if (args.includes("--foreground")) {
|
|
539
544
|
let identity;
|
|
540
545
|
try { identity = await loadTelemetryIdentity(); }
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// BOT-1650 — the only CLI-side bridge between a harness and the tier-3 token.
|
|
2
|
+
// It never prints or exports a secret; the runtime store is consumed by `bb wait`.
|
|
3
|
+
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { callToolJson } from "./api.mjs";
|
|
6
|
+
import { readAgentState, touchAgentStateExpiry, withStateLock, worktreeRoot, writeAgentState } from "./agent-state.mjs";
|
|
7
|
+
import { clearAgentSessionIfMatches, drainPendingCleanup, recordPendingCleanup } from "./agent-cleanup.mjs";
|
|
8
|
+
import { readAgentBinding } from "./wait-profile.mjs";
|
|
9
|
+
import { resolveAgentKey, resolveOwnerToken } from "./cli-credentials.mjs";
|
|
10
|
+
import { getConfig } from "./config.mjs";
|
|
11
|
+
|
|
12
|
+
// BOT-1650 → BOT-1649 reconciliation: the harness session lives in the ONE
|
|
13
|
+
// unified client cache (agent-state.mjs), written in main's `agent_session_token`
|
|
14
|
+
// schema. These adapters map between the bootstrap RPC response shape (which uses
|
|
15
|
+
// `session_token`) and the on-disk state, so `bb wait`/`run`/`test`/`pw` can
|
|
16
|
+
// authenticate from the same file via resolveAgentSessionCredential.
|
|
17
|
+
|
|
18
|
+
/** Read the per-worktree cache back in the RPC-shaped form the bridge expects. */
|
|
19
|
+
export async function loadHarnessSession({ cwd = process.cwd() } = {}) {
|
|
20
|
+
const state = await readAgentState(cwd);
|
|
21
|
+
if (!state) return null;
|
|
22
|
+
return {
|
|
23
|
+
...state,
|
|
24
|
+
session_token: state.agent_session_token,
|
|
25
|
+
...(state.session_token_generation ? { session_token_generation: state.session_token_generation } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Persist a bootstrap-issued session into the unified cache. The bootstrap RPC
|
|
30
|
+
* does not echo the tenant, so derive it from the committed binding exactly as
|
|
31
|
+
* selfHealAgentSession does; host/worktree/minted_at are derived locally. */
|
|
32
|
+
export async function saveHarnessSession(session, { cwd = process.cwd() } = {}) {
|
|
33
|
+
const root = await worktreeRoot(cwd);
|
|
34
|
+
let tenant;
|
|
35
|
+
try { tenant = (await readAgentBinding(root))?.tenant; } catch { tenant = undefined; }
|
|
36
|
+
const state = {
|
|
37
|
+
schema_version: 1,
|
|
38
|
+
agent_id: session.agent_id,
|
|
39
|
+
agent_session_token: session.session_token,
|
|
40
|
+
session_id: session.session_id ?? null,
|
|
41
|
+
...(tenant ? { tenant } : {}),
|
|
42
|
+
host: os.hostname(),
|
|
43
|
+
worktree: root,
|
|
44
|
+
minted_at: new Date().toISOString(),
|
|
45
|
+
expires_at: session.expires_at,
|
|
46
|
+
// BOT-1650 (Codex round-28 P2): mark provenance so a TIMER wait only relays for
|
|
47
|
+
// a bootstrap-issued cache (the cross-shell receipt-of-principal contract). A
|
|
48
|
+
// bb setup / self-heal cache shares this schema but omits this marker, so a
|
|
49
|
+
// plain timer from such a shell stays fully offline.
|
|
50
|
+
harness_origin: "bootstrap_agent_session",
|
|
51
|
+
...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
|
|
52
|
+
};
|
|
53
|
+
await writeAgentState(root, state);
|
|
54
|
+
// Return the RPC-shaped session so callers reading `.session_token` are unchanged.
|
|
55
|
+
return session;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Guarded, lock-free clear used inside the bootstrap transaction (re-exported so
|
|
59
|
+
* the Codex bridge and `bb setup` close path share one implementation). */
|
|
60
|
+
export const clearHarnessSession = clearAgentSessionIfMatches;
|
|
61
|
+
|
|
62
|
+
/** Serialize the whole remote-bootstrap-plus-save on the unified per-worktree
|
|
63
|
+
* state lock — the SAME `<agent-state>.lock` selfHealAgentSession takes — so a
|
|
64
|
+
* bootstrap and a self-heal can never both write the cache. */
|
|
65
|
+
export async function withHarnessBootstrapLock({ cwd = process.cwd() } = {}, operation) {
|
|
66
|
+
const root = await worktreeRoot(cwd);
|
|
67
|
+
return withStateLock(root, {}, () => operation(root));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function durableCleanupOptions({
|
|
71
|
+
resolveAgentKeyFn = resolveAgentKey,
|
|
72
|
+
resolveOwnerTokenFn = resolveOwnerToken,
|
|
73
|
+
getConfigFn = getConfig,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const owner = await resolveOwnerTokenFn({ getConfig: getConfigFn });
|
|
76
|
+
if (owner && !(owner.expiresAt && owner.expiresAt <= Date.now())) {
|
|
77
|
+
return { auth: { Authorization: `Bearer ${owner.token}` } };
|
|
78
|
+
}
|
|
79
|
+
const agentKey = await resolveAgentKeyFn();
|
|
80
|
+
if (agentKey) return { auth: { "x-agent-api-key": agentKey } };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanupError(response) {
|
|
85
|
+
return response?.data?.error || response?.data?.message || response?.error || "remote session cleanup could not be confirmed";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function closeWithDurableCredential(call, args, options) {
|
|
89
|
+
try {
|
|
90
|
+
const response = await call("close_session", args, options);
|
|
91
|
+
return { response, closed: !!response?.ok && !response.isError };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { response: null, closed: false, error };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// BOT-1650 (Codex round-24/25): a session was admitted but could not be retired.
|
|
98
|
+
// Persist a cleanup marker so a later bootstrap retires it, then ALWAYS throw a
|
|
99
|
+
// failure that preserves the primary cause, the retirement failure, AND any
|
|
100
|
+
// persistence failure — never swallowing a marker-write failure (round-25 P2),
|
|
101
|
+
// which would silently revert to leaving an orphaned holder. Never returns.
|
|
102
|
+
async function recordOrphanAndFail({ session, cwd, retirementError, primaryError }) {
|
|
103
|
+
let persistError;
|
|
104
|
+
try {
|
|
105
|
+
await recordPendingCleanup(
|
|
106
|
+
{ session_id: session.session_id, session_token_generation: session.session_token_generation },
|
|
107
|
+
{ cwd },
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
persistError = error;
|
|
111
|
+
}
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (primaryError) parts.push(primaryError.message);
|
|
114
|
+
parts.push(`the admitted session could not be retired: ${retirementError.message}`);
|
|
115
|
+
if (persistError) parts.push(`and its cleanup marker could not be persisted: ${persistError.message}`);
|
|
116
|
+
const err = new Error(parts.join("; "));
|
|
117
|
+
err.cause = primaryError ?? retirementError;
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Keep a REST-bootstrapped harness alive without handing its durable setup
|
|
122
|
+
// credential to the bridge. The session bearer is scoped to this exact session;
|
|
123
|
+
// the server rejects a mismatched or ended session rather than refreshing an
|
|
124
|
+
// unrelated agent-global heartbeat.
|
|
125
|
+
export async function heartbeatHarnessSession({
|
|
126
|
+
session,
|
|
127
|
+
codexThreadId = null,
|
|
128
|
+
model = null,
|
|
129
|
+
call = callToolJson,
|
|
130
|
+
cwd = process.cwd(),
|
|
131
|
+
touchExpiry = touchAgentStateExpiry,
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (!session?.session_id || !session?.session_token) {
|
|
134
|
+
throw new Error("bootstrap heartbeat requires a stored session credential");
|
|
135
|
+
}
|
|
136
|
+
const response = await call("heartbeat", {
|
|
137
|
+
session_id: session.session_id,
|
|
138
|
+
...(codexThreadId ? { codex_thread_id: codexThreadId } : {}),
|
|
139
|
+
...(model ? { model } : {}),
|
|
140
|
+
}, { auth: { "x-agent-api-key": session.session_token } });
|
|
141
|
+
if (!response?.ok || response.isError) {
|
|
142
|
+
throw new Error(response?.data?.message || response?.error || "bootstrap heartbeat failed");
|
|
143
|
+
}
|
|
144
|
+
// BOT-1650 (Codex round-30 P2): the server just rolled this session's expiry
|
|
145
|
+
// forward, but the runtime cache still holds the ORIGINAL deadline. Without
|
|
146
|
+
// mirroring the renewal, a later relay wait on a long-lived bridge judges the
|
|
147
|
+
// cache stale and self-heals into a DIFFERENT work session — losing the harness
|
|
148
|
+
// principal and creating a second live holder. touchAgentStateExpiry extends the
|
|
149
|
+
// cache only while it still holds THIS exact bearer, so it never revives a
|
|
150
|
+
// replaced credential. Best-effort: a heartbeat that cannot refresh the cache is
|
|
151
|
+
// not itself a failure.
|
|
152
|
+
await touchExpiry(cwd, session.session_token).catch(() => {});
|
|
153
|
+
return response.data;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function bootstrapHarnessSession({
|
|
157
|
+
name,
|
|
158
|
+
type = "codex",
|
|
159
|
+
harnessSessionId,
|
|
160
|
+
codexThreadId = null,
|
|
161
|
+
cwd = process.cwd(),
|
|
162
|
+
branch = null,
|
|
163
|
+
ticketId = null,
|
|
164
|
+
ticketUrl = null,
|
|
165
|
+
noTicketReason = null,
|
|
166
|
+
prId = null,
|
|
167
|
+
prNumber = null,
|
|
168
|
+
prUrl = null,
|
|
169
|
+
model = null,
|
|
170
|
+
effort = null,
|
|
171
|
+
harness = type,
|
|
172
|
+
call = callToolJson,
|
|
173
|
+
callOptions = undefined,
|
|
174
|
+
resolveCleanupOptions = durableCleanupOptions,
|
|
175
|
+
store = saveHarnessSession,
|
|
176
|
+
transaction = withHarnessBootstrapLock,
|
|
177
|
+
} = {}) {
|
|
178
|
+
if (!name || !harnessSessionId) throw new Error("bootstrap requires name and harnessSessionId");
|
|
179
|
+
const worktree = await worktreeRoot(cwd);
|
|
180
|
+
return transaction({ cwd: worktree }, async () => {
|
|
181
|
+
// BOT-1650 (Codex round-24): before admitting a new session, retry retiring
|
|
182
|
+
// any session a PRIOR bootstrap orphaned (its replacement retirement failed in
|
|
183
|
+
// a compound outage). The durable credential retires by id, so no bearer is
|
|
184
|
+
// needed. Best-effort — a missing durable credential just keeps the marker.
|
|
185
|
+
await drainPendingCleanup({
|
|
186
|
+
cwd: worktree,
|
|
187
|
+
retire: async (entry) => {
|
|
188
|
+
const opts = callOptions ?? await resolveCleanupOptions();
|
|
189
|
+
if (!opts) return false;
|
|
190
|
+
const closed = await closeWithDurableCredential(call, {
|
|
191
|
+
session_id: entry.session_id,
|
|
192
|
+
...(entry.session_token_generation ? { session_token_generation: entry.session_token_generation } : {}),
|
|
193
|
+
}, opts);
|
|
194
|
+
return closed.closed;
|
|
195
|
+
},
|
|
196
|
+
}).catch(() => {});
|
|
197
|
+
const cached = await loadHarnessSession({ cwd: worktree });
|
|
198
|
+
// `expires_at` is a cache snapshot, not authority: an authenticated relay
|
|
199
|
+
// use can extend the server-side session after the file was written. Pass
|
|
200
|
+
// bootstrap straight through so the server reuses a still-live incarnation
|
|
201
|
+
// or rotates a truly expired one; never revoke an active wait from a stale
|
|
202
|
+
// local timestamp.
|
|
203
|
+
const response = await call("bootstrap_agent_session", {
|
|
204
|
+
name,
|
|
205
|
+
type,
|
|
206
|
+
harness_session_id: harnessSessionId,
|
|
207
|
+
host: os.hostname(),
|
|
208
|
+
worktree,
|
|
209
|
+
...(branch ? { branch } : {}),
|
|
210
|
+
...(ticketId ? { ticket_id: ticketId } : {}),
|
|
211
|
+
...(ticketUrl ? { ticket_url: ticketUrl } : {}),
|
|
212
|
+
...(noTicketReason ? { no_ticket_reason: noTicketReason } : {}),
|
|
213
|
+
...(prId ? { pr_id: prId } : {}),
|
|
214
|
+
...(prNumber != null ? { pr_number: prNumber } : {}),
|
|
215
|
+
...(prUrl ? { pr_url: prUrl } : {}),
|
|
216
|
+
...(codexThreadId ? { codex_thread_id: codexThreadId } : {}),
|
|
217
|
+
...(model ? { model } : {}),
|
|
218
|
+
...(effort ? { effort } : {}),
|
|
219
|
+
harness,
|
|
220
|
+
}, callOptions);
|
|
221
|
+
if (!response?.ok || response.isError) {
|
|
222
|
+
throw new Error(response?.data?.message || response?.error || "bootstrap_agent_session failed");
|
|
223
|
+
}
|
|
224
|
+
// Persist only the four session-secret fields. Ticket signals are intentionally
|
|
225
|
+
// returned to the launcher, never copied into the runtime credential file.
|
|
226
|
+
const { ticket_signals: ticketSignals, display_name: displayName, linkage, ...session } = response.data;
|
|
227
|
+
if (ticketSignals?.concurrency) throw new Error(ticketSignals.concurrency.warning || "Another live session holds this ticket.");
|
|
228
|
+
if (linkage?.status === "gap") throw new Error(linkage.warning || "Session bootstrap requires ticket attribution.");
|
|
229
|
+
// A new harness may be admitted only after an abandoned cache aged out of
|
|
230
|
+
// the server's liveness window. Before replacing that file, ask the server
|
|
231
|
+
// to retire the cached session *only if its authoritative holder-liveness
|
|
232
|
+
// window elapsed*. The file's timestamp is intentionally not authority:
|
|
233
|
+
// relay heartbeats can extend it after the snapshot was written.
|
|
234
|
+
if (cached && cached.session_id !== session.session_id) {
|
|
235
|
+
const cleanupOptions = callOptions ?? await resolveCleanupOptions();
|
|
236
|
+
if (!cleanupOptions) {
|
|
237
|
+
// BOT-1650 (Codex round-27): the new session was admitted but there is no
|
|
238
|
+
// durable credential to retire the OLD cached session, so the replacement
|
|
239
|
+
// cannot complete. Do not strand the NEW bearer (never persisted): self-close
|
|
240
|
+
// it with its own scoped token first, and if that cannot be confirmed record
|
|
241
|
+
// an orphan marker for a later bootstrap to drain — then fail closed. Matches
|
|
242
|
+
// the store()-failure path's no-durable-credential handling.
|
|
243
|
+
const primaryError = new Error("a replacement harness cache requires a durable credential to confirm remote session cleanup");
|
|
244
|
+
const direct = await closeWithDurableCredential(call, {
|
|
245
|
+
session_id: session.session_id,
|
|
246
|
+
...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
|
|
247
|
+
}, { auth: { "x-agent-api-key": session.session_token } });
|
|
248
|
+
if (!direct.closed) {
|
|
249
|
+
await recordOrphanAndFail({
|
|
250
|
+
session,
|
|
251
|
+
cwd: worktree,
|
|
252
|
+
retirementError: new Error("no durable credential is available to retire the cached session, and the new session's self-close was not confirmed"),
|
|
253
|
+
primaryError,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
throw primaryError;
|
|
257
|
+
}
|
|
258
|
+
const discardNewSession = async () => {
|
|
259
|
+
// A successful self-close revokes this tier-3 token before its response
|
|
260
|
+
// reaches us. If that response is lost, retrying the bearer loops on
|
|
261
|
+
// 401 forever; the durable setup credential is the authority that can
|
|
262
|
+
// confirm the idempotent close (including already_closed).
|
|
263
|
+
const closeArgs = {
|
|
264
|
+
session_id: session.session_id,
|
|
265
|
+
...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
|
|
266
|
+
};
|
|
267
|
+
const direct = await closeWithDurableCredential(call, closeArgs, {
|
|
268
|
+
auth: { "x-agent-api-key": session.session_token },
|
|
269
|
+
});
|
|
270
|
+
if (direct.closed) return;
|
|
271
|
+
const confirmed = await closeWithDurableCredential(call, closeArgs, cleanupOptions);
|
|
272
|
+
if (confirmed.closed) return;
|
|
273
|
+
throw new Error(cleanupError(confirmed.response) || direct.error?.message || "new harness session cleanup could not be confirmed");
|
|
274
|
+
};
|
|
275
|
+
const cachedCloseArgs = {
|
|
276
|
+
session_id: cached.session_id,
|
|
277
|
+
if_stale: true,
|
|
278
|
+
...(cached.session_token_generation ? { session_token_generation: cached.session_token_generation } : {}),
|
|
279
|
+
};
|
|
280
|
+
let retired;
|
|
281
|
+
try {
|
|
282
|
+
retired = await call("close_session", cachedCloseArgs, cleanupOptions);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// BOT-1650 (Codex round-22): a THROWN cached-close (e.g. its idempotent
|
|
285
|
+
// close committed but the response was lost) must not bypass cleanup and
|
|
286
|
+
// strand the newly-admitted session — whose bearer is neither returned nor
|
|
287
|
+
// persisted — while the active holder blocks a later bridge thread with
|
|
288
|
+
// agent_session_conflict. Retry once (if_stale is idempotent) to confirm
|
|
289
|
+
// the old close; if it still cannot be confirmed, retire the new session
|
|
290
|
+
// before propagating.
|
|
291
|
+
const confirm = await call("close_session", cachedCloseArgs, cleanupOptions).catch((retryError) => ({ ok: false, error: retryError?.message }));
|
|
292
|
+
if (!confirm?.ok || confirm.isError) {
|
|
293
|
+
// BOT-1650 (Codex round-23): if retiring the new session ALSO fails (e.g.
|
|
294
|
+
// the same outage hits its direct AND durable close), do NOT swallow that
|
|
295
|
+
// and claim cleanup succeeded — the replacement bearer would stay active
|
|
296
|
+
// and block the next bootstrap. Surface both failures; a fresh bootstrap
|
|
297
|
+
// retries retirement (the orphaned session ages out / is re-reconciled).
|
|
298
|
+
try {
|
|
299
|
+
await discardNewSession();
|
|
300
|
+
} catch (discardError) {
|
|
301
|
+
// BOT-1650 (Codex round-24/25): retirement failed, so the new session
|
|
302
|
+
// is admitted but unpersisted. Record a cleanup marker (id+generation,
|
|
303
|
+
// no bearer) for a later bootstrap to drain, and fail closed preserving
|
|
304
|
+
// the cached-close cause, the retirement failure, and any marker-write
|
|
305
|
+
// failure (never swallowed).
|
|
306
|
+
await recordOrphanAndFail({ session, cwd: worktree, retirementError: discardError, primaryError: error });
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
retired = confirm;
|
|
311
|
+
}
|
|
312
|
+
if (!retired?.ok || retired.isError) {
|
|
313
|
+
try {
|
|
314
|
+
await discardNewSession();
|
|
315
|
+
} catch (discardError) {
|
|
316
|
+
// BOT-1650 (Codex round-24/25): retirement failed here too — record the
|
|
317
|
+
// orphan and fail closed (propagating any marker-write failure).
|
|
318
|
+
await recordOrphanAndFail({ session, cwd: worktree, retirementError: discardError });
|
|
319
|
+
}
|
|
320
|
+
throw new Error(retired?.data?.error || retired?.error || "cached harness session is still live or could not be retired");
|
|
321
|
+
}
|
|
322
|
+
await clearHarnessSession(cached.session_id, {
|
|
323
|
+
cwd: worktree,
|
|
324
|
+
sessionToken: cached.session_token,
|
|
325
|
+
lock: false, // already inside withHarnessBootstrapLock (the same state lock); re-locking would deadlock
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
let state;
|
|
329
|
+
try {
|
|
330
|
+
state = await store(session, { cwd: worktree });
|
|
331
|
+
} catch (error) {
|
|
332
|
+
// The server session is unusable if this harness cannot persist its only
|
|
333
|
+
// credential. The freshly issued session bearer is scoped to this exact
|
|
334
|
+
// session and can self-close, so try it first — the durable OAuth/MCP
|
|
335
|
+
// credential may have expired or been revoked between bootstrap and
|
|
336
|
+
// cleanup, and using only that would strand an active session whose token
|
|
337
|
+
// was never returned or saved. Fall back to the durable credential just
|
|
338
|
+
// to confirm an ambiguous close (a lost tier-3 response that already
|
|
339
|
+
// revoked its own bearer).
|
|
340
|
+
const closeArgs = {
|
|
341
|
+
session_id: session.session_id,
|
|
342
|
+
...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
|
|
343
|
+
};
|
|
344
|
+
const direct = await closeWithDurableCredential(call, closeArgs, {
|
|
345
|
+
auth: { "x-agent-api-key": session.session_token },
|
|
346
|
+
});
|
|
347
|
+
if (direct.closed) throw error;
|
|
348
|
+
const cleanupOptions = callOptions ?? await resolveCleanupOptions();
|
|
349
|
+
if (!cleanupOptions) {
|
|
350
|
+
// BOT-1650 (Codex round-26): the fresh bearer could not self-close AND no
|
|
351
|
+
// durable credential is available to retire the admitted session — so it
|
|
352
|
+
// cannot be closed at all. Record the orphan (so a later bootstrap with a
|
|
353
|
+
// credential drains it) and fail closed, instead of throwing with no marker.
|
|
354
|
+
await recordOrphanAndFail({
|
|
355
|
+
session,
|
|
356
|
+
cwd: worktree,
|
|
357
|
+
retirementError: new Error("no durable credential is available to confirm session cleanup"),
|
|
358
|
+
primaryError: error,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
const confirmed = await closeWithDurableCredential(call, closeArgs, cleanupOptions);
|
|
362
|
+
if (!confirmed.closed) {
|
|
363
|
+
// BOT-1650 (Codex round-25): the session was admitted but its credential
|
|
364
|
+
// could not be persisted AND neither the fresh-bearer nor the durable
|
|
365
|
+
// close confirmed retirement — an orphaned live holder unrelated to cache
|
|
366
|
+
// replacement (e.g. a Windows ACL-hardening failure in store()). Record it
|
|
367
|
+
// so a later bootstrap drains it, and fail closed preserving both causes.
|
|
368
|
+
const retirementError = new Error(cleanupError(confirmed.response) || direct.error?.message || "bootstrap persistence cleanup could not be confirmed");
|
|
369
|
+
await recordOrphanAndFail({ session, cwd: worktree, retirementError, primaryError: error });
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
return { ...state, ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
|
|
374
|
+
});
|
|
375
|
+
}
|
package/src/setup-block.mjs
CHANGED
|
@@ -8,46 +8,39 @@
|
|
|
8
8
|
// session persistence; normal operator output must never turn that into a
|
|
9
9
|
// multi-command credential handoff.
|
|
10
10
|
|
|
11
|
+
import { credentialTier } from "./credential-kinds.mjs";
|
|
12
|
+
|
|
11
13
|
/** The canonical guide, referenced from every SETUP block and recovery. */
|
|
12
14
|
export const CONNECTIVITY_GUIDE = "docs/agent-connectivity.md";
|
|
13
15
|
|
|
14
16
|
// The three connectivity credential tiers, named consistently everywhere. A
|
|
15
17
|
// connectivity error names the MISSING tier and the exact next command; the
|
|
16
18
|
// guide's tiered table and the help SETUP block use these same labels.
|
|
19
|
+
//
|
|
20
|
+
// BOT-1701: the noun, prefix, flag, env, and kind for each tier are OWNED by
|
|
21
|
+
// credential-kinds.mjs (CREDENTIAL_TIERS) — the single source of truth. This map
|
|
22
|
+
// only adds the setup-specific fields (the tier number, the `setup` command, and
|
|
23
|
+
// the scope). A drift test asserts every user-facing surface agrees with the
|
|
24
|
+
// canonical table, so `label`/`prefix`/`env`/`flag` here can never fork from it.
|
|
17
25
|
export const TIERS = Object.freeze({
|
|
18
26
|
// Tier 1 — per-machine client key. `bb login`, USER-scoped (reaches every
|
|
19
27
|
// tenant you belong to; each request pins one). Never a wait credential.
|
|
20
|
-
client: Object.freeze({
|
|
21
|
-
n: 1,
|
|
22
|
-
label: "client key",
|
|
23
|
-
prefix: "bb_cli_",
|
|
24
|
-
env: "BOTBUDDY_CLIENT_KEY",
|
|
25
|
-
setup: "bb login",
|
|
26
|
-
scope: "user",
|
|
27
|
-
}),
|
|
28
|
+
client: Object.freeze({ ...tierFields(1), setup: "bb login", scope: "user" }),
|
|
28
29
|
// Tier 2 — per-tenant MCP config key. `bb mcp setup`, TENANT-bound, lives in
|
|
29
30
|
// .mcp.json under $BOTBUDDY_MCP_KEY. Authenticates MCP calls + register_agent.
|
|
30
|
-
mcp: Object.freeze({
|
|
31
|
-
|
|
32
|
-
label: "MCP key",
|
|
33
|
-
prefix: "bb_mcp_",
|
|
34
|
-
env: "BOTBUDDY_MCP_KEY",
|
|
35
|
-
setup: "bb mcp setup",
|
|
36
|
-
scope: "tenant",
|
|
37
|
-
}),
|
|
38
|
-
// Tier 3 — per-session agent token, tenant-bound, 8 h TTL, revoked on
|
|
31
|
+
mcp: Object.freeze({ ...tierFields(2), setup: "bb mcp setup", scope: "tenant" }),
|
|
32
|
+
// Tier 3 — per-session agent session token, tenant-bound, 8 h TTL, revoked on
|
|
39
33
|
// re-register. The CLI caches it in .botbuddy/agent-state.json and restores
|
|
40
34
|
// it automatically for wait/run/test/pw.
|
|
41
|
-
session: Object.freeze({
|
|
42
|
-
n: 3,
|
|
43
|
-
label: "session token",
|
|
44
|
-
prefix: "bb_sess_",
|
|
45
|
-
env: "BOTBUDDY_AGENT_SESSION_TOKEN",
|
|
46
|
-
setup: "register_agent",
|
|
47
|
-
scope: "session",
|
|
48
|
-
}),
|
|
35
|
+
session: Object.freeze({ ...tierFields(3), setup: "register_agent", scope: "session" }),
|
|
49
36
|
});
|
|
50
37
|
|
|
38
|
+
/** Project a canonical CREDENTIAL_TIERS row onto the TIERS shape (n/label/…). */
|
|
39
|
+
function tierFields(tier) {
|
|
40
|
+
const t = credentialTier(tier);
|
|
41
|
+
return { n: t.tier, label: t.noun, prefix: t.prefix, env: t.env, flag: t.flag };
|
|
42
|
+
}
|
|
43
|
+
|
|
51
44
|
/**
|
|
52
45
|
* The shared SETUP block reproduced verbatim in every `--help` output. Plain
|
|
53
46
|
* text (no ANSI) so the snapshot test can match it byte-for-byte across surfaces.
|
package/src/test-lane.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { execFileSync } from "node:child_process";
|
|
|
21
21
|
|
|
22
22
|
import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
|
|
23
23
|
import { callToolJson } from "./api.mjs";
|
|
24
|
-
import { readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
24
|
+
import { readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
25
25
|
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
26
26
|
|
|
27
27
|
// EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
|
|
@@ -338,7 +338,8 @@ function laneList(cwd) {
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
function testHelp() {
|
|
341
|
-
console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
|
|
341
|
+
console.log(`botbuddy test <lane> [--session-id <uuid>] [--agent-session-token <token>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
|
|
342
|
+
--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases
|
|
342
343
|
botbuddy test list List the lanes configured in .botbuddy/lanes.json
|
|
343
344
|
botbuddy test help Show this help
|
|
344
345
|
|
|
@@ -351,6 +352,9 @@ export async function cmdTest(args, { cwd = process.cwd() } = {}) {
|
|
|
351
352
|
const { subcommand } = parseTestArgs(args);
|
|
352
353
|
if (subcommand === "help" || args[0] === "--help" || args[0] === "-h") { testHelp(); return; }
|
|
353
354
|
if (subcommand === "list") { process.exitCode = laneList(cwd); return; }
|
|
355
|
+
// BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy flag/env alias.
|
|
356
|
+
const aliasHint = legacyAgentAliasHint({ argv: args, env: process.env });
|
|
357
|
+
if (aliasHint) process.stderr.write(`botbuddy test: ${aliasHint}\n`);
|
|
354
358
|
const result = await launchTestLane(args, { cwd });
|
|
355
359
|
process.stdout.write(result.line + "\n");
|
|
356
360
|
// --wait (AC-9): after launch, block until the run completes and exit with the
|
package/src/wait.mjs
CHANGED
|
@@ -18,8 +18,8 @@ import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
|
|
|
18
18
|
import { VERSION } from "./version.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
21
|
-
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
22
|
-
import { clearAgentState, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
21
|
+
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
22
|
+
import { clearAgentState, hasBootstrapAgentSession, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
23
23
|
import { touchAgentStateExpiry } from "./agent-state.mjs";
|
|
24
24
|
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
25
25
|
import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
|
|
@@ -1344,6 +1344,16 @@ async function acknowledgeSavedWait(id) {
|
|
|
1344
1344
|
|
|
1345
1345
|
export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
1346
1346
|
if (argv.length === 0 || argv[0] === "--offset") return inspectSavedWaits(argv);
|
|
1347
|
+
// BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy alias (flag, env, or a
|
|
1348
|
+
// bb_agent_ token value), emitted BEFORE any subcommand dispatch so the
|
|
1349
|
+
// credentialed recovery actions (resume/cancel/acknowledge) warn too (Codex R7).
|
|
1350
|
+
// STDERR only, so a machine-parseable stdout receipt is byte-identical. Skipped
|
|
1351
|
+
// on the internal recovery re-entry (resumeSavedWait recurses into runWait with
|
|
1352
|
+
// recoveryLocalWaitId set) so a resume never nudges twice (Codex R8).
|
|
1353
|
+
if (recoveryLocalWaitId == null) {
|
|
1354
|
+
const aliasHint = legacyAgentAliasHint({ argv, env: process.env });
|
|
1355
|
+
if (aliasHint) process.stderr.write(`botbuddy wait: ${aliasHint}\n`);
|
|
1356
|
+
}
|
|
1347
1357
|
if (argv[0] === "resume") {
|
|
1348
1358
|
const id = argv[1];
|
|
1349
1359
|
if (!id || argv.length > 2) return emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "select exactly one saved wait: bb wait resume <local_wait_id>"));
|
|
@@ -1505,7 +1515,11 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1505
1515
|
: new WaitCheckpointError("wait_checkpoint_write_failed", String(error?.message ?? error)));
|
|
1506
1516
|
}
|
|
1507
1517
|
|
|
1508
|
-
|
|
1518
|
+
// BOT-1650: a plain timer stays offline, but a timer from a bootstrapped harness
|
|
1519
|
+
// registers so its terminal receipt proves the current session principal (the
|
|
1520
|
+
// cross-shell bootstrap contract). Detected without a network self-heal.
|
|
1521
|
+
const needsRelay = conditions.some((c) => c.type !== "timer")
|
|
1522
|
+
|| await hasBootstrapAgentSession({ cwd: process.cwd() });
|
|
1509
1523
|
if (needsRelay) {
|
|
1510
1524
|
try {
|
|
1511
1525
|
const credential = await resolveAgentSessionCredential({ argv, env: process.env, cwd: process.cwd() });
|