@botbuddy/cli 1.9.2 → 1.12.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/api.mjs +30 -3
- package/src/profile-bootstrap.mjs +46 -4
- package/src/botbuddy-release-repair.json +0 -1
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -71,7 +71,20 @@ export async function resolveCallAuth() {
|
|
|
71
71
|
return { error: { ok: false, auth: true, error: "not_authenticated" } };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// BOT-1561: a multi-membership owner credential is tenant-ambiguous without an
|
|
75
|
+
// explicit pin — the server (mcpSessionIdentity, BOT-1521) fails closed, so
|
|
76
|
+
// stateless REST tools/call like whoami return no tenant_id. `tenant` pins the
|
|
77
|
+
// call to a specific tenant by appending ?tenant=<slug> to the endpoint (the
|
|
78
|
+
// same mechanism as the documented BOTBUDDY_SERVER_URL override), letting the
|
|
79
|
+
// server resolve/confirm exactly that tenant.
|
|
80
|
+
function toolEndpoint(tenant) {
|
|
81
|
+
if (!tenant) return SERVER_URL;
|
|
82
|
+
const url = new URL(SERVER_URL);
|
|
83
|
+
url.searchParams.set("tenant", tenant);
|
|
84
|
+
return url.toString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null } = {}) {
|
|
75
88
|
let auth = pinnedAuth;
|
|
76
89
|
if (!auth) {
|
|
77
90
|
const resolved = await resolveCallAuth();
|
|
@@ -81,7 +94,7 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
|
|
|
81
94
|
const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
|
|
82
95
|
let res;
|
|
83
96
|
try {
|
|
84
|
-
res = await fetchImpl(
|
|
97
|
+
res = await fetchImpl(toolEndpoint(tenant), {
|
|
85
98
|
method: "POST",
|
|
86
99
|
headers: { "Content-Type": "application/json", ...auth },
|
|
87
100
|
body: JSON.stringify(body),
|
|
@@ -90,7 +103,21 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
|
|
|
90
103
|
} catch (err) {
|
|
91
104
|
return { ok: false, status: null, error: `transport: ${err?.message ?? err}` };
|
|
92
105
|
}
|
|
93
|
-
if (res.status === 401 || res.status === 403)
|
|
106
|
+
if (res.status === 401 || res.status === 403) {
|
|
107
|
+
// BOT-1561: a 401/403 can still carry a structured MCP error code in its
|
|
108
|
+
// JSON-RPC body (e.g. MCP_TENANT_MEMBERSHIP_REQUIRED from a connection
|
|
109
|
+
// tenant-pin refusal — index.ts serializes it as
|
|
110
|
+
// { error: { code: -32002, data: { code } } }). Surface that `code` so a
|
|
111
|
+
// caller can tell a tenant refusal apart from a plain credential failure.
|
|
112
|
+
// Best-effort: an opaque/bodyless 401/403 keeps the generic shape.
|
|
113
|
+
let code = null;
|
|
114
|
+
try {
|
|
115
|
+
const body = await res.json();
|
|
116
|
+
const raw = body?.error?.data?.code ?? body?.error?.code ?? body?.code ?? null;
|
|
117
|
+
if (typeof raw === "string" && raw) code = raw;
|
|
118
|
+
} catch { /* opaque body */ }
|
|
119
|
+
return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}) };
|
|
120
|
+
}
|
|
94
121
|
let payload;
|
|
95
122
|
try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
|
|
96
123
|
if (payload.error?.message) return { ok: false, status: res.status, error: payload.error.message };
|
|
@@ -27,6 +27,15 @@ export function defaultProfileAgentName(profile) {
|
|
|
27
27
|
return `${profile}-${host || "host"}-${suffix}`;
|
|
28
28
|
}
|
|
29
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
|
+
|
|
30
39
|
function registrationCredential(data) {
|
|
31
40
|
return typeof data?.agent_api_key === "string"
|
|
32
41
|
? data.agent_api_key
|
|
@@ -81,8 +90,27 @@ export async function bootstrapProfile(profileName, {
|
|
|
81
90
|
const pinned = await resolveAuth();
|
|
82
91
|
if (pinned?.error) throw new ProfileBootstrapError("profile_agent_required");
|
|
83
92
|
const auth = pinned?.auth ?? null;
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
}
|
|
86
114
|
if (identityCode === "MCP_TENANT_SELECTION_REQUIRED" || identityCode === "MCP_TENANT_CONTEXT_IS_AUTH_ONLY") {
|
|
87
115
|
throw new ProfileBootstrapError("profile_tenant_selection_required");
|
|
88
116
|
}
|
|
@@ -100,14 +128,18 @@ export async function bootstrapProfile(profileName, {
|
|
|
100
128
|
type: "codex",
|
|
101
129
|
...(reusableIdentity ? { agent_id: reusableIdentity } : {}),
|
|
102
130
|
};
|
|
103
|
-
const response = await call("register_agent", args, { auth });
|
|
131
|
+
const response = await call("register_agent", args, { auth, tenant });
|
|
104
132
|
const data = response?.data;
|
|
133
|
+
const responseCode = structuredCode(response);
|
|
105
134
|
// Defense-in-depth: whoami above already gated on the tenant, so register_agent
|
|
106
135
|
// should not return a tenant-context error here — but if the contract drifts,
|
|
107
136
|
// surface its own actionable recovery, not the misleading
|
|
108
137
|
// `profile_agent_required` → `botbuddy login` loop that keeps failing because
|
|
109
138
|
// login itself is fine.
|
|
110
|
-
if (
|
|
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") {
|
|
111
143
|
throw new ProfileBootstrapError("profile_tenant_selection_required");
|
|
112
144
|
}
|
|
113
145
|
if (!response?.ok || (response.isError && data?.code !== "FRESH_CONNECTION_REQUIRED")) {
|
|
@@ -173,6 +205,16 @@ export function profileBootstrapRecovery(code, profileName) {
|
|
|
173
205
|
if (code === "profile_credential_persist_failed") {
|
|
174
206
|
return `ensure ~/.botbuddy is writable and no other setup is running, then re-run: botbuddy profile setup ${profileName}`;
|
|
175
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
|
+
const tenant = getAgentProfile(profileName)?.tenant;
|
|
214
|
+
return tenant
|
|
215
|
+
? `run botbuddy login and select the "${tenant}" tenant during authentication, then re-run: botbuddy profile setup ${profileName} (if you are not a member of "${tenant}", ask an admin for access)`
|
|
216
|
+
: `run botbuddy login and select the profile's tenant during authentication, then re-run: botbuddy profile setup ${profileName}`;
|
|
217
|
+
}
|
|
176
218
|
if (code === "profile_tenant_unverified") {
|
|
177
219
|
// whoami could not confirm the bound tenant, so registration was refused
|
|
178
220
|
// before any mutation. Usually transient — retry; login only if it persists.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"schema_version":1,"source_version":"1.9.0","source_identity":"8bc02282f116b4857951c61a187ad7894c3153b36559b46cd171df24b0af98c4"}
|