@botbuddy/cli 1.25.0 → 1.27.0
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 +2 -1
- package/src/agent-credential-store.mjs +11 -181
- package/src/api.mjs +8 -8
- package/src/auth.mjs +1 -1
- package/src/cli-credentials.mjs +11 -6
- package/src/codex-bridge.mjs +3 -3
- package/src/commands.mjs +94 -140
- package/src/config.mjs +1 -1
- package/src/mcp-key.mjs +23 -22
- package/src/pw/coordinator.mjs +0 -5
- package/src/pw/run.mjs +32 -32
- package/src/run.mjs +165 -10
- package/src/stack.mjs +3 -3
- package/src/telemetry-config.mjs +109 -0
- package/src/telemetry-delivery.mjs +40 -0
- package/src/telemetry-outbox.mjs +327 -0
- package/src/wait-profile.mjs +128 -79
- package/src/wait.mjs +88 -226
- package/src/profile-bootstrap.mjs +0 -252
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
import { hostname } from "node:os";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
|
|
4
|
-
import { ensureProfileCredentialBackend, readProfileIdentity, readProfileRetryIdentity, recordProfileRetryIdentity, installProfileCredential, profileCredentialEnvironment, ProfileCredentialStoreError } from "./agent-credential-store.mjs";
|
|
5
|
-
import { callToolJson, resolveCallAuth } from "./api.mjs";
|
|
6
|
-
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
7
|
-
|
|
8
|
-
const PROFILES = Object.freeze({
|
|
9
|
-
"botbuddy-dev": Object.freeze({ tenant: "botbuddy" }),
|
|
10
|
-
"supplyguard-dev": Object.freeze({ tenant: "supply-guard" }),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
function getAgentProfile(name) {
|
|
14
|
-
return PROFILES[name] ?? null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class ProfileBootstrapError extends Error {
|
|
18
|
-
constructor(code) {
|
|
19
|
-
super(code);
|
|
20
|
-
this.code = code;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function defaultProfileAgentName(profile) {
|
|
25
|
-
const host = hostname().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
26
|
-
const suffix = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
27
|
-
return `${profile}-${host || "host"}-${suffix}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// A structured MCP error code can arrive on a tool result body (`data.code`) or,
|
|
31
|
-
// for a 401/403, on the top-level auth receipt (`code`) that callToolJson lifts
|
|
32
|
-
// out of the JSON-RPC error body (BOT-1561). Read whichever is present.
|
|
33
|
-
function structuredCode(response) {
|
|
34
|
-
if (typeof response?.data?.code === "string") return response.data.code;
|
|
35
|
-
if (typeof response?.code === "string") return response.code;
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function registrationCredential(data) {
|
|
40
|
-
return typeof data?.agent_api_key === "string"
|
|
41
|
-
? data.agent_api_key
|
|
42
|
-
: typeof data?.api_key === "string"
|
|
43
|
-
? data.api_key
|
|
44
|
-
: null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function bootstrapProfile(profileName, {
|
|
48
|
-
name = null,
|
|
49
|
-
call = callToolJson,
|
|
50
|
-
resolveAuth = resolveCallAuth,
|
|
51
|
-
readIdentity = readProfileIdentity,
|
|
52
|
-
readRetryIdentity = readProfileRetryIdentity,
|
|
53
|
-
recordRetryIdentity = recordProfileRetryIdentity,
|
|
54
|
-
store = installProfileCredential,
|
|
55
|
-
ensureBackend = ensureProfileCredentialBackend,
|
|
56
|
-
} = {}) {
|
|
57
|
-
const profile = getAgentProfile(profileName);
|
|
58
|
-
if (!profile) throw new ProfileBootstrapError("profile_required");
|
|
59
|
-
try { ensureBackend(); } catch { throw new ProfileBootstrapError("profile_agent_required"); }
|
|
60
|
-
|
|
61
|
-
const existing = await readIdentity(profileName);
|
|
62
|
-
const retryIdentity = existing ? null : await readRetryIdentity(profileName);
|
|
63
|
-
// A mismatched local entry is never reused. A fresh server-attested
|
|
64
|
-
// registration replaces it atomically, so the documented recovery command
|
|
65
|
-
// cannot loop forever on stale metadata.
|
|
66
|
-
const reusableIdentity = existing?.tenant === profile.tenant ? existing.agentId : retryIdentity?.agentId ?? null;
|
|
67
|
-
const agentName = name ?? (existing?.tenant === profile.tenant ? existing.name : retryIdentity?.name ?? defaultProfileAgentName(profileName));
|
|
68
|
-
// BOT-1487: tenancy is auth-bound after BOT-1416 — register_agent selects the
|
|
69
|
-
// tenant from the OAuth session / agent key and rejects a runtime tenant_id
|
|
70
|
-
// with MCP_TENANT_CONTEXT_IS_AUTH_ONLY. So we no longer send tenant_id; the
|
|
71
|
-
// returned tenant_id is attested against the profile's tenant below instead.
|
|
72
|
-
//
|
|
73
|
-
// register_agent is a MUTATING call (it persists the agent, its credential
|
|
74
|
-
// hash, and tenant membership). If the session is logged into another tenant,
|
|
75
|
-
// registering first and rejecting the response afterwards would leave a stray,
|
|
76
|
-
// unreachable agent in the wrong tenant on every attempt (names are randomized
|
|
77
|
-
// and the wrong-tenant path records no retry identity). So resolve the bound
|
|
78
|
-
// tenant with the read-only `whoami` FIRST and gate registration on it.
|
|
79
|
-
//
|
|
80
|
-
// Fail CLOSED (AGENTS.md — no fallbacks; non-determinism is worse than
|
|
81
|
-
// failure): the mutating register_agent runs ONLY on a positively attested
|
|
82
|
-
// tenant that matches the profile. A missing/erroring/malformed whoami never
|
|
83
|
-
// silently proceeds, because the later attestation cannot undo an agent that
|
|
84
|
-
// registration already persisted in the wrong tenant.
|
|
85
|
-
//
|
|
86
|
-
// Snapshot the credential ONCE and pin it to both whoami and register_agent.
|
|
87
|
-
// Otherwise each call re-reads the owner token, so a concurrent `botbuddy
|
|
88
|
-
// login` could swap the bound tenant between the attesting whoami and the
|
|
89
|
-
// mutating register (a TOCTOU that would register in the second tenant).
|
|
90
|
-
const pinned = await resolveAuth();
|
|
91
|
-
if (pinned?.error) throw new ProfileBootstrapError("profile_agent_required");
|
|
92
|
-
const auth = pinned?.auth ?? null;
|
|
93
|
-
// BOT-1561: a multi-tenant owner's OAuth credential is tenant-ambiguous, so an
|
|
94
|
-
// unpinned whoami returns no tenant_id and BOT-1487's gate throws
|
|
95
|
-
// profile_tenant_unverified. Each profile already knows its tenant, so pin it
|
|
96
|
-
// on BOTH calls: whoami resolves/confirms exactly that tenant (the server
|
|
97
|
-
// refuses a non-member owner → profile_credential_wrong_tenant below), and
|
|
98
|
-
// register_agent mints in the pinned tenant. Preserves BOT-1487's fail-closed
|
|
99
|
-
// + single-credential pinning guarantees.
|
|
100
|
-
const tenant = profile.tenant;
|
|
101
|
-
const identity = await call("whoami", {}, { auth, tenant });
|
|
102
|
-
const identityCode = structuredCode(identity);
|
|
103
|
-
// BOT-1561: the server refuses the pin with MCP_TENANT_MEMBERSHIP_REQUIRED
|
|
104
|
-
// (HTTP 403) when the credential cannot register in the profile's tenant —
|
|
105
|
-
// either the owner is not a member, or a tenant-bound login is sealed to a
|
|
106
|
-
// DIFFERENT tenant (resolveHintedTenant never repoints a bound credential).
|
|
107
|
-
// callToolJson now preserves that code from the 403 body; map it to the
|
|
108
|
-
// wrong-tenant outcome so setup reports the accurate, actionable failure
|
|
109
|
-
// instead of the misleading profile_tenant_unverified the swallowed 403
|
|
110
|
-
// previously produced. register_agent never runs.
|
|
111
|
-
if (identityCode === "MCP_TENANT_MEMBERSHIP_REQUIRED") {
|
|
112
|
-
throw new ProfileBootstrapError("profile_credential_wrong_tenant");
|
|
113
|
-
}
|
|
114
|
-
if (identityCode === "MCP_TENANT_SELECTION_REQUIRED" || identityCode === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY") {
|
|
115
|
-
throw new ProfileBootstrapError("profile_tenant_selection_required");
|
|
116
|
-
}
|
|
117
|
-
const boundTenant = typeof identity?.data?.tenant_id === "string" ? identity.data.tenant_id : null;
|
|
118
|
-
if (!boundTenant) {
|
|
119
|
-
// whoami did not positively attest a tenant (transport/server error or a
|
|
120
|
-
// response without a string tenant_id) — refuse rather than register blind.
|
|
121
|
-
throw new ProfileBootstrapError("profile_tenant_unverified");
|
|
122
|
-
}
|
|
123
|
-
if (boundTenant !== profile.tenant) {
|
|
124
|
-
throw new ProfileBootstrapError("profile_credential_wrong_tenant");
|
|
125
|
-
}
|
|
126
|
-
const args = {
|
|
127
|
-
name: agentName,
|
|
128
|
-
type: "codex",
|
|
129
|
-
...(reusableIdentity ? { agent_id: reusableIdentity } : {}),
|
|
130
|
-
};
|
|
131
|
-
const response = await call("register_agent", args, { auth, tenant });
|
|
132
|
-
const data = response?.data;
|
|
133
|
-
const responseCode = structuredCode(response);
|
|
134
|
-
// Defense-in-depth: whoami above already gated on the tenant, so register_agent
|
|
135
|
-
// should not return a tenant-context error here — but if the contract drifts,
|
|
136
|
-
// surface its own actionable recovery, not the misleading
|
|
137
|
-
// `profile_agent_required` → `botbuddy login` loop that keeps failing because
|
|
138
|
-
// login itself is fine.
|
|
139
|
-
if (responseCode === "MCP_TENANT_MEMBERSHIP_REQUIRED") {
|
|
140
|
-
throw new ProfileBootstrapError("profile_credential_wrong_tenant");
|
|
141
|
-
}
|
|
142
|
-
if (responseCode === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY" || responseCode === "MCP_TENANT_SELECTION_REQUIRED") {
|
|
143
|
-
throw new ProfileBootstrapError("profile_tenant_selection_required");
|
|
144
|
-
}
|
|
145
|
-
if (!response?.ok || (response.isError && data?.code !== "FRESH_CONNECTION_REQUIRED")) {
|
|
146
|
-
throw new ProfileBootstrapError("profile_agent_required");
|
|
147
|
-
}
|
|
148
|
-
if (!data?.agent_id || typeof data.agent_id !== "string") {
|
|
149
|
-
throw new ProfileBootstrapError("profile_agent_required");
|
|
150
|
-
}
|
|
151
|
-
if (typeof data.tenant_id !== "string") {
|
|
152
|
-
await recordRetryIdentity({ profile: profileName, agentId: data.agent_id, name: agentName });
|
|
153
|
-
throw new ProfileBootstrapError("profile_tenant_attestation_missing");
|
|
154
|
-
}
|
|
155
|
-
if (data.tenant_id !== profile.tenant) {
|
|
156
|
-
throw new ProfileBootstrapError("profile_credential_wrong_tenant");
|
|
157
|
-
}
|
|
158
|
-
const token = registrationCredential(data);
|
|
159
|
-
if (!token) throw new ProfileBootstrapError("profile_agent_required");
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
await store({
|
|
163
|
-
profile: profileName,
|
|
164
|
-
tenant: profile.tenant,
|
|
165
|
-
agentId: data.agent_id,
|
|
166
|
-
name: agentName,
|
|
167
|
-
token,
|
|
168
|
-
});
|
|
169
|
-
} catch (error) {
|
|
170
|
-
// OAuth + registration + tenant attestation all succeeded; only persisting
|
|
171
|
-
// the credential failed. Give the two persistence failures accurate,
|
|
172
|
-
// distinct recoveries — never the `botbuddy login` OAuth recovery:
|
|
173
|
-
// • a Keychain write failure (ProfileCredentialStoreError) is resolved by
|
|
174
|
-
// unlocking the login keychain;
|
|
175
|
-
// • a filesystem/lock failure writing ~/.botbuddy/agent-profiles.json
|
|
176
|
-
// (EACCES/ENOSPC/busy store lock) is NOT — unlocking the keychain would
|
|
177
|
-
// not help, so it gets its own code.
|
|
178
|
-
if (error instanceof ProfileBootstrapError) throw error;
|
|
179
|
-
const code = error instanceof ProfileCredentialStoreError
|
|
180
|
-
? "profile_credential_store_failed"
|
|
181
|
-
: "profile_credential_persist_failed";
|
|
182
|
-
const failure = new ProfileBootstrapError(code);
|
|
183
|
-
failure.cause = error;
|
|
184
|
-
throw failure;
|
|
185
|
-
}
|
|
186
|
-
return {
|
|
187
|
-
schema_version: 1,
|
|
188
|
-
outcome: "installed",
|
|
189
|
-
profile: profileName,
|
|
190
|
-
tenant_id: profile.tenant,
|
|
191
|
-
agent_id: data.agent_id,
|
|
192
|
-
credential_source: "keychain_profile_slot",
|
|
193
|
-
shell_refresh: `source <(${latestPublicCliCommand(`profile env ${profileName}`)})`,
|
|
194
|
-
gui_refresh: "$HOME/.local/bin/botbuddy-mcp-env.sh",
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Maps a bootstrap error code to the recovery hint shown in the CLI receipt.
|
|
199
|
-
// A credential-store/Keychain failure gets its own actionable recovery; every
|
|
200
|
-
// other failure keeps the historical login-first recovery verbatim.
|
|
201
|
-
export function profileBootstrapRecovery(code, profileName) {
|
|
202
|
-
if (code === "profile_credential_store_failed") {
|
|
203
|
-
return `unlock your login keychain (security unlock-keychain), then re-run: botbuddy profile setup ${profileName}`;
|
|
204
|
-
}
|
|
205
|
-
if (code === "profile_credential_persist_failed") {
|
|
206
|
-
return `ensure ~/.botbuddy is writable and no other setup is running, then re-run: botbuddy profile setup ${profileName}`;
|
|
207
|
-
}
|
|
208
|
-
if (code === "profile_credential_wrong_tenant") {
|
|
209
|
-
// BOT-1561: reached when the login is authorized for a different tenant (a
|
|
210
|
-
// tenant-bound token can't be repointed) or the owner isn't a member of the
|
|
211
|
-
// profile's tenant. The fix is to authenticate INTO the profile's tenant —
|
|
212
|
-
// not the bare login loop, which relogs into the same wrong tenant.
|
|
213
|
-
// BOT-1571: a plain `botbuddy login` (no --tenant) now mints a USER token
|
|
214
|
-
// that reaches every tenant the owner belongs to, so the fix is that — not
|
|
215
|
-
// "log in again and pick this tenant", which is exactly the per-tenant
|
|
216
|
-
// re-login friction BOT-1571 removes.
|
|
217
|
-
// BOT-1571 (Codex follow-up): MCP_TENANT_MEMBERSHIP_REQUIRED conflates two
|
|
218
|
-
// causes and the CLI cannot tell them apart from the code alone — the owner
|
|
219
|
-
// is not a member of the tenant, OR a `--tenant`-sealed login is pinned to a
|
|
220
|
-
// different one. Lead with the membership case and say re-login will NOT fix
|
|
221
|
-
// it (a user token still can't reach a tenant you don't belong to), so a
|
|
222
|
-
// non-member is not sent through a no-op `botbuddy login` loop; offer the
|
|
223
|
-
// re-login only for the genuinely-sealed case.
|
|
224
|
-
const tenant = getAgentProfile(profileName)?.tenant;
|
|
225
|
-
const forTenant = tenant ? ` tenant "${tenant}"` : " this profile's tenant";
|
|
226
|
-
const memberHint = tenant
|
|
227
|
-
? `if you are not a member of "${tenant}", ask an admin to add you — a re-login will not help`
|
|
228
|
-
: "if you are not a member of it, ask an admin to add you — a re-login will not help";
|
|
229
|
-
return `your login is not authorized for${forTenant}: ${memberHint}. If instead your login is sealed to a different tenant (you signed in with --tenant), run botbuddy login (without --tenant) to get a user token that reaches all your tenants, then re-run: botbuddy profile setup ${profileName}`;
|
|
230
|
-
}
|
|
231
|
-
if (code === "profile_tenant_unverified") {
|
|
232
|
-
// whoami could not confirm the bound tenant, so registration was refused
|
|
233
|
-
// before any mutation. Usually transient — retry; login only if it persists.
|
|
234
|
-
return `could not confirm your tenant with the server — retry: botbuddy profile setup ${profileName} (if it persists, run botbuddy login, then re-run setup)`;
|
|
235
|
-
}
|
|
236
|
-
if (code === "profile_tenant_selection_required") {
|
|
237
|
-
// Tenancy is chosen at MCP authentication (BOT-1416), not passed to
|
|
238
|
-
// register_agent. Point the user at selecting the profile's tenant when
|
|
239
|
-
// they sign in — never the bare login loop that implies login is broken.
|
|
240
|
-
const tenant = getAgentProfile(profileName)?.tenant;
|
|
241
|
-
return tenant
|
|
242
|
-
? `sign in and select the "${tenant}" tenant during authentication, then re-run: botbuddy profile setup ${profileName}`
|
|
243
|
-
: `sign in and select the matching tenant during authentication, then re-run: botbuddy profile setup ${profileName}`;
|
|
244
|
-
}
|
|
245
|
-
return `botbuddy login && botbuddy profile setup ${profileName}`;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export function profileShellRefresh(profileName) {
|
|
249
|
-
const tokenEnv = profileCredentialEnvironment(profileName);
|
|
250
|
-
if (!tokenEnv) throw new ProfileBootstrapError("profile_required");
|
|
251
|
-
return `if botbuddy_profile_token="$(security find-generic-password -a \"$USER\" -s \"${tokenEnv}\" -w)"; then export ${tokenEnv}="$botbuddy_profile_token"; unset botbuddy_profile_token; else unset botbuddy_profile_token; false; fi`;
|
|
252
|
-
}
|