@botbuddy/cli 1.21.1 → 1.23.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 +1 -1
- package/src/api.mjs +78 -17
- package/src/auth.mjs +12 -1
- package/src/cli-credentials.mjs +44 -11
- package/src/commands.mjs +153 -39
- package/src/discovery.mjs +14 -1
- package/src/oauth-loopback.mjs +12 -4
- package/src/profile-bootstrap.mjs +16 -3
- package/src/stack.mjs +19 -0
- package/src/wait.mjs +60 -6
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -1,7 +1,53 @@
|
|
|
1
1
|
import { getConfig, SERVER_URL } from "./config.mjs";
|
|
2
2
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
3
|
+
import { findProfileName, getAgentProfile } from "./wait-profile.mjs";
|
|
3
4
|
import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
|
|
4
5
|
|
|
6
|
+
// Same slug shape the server accepts on `?tenant=`.
|
|
7
|
+
const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
8
|
+
|
|
9
|
+
// BOT-1571 (Codex P1): a plain `botbuddy login` mints a USER token, which the
|
|
10
|
+
// server refuses unpinned (MCP_TENANT_PIN_REQUIRED). Generic commands
|
|
11
|
+
// (`botbuddy call`, resources) therefore derive a default pin:
|
|
12
|
+
// 1. BOTBUDDY_TENANT (explicit; malformed → usage error, never sent),
|
|
13
|
+
// 2. the repo's .botbuddy-agent.json profile tenant (walks up from cwd),
|
|
14
|
+
// 3. the token's sole reachable tenant,
|
|
15
|
+
// 4. none — the server's error names the fix.
|
|
16
|
+
// A sealed (tenant-mode) or pre-1571 token gets NO derived pin: the server
|
|
17
|
+
// resolves its own tenant, and a differing pin would be a hard conflict
|
|
18
|
+
// (a pin can confirm a sealed tenant, never repoint it). BOTBUDDY_TENANT is
|
|
19
|
+
// always honoured — it is the operator's explicit statement.
|
|
20
|
+
export async function resolveDefaultTenantPin({
|
|
21
|
+
getConfig: getCfg = getConfig,
|
|
22
|
+
env = process.env,
|
|
23
|
+
cwd = process.cwd(),
|
|
24
|
+
findProfile = findProfileName,
|
|
25
|
+
profileFor = getAgentProfile,
|
|
26
|
+
} = {}) {
|
|
27
|
+
const explicit = typeof env.BOTBUDDY_TENANT === "string" ? env.BOTBUDDY_TENANT.trim() : "";
|
|
28
|
+
if (explicit) {
|
|
29
|
+
if (!TENANT_PIN_RE.test(explicit)) throw new Error("BOTBUDDY_TENANT must be a lowercase tenant slug");
|
|
30
|
+
return explicit;
|
|
31
|
+
}
|
|
32
|
+
const cfg = getCfg() ?? {};
|
|
33
|
+
if (cfg.token_tenant_mode !== "user") return null;
|
|
34
|
+
let profileName = null;
|
|
35
|
+
try { profileName = await findProfile(cwd); } catch { profileName = null; }
|
|
36
|
+
const profileTenant = profileName ? profileFor(profileName)?.tenant ?? null : null;
|
|
37
|
+
if (profileTenant) return profileTenant;
|
|
38
|
+
const tenants = Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
|
|
39
|
+
return tenants.length === 1 ? tenants[0] : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Endpoint for a generic call: explicit `tenant` beats the derived pin.
|
|
43
|
+
async function genericEndpoint({ tenant = null, resolvePin = resolveDefaultTenantPin } = {}) {
|
|
44
|
+
let pin = tenant;
|
|
45
|
+
if (!pin) {
|
|
46
|
+
try { pin = await resolvePin(); } catch (err) { die(err.message); }
|
|
47
|
+
}
|
|
48
|
+
return toolEndpoint(pin);
|
|
49
|
+
}
|
|
50
|
+
|
|
5
51
|
// BOT-1520: auth headers come from the Keychain, never a plaintext config.json
|
|
6
52
|
// secret. The owner OAuth token (from `botbuddy login`) is preferred; the
|
|
7
53
|
// tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
|
|
@@ -23,8 +69,8 @@ async function authHeader() {
|
|
|
23
69
|
die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
|
|
24
70
|
}
|
|
25
71
|
|
|
26
|
-
export async function callTool(toolName, args = {}) {
|
|
27
|
-
const headers = { "Content-Type": "application/json", ...(await authHeader()) };
|
|
72
|
+
export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
|
|
73
|
+
const headers = { "Content-Type": "application/json", ...(auth ?? await authHeader()) };
|
|
28
74
|
const body = {
|
|
29
75
|
jsonrpc: "2.0",
|
|
30
76
|
id: 1,
|
|
@@ -32,16 +78,22 @@ export async function callTool(toolName, args = {}) {
|
|
|
32
78
|
params: { name: toolName, arguments: args },
|
|
33
79
|
};
|
|
34
80
|
|
|
35
|
-
const res = await
|
|
81
|
+
const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
|
|
36
82
|
const data = await res.json();
|
|
37
83
|
|
|
38
|
-
if (data.error?.message)
|
|
84
|
+
if (data.error?.message) {
|
|
85
|
+
const code = data.error?.data?.code;
|
|
86
|
+
const hint = code === "MCP_TENANT_PIN_REQUIRED"
|
|
87
|
+
? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
|
|
88
|
+
: "";
|
|
89
|
+
die(`Server error: ${data.error.message}${hint}`);
|
|
90
|
+
}
|
|
39
91
|
|
|
40
92
|
const text = data.result?.content?.map((c) => c.text).join("\n");
|
|
41
93
|
if (text) {
|
|
42
|
-
|
|
94
|
+
log(prettyJson(text));
|
|
43
95
|
} else {
|
|
44
|
-
|
|
96
|
+
log(JSON.stringify(data, null, 2));
|
|
45
97
|
}
|
|
46
98
|
return data;
|
|
47
99
|
}
|
|
@@ -84,17 +136,26 @@ function toolEndpoint(tenant) {
|
|
|
84
136
|
return url.toString();
|
|
85
137
|
}
|
|
86
138
|
|
|
87
|
-
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null } = {}) {
|
|
139
|
+
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin } = {}) {
|
|
88
140
|
let auth = pinnedAuth;
|
|
89
141
|
if (!auth) {
|
|
90
142
|
const resolved = await resolveCallAuth();
|
|
91
143
|
if (resolved.error) return resolved.error;
|
|
92
144
|
auth = resolved.auth;
|
|
93
145
|
}
|
|
146
|
+
// BOT-1571 (Codex P1, round 2): `botbuddy stack` and every other
|
|
147
|
+
// callToolJson caller that passes no explicit tenant would send a USER token
|
|
148
|
+
// unpinned → MCP_TENANT_PIN_REQUIRED. Derive the default pin, but only for
|
|
149
|
+
// an OWNER bearer — an agent key is already tenant-bound and a differing
|
|
150
|
+
// derived pin would be a spurious conflict.
|
|
151
|
+
let pin = tenant;
|
|
152
|
+
if (!pin && auth && Object.prototype.hasOwnProperty.call(auth, "Authorization")) {
|
|
153
|
+
try { pin = await resolvePin(); } catch (err) { return { ok: false, status: null, error: `usage: ${err?.message ?? err}` }; }
|
|
154
|
+
}
|
|
94
155
|
const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
|
|
95
156
|
let res;
|
|
96
157
|
try {
|
|
97
|
-
res = await fetchImpl(toolEndpoint(
|
|
158
|
+
res = await fetchImpl(toolEndpoint(pin), {
|
|
98
159
|
method: "POST",
|
|
99
160
|
headers: { "Content-Type": "application/json", ...auth },
|
|
100
161
|
body: JSON.stringify(body),
|
|
@@ -127,23 +188,23 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
|
|
|
127
188
|
return { ok: true, data, isError: payload.result?.isError === true };
|
|
128
189
|
}
|
|
129
190
|
|
|
130
|
-
export async function listResources() {
|
|
131
|
-
const headers = { "Content-Type": "application/json", ...(await authHeader()) };
|
|
191
|
+
export async function listResources({ fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
|
|
192
|
+
const headers = { "Content-Type": "application/json", ...(auth ?? await authHeader()) };
|
|
132
193
|
const body = { jsonrpc: "2.0", id: 1, method: "resources/list", params: {} };
|
|
133
|
-
const res = await
|
|
194
|
+
const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
|
|
134
195
|
const data = await res.json();
|
|
135
|
-
|
|
196
|
+
log(JSON.stringify(data.result?.resources ?? data.result ?? data, null, 2));
|
|
136
197
|
}
|
|
137
198
|
|
|
138
|
-
export async function readResource(uri) {
|
|
139
|
-
const headers = { "Content-Type": "application/json", ...(await authHeader()) };
|
|
199
|
+
export async function readResource(uri, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, log = (line) => console.log(line) } = {}) {
|
|
200
|
+
const headers = { "Content-Type": "application/json", ...(auth ?? await authHeader()) };
|
|
140
201
|
const body = { jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri } };
|
|
141
|
-
const res = await
|
|
202
|
+
const res = await fetchImpl(await genericEndpoint({ tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
|
|
142
203
|
const data = await res.json();
|
|
143
204
|
const text = data.result?.contents?.[0]?.text;
|
|
144
205
|
if (text) {
|
|
145
|
-
|
|
206
|
+
log(prettyJson(text));
|
|
146
207
|
} else {
|
|
147
|
-
|
|
208
|
+
log(JSON.stringify(data.result ?? data, null, 2));
|
|
148
209
|
}
|
|
149
210
|
}
|
package/src/auth.mjs
CHANGED
|
@@ -170,8 +170,14 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
170
170
|
// BOT-1566: a Keychain read-back mismatch (`keychain_readback_mismatch`)
|
|
171
171
|
// propagates from here, so login exits non-zero and the success line below
|
|
172
172
|
// is never printed for a credential that did not verifiably land.
|
|
173
|
+
// BOT-1571: record the token's scope. A server that predates tenant modes
|
|
174
|
+
// omits `tenant_mode`; such a token is sealed (or legacy), never assumed
|
|
175
|
+
// to be a user token.
|
|
176
|
+
const tenantMode = tokenData.tenant_mode === "user" ? "user" : "tenant";
|
|
177
|
+
const tenantId = typeof tokenData.tenant_id === "string" && tokenData.tenant_id ? tokenData.tenant_id : null;
|
|
178
|
+
const tenants = Array.isArray(tokenData.tenants) ? tokenData.tenants.filter((t) => typeof t === "string" && t) : [];
|
|
173
179
|
const { storedInKeychain } = await persist(
|
|
174
|
-
{ token: tokenData.access_token, expiresAt, clientId },
|
|
180
|
+
{ token: tokenData.access_token, expiresAt, clientId, tenantMode, tenantId, tenants },
|
|
175
181
|
{ getConfig, saveConfig, warn: errorLog },
|
|
176
182
|
);
|
|
177
183
|
|
|
@@ -180,6 +186,11 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
180
186
|
storedInKeychain ? "the macOS Keychain" : dim("~/.botbuddy/config.json")
|
|
181
187
|
}.`,
|
|
182
188
|
);
|
|
189
|
+
if (tenantMode === "user") {
|
|
190
|
+
log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy profile setup")} pins its own.`);
|
|
191
|
+
} else if (tenantId) {
|
|
192
|
+
log(` Token scope: ${cyan("tenant")} — sealed to ${cyan(tenantId)}. Run ${cyan("botbuddy login")} without ${dim("--tenant")} for a token that reaches all your tenants.`);
|
|
193
|
+
}
|
|
183
194
|
return { clientId, redirectUri };
|
|
184
195
|
} finally {
|
|
185
196
|
// AC-11/AC-13: always release the socket so re-running login starts clean.
|
package/src/cli-credentials.mjs
CHANGED
|
@@ -25,9 +25,19 @@ import {
|
|
|
25
25
|
} from "./agent-credential-store.mjs";
|
|
26
26
|
import { resolveAgentProfile } from "./wait-profile.mjs";
|
|
27
27
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// BOT-1574: the per-machine CLIENT KEY. `botbuddy login` mints a user-mode OAuth
|
|
29
|
+
// token (BOT-1571) — tenant-agnostic, pins one tenant per request against the
|
|
30
|
+
// owner's memberships — and that token IS the machine's client credential. It is
|
|
31
|
+
// stored under ONE Keychain item per machine, distinct from the per-profile
|
|
32
|
+
// agent-key services (BOTBUDDY_BB_AGENT_KEY / BOTBUDDY_SG_AGENT_KEY) and from the
|
|
33
|
+
// per-session token (bb_sess_), so a setup credential is never mistaken for an
|
|
34
|
+
// agent. (The wire format stays `mcp_at_`; "client key" is the kind, not a new
|
|
35
|
+
// prefix — the OAuth token format is out of scope for BOT-1574.)
|
|
36
|
+
export const CLIENT_KEY_SERVICE = "BOTBUDDY_CLIENT_KEY";
|
|
37
|
+
|
|
38
|
+
// The legacy service the same token was stored under before BOT-1574. Still read
|
|
39
|
+
// (so an already-logged-in user keeps working across the upgrade without
|
|
40
|
+
// re-login) and cleared on logout; `login` always writes CLIENT_KEY_SERVICE now.
|
|
31
41
|
export const OWNER_TOKEN_SERVICE = "BOTBUDDY_OWNER_TOKEN";
|
|
32
42
|
|
|
33
43
|
// Config keys that must never carry secret material at rest.
|
|
@@ -67,11 +77,11 @@ export async function migrateConfigSecrets(config, {
|
|
|
67
77
|
exists,
|
|
68
78
|
// `createOnly`: the migration write must not overwrite a token another process
|
|
69
79
|
// wrote after our empty-slot read (BOT-1569 Codex P2 TOCTOU).
|
|
70
|
-
keychainWrite = (value) => writeKeychainSecret(
|
|
80
|
+
keychainWrite = (value) => writeKeychainSecret(CLIENT_KEY_SERVICE, value, { createOnly: true }),
|
|
71
81
|
// `strict`: a transient READ failure throws (fail closed) instead of reading as
|
|
72
82
|
// an empty slot, so the migration never overwrites a live stored token because
|
|
73
83
|
// the lookup momentarily failed (BOT-1569 Codex P2).
|
|
74
|
-
keychainRead = () => readKeychainSecret(
|
|
84
|
+
keychainRead = () => readKeychainSecret(CLIENT_KEY_SERVICE, { strict: true }),
|
|
75
85
|
warn = (message) => console.error(message),
|
|
76
86
|
} = {}) {
|
|
77
87
|
const out = { ...(config ?? {}) };
|
|
@@ -167,13 +177,13 @@ export async function migrateConfigSecrets(config, {
|
|
|
167
177
|
// the token itself goes to the Keychain on darwin, or (with a warning) stays in
|
|
168
178
|
// the 0600 config.json where no Keychain exists.
|
|
169
179
|
export async function persistOwnerToken(
|
|
170
|
-
{ token, expiresAt, clientId },
|
|
180
|
+
{ token, expiresAt, clientId, tenantMode, tenantId, tenants },
|
|
171
181
|
{
|
|
172
182
|
platform = process.platform,
|
|
173
183
|
exists,
|
|
174
184
|
getConfig,
|
|
175
185
|
saveConfig,
|
|
176
|
-
keychainWrite = (value) => writeKeychainSecret(
|
|
186
|
+
keychainWrite = (value) => writeKeychainSecret(CLIENT_KEY_SERVICE, value),
|
|
177
187
|
warn = (message) => console.error(message),
|
|
178
188
|
} = {},
|
|
179
189
|
) {
|
|
@@ -187,6 +197,16 @@ export async function persistOwnerToken(
|
|
|
187
197
|
const next = { ...base };
|
|
188
198
|
if (clientId !== undefined) next.client_id = clientId;
|
|
189
199
|
if (expiresAt !== undefined) next.token_expires_at = expiresAt;
|
|
200
|
+
// BOT-1571: the token's SCOPE (non-secret). `user` = not bound to a tenant,
|
|
201
|
+
// pins one per request; `tenant` = sealed to token_tenant_id. Recorded from
|
|
202
|
+
// the /token response so `status` can describe the credential and probe it
|
|
203
|
+
// with a pin it is known to reach. Always replaced together, never merged
|
|
204
|
+
// with a previous login's scope.
|
|
205
|
+
if (tenantMode !== undefined) {
|
|
206
|
+
next.token_tenant_mode = tenantMode;
|
|
207
|
+
next.token_tenant_id = tenantId ?? null;
|
|
208
|
+
next.token_tenants = Array.isArray(tenants) ? tenants.filter((t) => typeof t === "string" && t) : [];
|
|
209
|
+
}
|
|
190
210
|
|
|
191
211
|
if (keychain) {
|
|
192
212
|
await keychainWrite(token);
|
|
@@ -209,25 +229,38 @@ export async function resolveOwnerToken({
|
|
|
209
229
|
platform = process.platform,
|
|
210
230
|
exists,
|
|
211
231
|
getConfig,
|
|
212
|
-
keychainRead = () => readKeychainSecret(
|
|
232
|
+
keychainRead = () => readKeychainSecret(CLIENT_KEY_SERVICE),
|
|
233
|
+
// BOT-1574 migration: a machine logged in before 1574 has the token under the
|
|
234
|
+
// legacy OWNER_TOKEN service. Read it as a fallback so an upgrade needs no
|
|
235
|
+
// re-login; `login` rewrites it to CLIENT_KEY, and `logout` clears both.
|
|
236
|
+
keychainReadLegacy = () => readKeychainSecret(OWNER_TOKEN_SERVICE),
|
|
213
237
|
} = {}) {
|
|
214
238
|
const cfg = getConfig() ?? {};
|
|
215
239
|
const expiresAt = typeof cfg.token_expires_at === "number" ? cfg.token_expires_at : null;
|
|
216
240
|
let token = null;
|
|
217
241
|
if (keychainAvailable(platform, exists)) {
|
|
218
242
|
token = await keychainRead();
|
|
243
|
+
if (!token) token = await keychainReadLegacy();
|
|
219
244
|
} else if (typeof cfg.access_token === "string" && cfg.access_token) {
|
|
220
245
|
token = cfg.access_token;
|
|
221
246
|
}
|
|
222
247
|
if (!token) return null;
|
|
223
|
-
|
|
248
|
+
const tenantMode = cfg.token_tenant_mode === "user" || cfg.token_tenant_mode === "tenant" ? cfg.token_tenant_mode : null;
|
|
249
|
+
const tenantId = typeof cfg.token_tenant_id === "string" && cfg.token_tenant_id ? cfg.token_tenant_id : null;
|
|
250
|
+
const tenants = Array.isArray(cfg.token_tenants) ? cfg.token_tenants.filter((t) => typeof t === "string" && t) : [];
|
|
251
|
+
return { token, expiresAt, tenantMode, tenantId, tenants };
|
|
224
252
|
}
|
|
225
253
|
|
|
226
|
-
// Remove the
|
|
254
|
+
// Remove the client key from every store (`botbuddy logout`). Clears BOTH the
|
|
255
|
+
// current CLIENT_KEY item and the legacy OWNER_TOKEN item so a pre-1574 token
|
|
256
|
+
// left behind by the read-both migration is never orphaned in the Keychain.
|
|
227
257
|
export async function clearOwnerToken({
|
|
228
258
|
platform = process.platform,
|
|
229
259
|
exists,
|
|
230
|
-
keychainDelete = () =>
|
|
260
|
+
keychainDelete = async () => {
|
|
261
|
+
await deleteKeychainSecret(CLIENT_KEY_SERVICE);
|
|
262
|
+
await deleteKeychainSecret(OWNER_TOKEN_SERVICE);
|
|
263
|
+
},
|
|
231
264
|
} = {}) {
|
|
232
265
|
if (keychainAvailable(platform, exists)) {
|
|
233
266
|
await keychainDelete();
|
package/src/commands.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import { callTool, callToolJson, readResource } from "./api.mjs";
|
|
2
3
|
import { doLogin } from "./auth.mjs";
|
|
3
4
|
import { runBridge } from "./codex-bridge.mjs";
|
|
@@ -55,6 +56,7 @@ export async function run(argv, {
|
|
|
55
56
|
case "wait": return runWait(args);
|
|
56
57
|
case "pw": return runPw(args);
|
|
57
58
|
case "profile": return cmdProfile(args);
|
|
59
|
+
case "carrier": return cmdCarrier(args);
|
|
58
60
|
case "resources": return callTool("list_resources");
|
|
59
61
|
case "agents": return callTool("list_agents");
|
|
60
62
|
case "tasks": return readResource("botbuddy://tasks");
|
|
@@ -96,13 +98,16 @@ ${bold("AUTH")}
|
|
|
96
98
|
Authenticate via OAuth (opens browser + localhost callback)
|
|
97
99
|
logout Remove saved credentials
|
|
98
100
|
status Show current auth status (local metadata + server check)
|
|
99
|
-
|
|
101
|
+
carrier setup <profile> CI hosts only: store an unattended tenant-bound carrier key
|
|
102
|
+
(interactive operators use ${cyan("login")} — it installs the client key)
|
|
100
103
|
|
|
101
104
|
${bold("TOOLS")}
|
|
102
105
|
help --tools List every BotBuddy tool
|
|
103
106
|
help <tool> Show one tool's arguments
|
|
104
107
|
call <tool> [--key value] Invoke any tool
|
|
105
108
|
call <tool> --json '{...}' Invoke with a JSON arguments object
|
|
109
|
+
call <tool> --pin <slug> Pin the connection tenant (a user token needs one;
|
|
110
|
+
default: BOTBUDDY_TENANT → repo profile → sole tenant)
|
|
106
111
|
|
|
107
112
|
${bold("STACK LEASES")}
|
|
108
113
|
stack up [options] Request a batch-scoped local stack lease; park if full; hold it active
|
|
@@ -146,7 +151,8 @@ async function cmdCall(args) {
|
|
|
146
151
|
if (e instanceof CallUsageError) die(e.message);
|
|
147
152
|
throw e;
|
|
148
153
|
}
|
|
149
|
-
|
|
154
|
+
// BOT-1571: an explicit --pin beats the derived default pin (see api.mjs).
|
|
155
|
+
return callTool(parsedArgs.tool, parsedArgs.args, { tenant: parsedArgs.pin });
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
async function cmdToolHelp(args) {
|
|
@@ -280,6 +286,15 @@ ${bold("USAGE")}
|
|
|
280
286
|
botbuddy login [--no-browser] [--tenant <slug>]
|
|
281
287
|
[--caller <name>] [--caller-harness <harness>] [--caller-ticket <ref>]
|
|
282
288
|
|
|
289
|
+
${bold("TOKEN SCOPE")}
|
|
290
|
+
By default login installs this machine's ${bold("client key (bb_cli_)")}: a user
|
|
291
|
+
token that is not bound to a tenant and reaches every tenant you belong to —
|
|
292
|
+
each request pins one. ONE login serves ALL tenants; ${cyan("register_agent")} then
|
|
293
|
+
exchanges it for a per-session agent token. No per-tenant ${cyan("profile setup")} is
|
|
294
|
+
needed (that path is now CI-carrier only).
|
|
295
|
+
${cyan("--tenant <slug>")} instead mints a ${bold("tenant token")} sealed to that one
|
|
296
|
+
tenant (the MCP-session model); use it only when you want that guarantee.
|
|
297
|
+
|
|
283
298
|
${bold("HOW IT WORKS")}
|
|
284
299
|
Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
|
|
285
300
|
your default browser at the BotBuddy authorization page, and waits for you to
|
|
@@ -293,9 +308,10 @@ ${bold("OPTIONS")}
|
|
|
293
308
|
in a browser on THIS machine yourself. The callback listener
|
|
294
309
|
still runs on this machine's localhost.
|
|
295
310
|
--tenant <slug>
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
membership is still enforced by the server.
|
|
311
|
+
Mint a token SEALED to one tenant (e.g. ${cyan("supply-guard")}); the
|
|
312
|
+
sign-in page shows it as requested by the CLI. Must be a lowercase
|
|
313
|
+
slug; membership is still enforced by the server. Without it the
|
|
314
|
+
token is a user token that reaches all your tenants.
|
|
299
315
|
--caller <name>
|
|
300
316
|
Show WHO requested this login on the authorization screen, so
|
|
301
317
|
the human approving it can confirm the request came from the
|
|
@@ -325,8 +341,8 @@ ${bold("NOTES")}
|
|
|
325
341
|
reach the listener and login will time out.
|
|
326
342
|
|
|
327
343
|
${bold("RECOVERY")}
|
|
328
|
-
If ${cyan("botbuddy
|
|
329
|
-
|
|
344
|
+
If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
|
|
345
|
+
unattended CI host (no browser), use ${cyan("botbuddy carrier setup <profile>")}.`);
|
|
330
346
|
}
|
|
331
347
|
|
|
332
348
|
async function cmdStart(args) {
|
|
@@ -382,27 +398,38 @@ const STATUS_PROBE_TIMEOUT_MS = 4000;
|
|
|
382
398
|
// so a future opaque-token size change can't reintroduce the false rejection.
|
|
383
399
|
const looksLikeOwnerToken = (t) => typeof t === "string" && /^mcp_at_[0-9a-f]{32,}$/.test(t);
|
|
384
400
|
|
|
385
|
-
async function logServerStatus(call, auth, log) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
401
|
+
async function logServerStatus(call, auth, log, { tenant = null, tenants = null } = {}) {
|
|
402
|
+
// BOT-1571: a USER token is refused unpinned (MCP_TENANT_PIN_REQUIRED), so the
|
|
403
|
+
// probe carries a pin the token is known to reach when one is stored. Codex
|
|
404
|
+
// round 9 (P2): user-token membership is evaluated PER REQUEST, so a tenant
|
|
405
|
+
// cached at login may be stale — probe EACH recorded tenant until one
|
|
406
|
+
// authenticates before reporting the token rejected.
|
|
407
|
+
const pins = Array.isArray(tenants) && tenants.length ? tenants : [tenant];
|
|
408
|
+
let lastRes = null;
|
|
409
|
+
for (const pin of pins) {
|
|
410
|
+
let res;
|
|
411
|
+
try {
|
|
412
|
+
res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS), ...(pin ? { tenant: pin } : {}) });
|
|
413
|
+
} catch {
|
|
414
|
+
log(` server: ${red("unreachable")}`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (res?.ok && !res.isError) {
|
|
418
|
+
const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
|
|
419
|
+
log(` server: ${green("authenticated")} (${slug})`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
|
|
423
|
+
log(` server: ${red("unreachable")}`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
lastRes = res; // a rejection for this pin — try the next recorded tenant
|
|
397
427
|
}
|
|
428
|
+
const res = lastRes;
|
|
398
429
|
if (res?.ok && res.isError) {
|
|
399
430
|
log(` server: ${red("rejected")} (${res.data?.error ?? res.data?.code ?? "tool error"})`);
|
|
400
431
|
return;
|
|
401
432
|
}
|
|
402
|
-
if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
|
|
403
|
-
log(` server: ${red("unreachable")}`);
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
433
|
log(` server: ${red("rejected")} (${res?.error ?? "unknown error"})`);
|
|
407
434
|
}
|
|
408
435
|
|
|
@@ -413,6 +440,8 @@ export async function cmdStatus({
|
|
|
413
440
|
callToolJson: call = callToolJson,
|
|
414
441
|
log = (line) => console.log(line),
|
|
415
442
|
now = () => Date.now(),
|
|
443
|
+
hostname = () => os.hostname(),
|
|
444
|
+
env = process.env,
|
|
416
445
|
} = {}) {
|
|
417
446
|
const cfg = getCfg();
|
|
418
447
|
const owner = await resolveOwner({ getConfig: getCfg });
|
|
@@ -430,6 +459,29 @@ export async function cmdStatus({
|
|
|
430
459
|
}
|
|
431
460
|
if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
|
|
432
461
|
if (cfg.client_id) log(` Client: ${dim(cfg.client_id)}`);
|
|
462
|
+
// BOT-1571/BOT-1574: what kind of token this is. A `user` token is the
|
|
463
|
+
// per-machine CLIENT KEY (bb_cli_): host-bound at login, reaching every
|
|
464
|
+
// listed tenant (each request pins one). A `tenant` token is sealed to one.
|
|
465
|
+
// A pre-1571 login has no recorded scope and prints nothing rather than a
|
|
466
|
+
// guess.
|
|
467
|
+
const ownerTenants = Array.isArray(owner.tenants) ? owner.tenants : [];
|
|
468
|
+
if (ownerValid && owner.tenantMode === "user") {
|
|
469
|
+
const memberships = ownerTenants.length ? ownerTenants.join(", ") : dim("none recorded");
|
|
470
|
+
log(` ${cyan("Client key (bb_cli_)")} · host ${dim(hostname())} · memberships: ${memberships}`);
|
|
471
|
+
// BOT-1574 AC1: the per-tenant Keychain agent-key slots (BOT-1558) are
|
|
472
|
+
// retired for interactive use — the client key replaces them. If any are
|
|
473
|
+
// still exported, they are IGNORED; say so once so the operator can drop
|
|
474
|
+
// them. The per-session token var $BOTBUDDY_AGENT_KEY (no middle segment)
|
|
475
|
+
// is the live wait credential and must never be flagged here.
|
|
476
|
+
const ignoredSlots = Object.keys(env ?? {})
|
|
477
|
+
.filter((k) => /^BOTBUDDY_[A-Z0-9]+_AGENT_KEY$/.test(k))
|
|
478
|
+
.sort();
|
|
479
|
+
if (ignoredSlots.length) {
|
|
480
|
+
log(` ${dim("!")} ignoring ${ignoredSlots.join(", ")} — botbuddy login's client key is used instead (per-tenant agent-key slots are retired for interactive use).`);
|
|
481
|
+
}
|
|
482
|
+
} else if (ownerValid && owner.tenantMode === "tenant" && owner.tenantId) {
|
|
483
|
+
log(` Token scope: ${cyan("tenant")} (${owner.tenantId})`);
|
|
484
|
+
}
|
|
433
485
|
if (ownerValid && owner.expiresAt) {
|
|
434
486
|
const remaining = owner.expiresAt - now();
|
|
435
487
|
if (remaining <= 0) {
|
|
@@ -464,6 +516,11 @@ export async function cmdStatus({
|
|
|
464
516
|
call,
|
|
465
517
|
fallbackKey ? { "x-agent-api-key": fallbackKey } : { Authorization: `Bearer ${owner.token}` },
|
|
466
518
|
log,
|
|
519
|
+
// Pin only for a user token probed as itself; an agent key or a sealed
|
|
520
|
+
// token resolves its own tenant. Probe EVERY recorded tenant (round 9 P2):
|
|
521
|
+
// membership is per-request, so the first cached tenant may be stale while
|
|
522
|
+
// another is still valid.
|
|
523
|
+
!fallbackKey && owner.tenantMode === "user" ? { tenants: ownerTenants } : {},
|
|
467
524
|
);
|
|
468
525
|
return;
|
|
469
526
|
}
|
|
@@ -530,38 +587,95 @@ function cmdHeartbeat(args) {
|
|
|
530
587
|
return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
|
|
531
588
|
}
|
|
532
589
|
|
|
533
|
-
|
|
590
|
+
// BOT-1574 AC6: the carrier bootstrap that `profile setup --unattended` and
|
|
591
|
+
// `carrier setup` share. Never gated on a TTY — the caller decides whether the
|
|
592
|
+
// interactive guard applies before reaching here.
|
|
593
|
+
async function runCarrierSetup(profile, { log, setExitCode, bootstrap }) {
|
|
594
|
+
try {
|
|
595
|
+
const receipt = await bootstrap(profile, { call: callToolJson });
|
|
596
|
+
log(JSON.stringify(receipt));
|
|
597
|
+
} catch (error) {
|
|
598
|
+
const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
|
|
599
|
+
log(JSON.stringify({
|
|
600
|
+
schema_version: 1,
|
|
601
|
+
outcome: "error",
|
|
602
|
+
error: code,
|
|
603
|
+
recovery: profileBootstrapRecovery(code, profile),
|
|
604
|
+
}));
|
|
605
|
+
setExitCode(3);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export async function cmdProfile(args, {
|
|
610
|
+
isTTY = Boolean(process.stdout.isTTY),
|
|
611
|
+
log = (line) => console.log(line),
|
|
612
|
+
setExitCode = (n) => { process.exitCode = n; },
|
|
613
|
+
bootstrap = bootstrapProfile,
|
|
614
|
+
} = {}) {
|
|
534
615
|
if (args[0] === "--help" || args[0] === "-h") {
|
|
535
|
-
console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev>
|
|
616
|
+
console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]
|
|
617
|
+
|
|
618
|
+
setup <profile> Carrier-only (CI): mint/reconnect and store a tenant-bound
|
|
619
|
+
carrier key. Retired for interactive use — run ${cyan("botbuddy login")}
|
|
620
|
+
on a workstation. On a TTY this refuses unless --unattended.
|
|
621
|
+
env <profile> Print shell exports that load the stored carrier key
|
|
536
622
|
|
|
537
|
-
|
|
538
|
-
|
|
623
|
+
Interactive operators: ${cyan("botbuddy login")} installs the per-machine client key.
|
|
624
|
+
CI hosts: ${cyan("botbuddy carrier setup <profile>")} (alias of setup --unattended).`);
|
|
539
625
|
return;
|
|
540
626
|
}
|
|
541
627
|
if (args[0] === "env" && args[1] && args.length === 2) {
|
|
542
628
|
try {
|
|
543
|
-
|
|
629
|
+
log(profileShellRefresh(args[1]));
|
|
544
630
|
return;
|
|
545
631
|
} catch {
|
|
546
632
|
die("Usage: botbuddy profile env <botbuddy-dev|supplyguard-dev>");
|
|
547
633
|
}
|
|
548
634
|
}
|
|
549
|
-
|
|
550
|
-
|
|
635
|
+
const unattended = args.includes("--unattended");
|
|
636
|
+
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
637
|
+
if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
|
|
638
|
+
die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev> [--unattended]");
|
|
551
639
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
640
|
+
const profile = positionals[1];
|
|
641
|
+
// BOT-1574 AC6: interactive setup is retired. A human on a TTY who did not ask
|
|
642
|
+
// for the unattended carrier path is redirected to `botbuddy login` (which
|
|
643
|
+
// installs the machine client key) instead of minting a per-tenant slot. A
|
|
644
|
+
// non-TTY caller (CI) or an explicit --unattended keeps the carrier path.
|
|
645
|
+
if (isTTY && !unattended) {
|
|
646
|
+
log(JSON.stringify({
|
|
558
647
|
schema_version: 1,
|
|
559
648
|
outcome: "error",
|
|
560
|
-
error:
|
|
561
|
-
recovery:
|
|
649
|
+
error: "interactive_use_retired",
|
|
650
|
+
recovery: `Interactive profile setup is retired. Run \`botbuddy login\` to install this machine's client key (bb_cli_) — it reaches every tenant you belong to, no per-tenant slot needed. For an unattended CI carrier, re-run with --unattended or use \`botbuddy carrier setup ${profile}\`.`,
|
|
562
651
|
}));
|
|
563
|
-
|
|
652
|
+
setExitCode(4);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
await runCarrierSetup(profile, { log, setExitCode, bootstrap });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// BOT-1574 AC6: `botbuddy carrier setup <profile>` — the explicit CI-carrier
|
|
659
|
+
// alias of `profile setup --unattended`. Being the named carrier command, it is
|
|
660
|
+
// never subject to the interactive-TTY guard.
|
|
661
|
+
export async function cmdCarrier(args, {
|
|
662
|
+
log = (line) => console.log(line),
|
|
663
|
+
setExitCode = (n) => { process.exitCode = n; },
|
|
664
|
+
bootstrap = bootstrapProfile,
|
|
665
|
+
} = {}) {
|
|
666
|
+
if (args[0] === "--help" || args[0] === "-h" || args.length === 0) {
|
|
667
|
+
console.log(`Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>
|
|
668
|
+
|
|
669
|
+
Mint/reconnect and store an unattended CI carrier key (alias of
|
|
670
|
+
${cyan("botbuddy profile setup <profile> --unattended")}). Interactive operators use
|
|
671
|
+
${cyan("botbuddy login")} instead.`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const positionals = args.filter((a) => !a.startsWith("-"));
|
|
675
|
+
if (positionals[0] !== "setup" || !positionals[1] || positionals.length > 2) {
|
|
676
|
+
die("Usage: botbuddy carrier setup <botbuddy-dev|supplyguard-dev>");
|
|
564
677
|
}
|
|
678
|
+
await runCarrierSetup(positionals[1], { log, setExitCode, bootstrap });
|
|
565
679
|
}
|
|
566
680
|
|
|
567
681
|
function cmdLock(args) {
|
package/src/discovery.mjs
CHANGED
|
@@ -30,10 +30,23 @@ export function parseCallArgs(argv) {
|
|
|
30
30
|
|
|
31
31
|
let args = {};
|
|
32
32
|
let sawJson = false;
|
|
33
|
+
// BOT-1571: `--pin <slug>` pins the CONNECTION tenant (the ?tenant= query),
|
|
34
|
+
// which a user token needs on every request. Deliberately not `--tenant`:
|
|
35
|
+
// several tools take a `tenant` / `tenant_id` argument of their own.
|
|
36
|
+
let pin = null;
|
|
33
37
|
|
|
34
38
|
for (let i = 0; i < rest.length; i++) {
|
|
35
39
|
const token = rest[i];
|
|
36
40
|
|
|
41
|
+
if (token === "--pin") {
|
|
42
|
+
const raw = rest[++i];
|
|
43
|
+
if (raw === undefined || raw.startsWith("--") || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(raw)) {
|
|
44
|
+
throw new CallUsageError("--pin requires a lowercase tenant slug, e.g. --pin supply-guard.");
|
|
45
|
+
}
|
|
46
|
+
pin = raw;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
if (token === "--json" || token === "-j") {
|
|
38
51
|
const raw = rest[++i];
|
|
39
52
|
if (raw === undefined) throw new CallUsageError("--json requires a JSON object argument.");
|
|
@@ -73,7 +86,7 @@ export function parseCallArgs(argv) {
|
|
|
73
86
|
);
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
return { tool, args, usedJson: sawJson };
|
|
89
|
+
return { tool, args, usedJson: sawJson, pin };
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
/**
|
package/src/oauth-loopback.mjs
CHANGED
|
@@ -89,10 +89,18 @@ export function buildAuthorizeUrl({
|
|
|
89
89
|
url.searchParams.set("state", state);
|
|
90
90
|
url.searchParams.set("code_challenge", codeChallenge);
|
|
91
91
|
url.searchParams.set("code_challenge_method", "S256");
|
|
92
|
-
// BOT-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
92
|
+
// BOT-1571: two OAuth modes. A plain `login` asks for a USER token
|
|
93
|
+
// (`tenant_mode=user`): not bound to any tenant, so one login serves every
|
|
94
|
+
// `profile setup`, each of which pins its own tenant per request. `login
|
|
95
|
+
// --tenant <slug>` asks for a TENANT token SEALED to that slug
|
|
96
|
+
// (`tenant_mode=tenant` + `tenant=` — membership is still enforced
|
|
97
|
+
// server-side at authorize_complete).
|
|
98
|
+
if (tenant) {
|
|
99
|
+
url.searchParams.set("tenant_mode", "tenant");
|
|
100
|
+
url.searchParams.set("tenant", tenant);
|
|
101
|
+
} else {
|
|
102
|
+
url.searchParams.set("tenant_mode", "user");
|
|
103
|
+
}
|
|
96
104
|
// BOT-1580: display-only caller identity, so the human approving the login can
|
|
97
105
|
// check it against the agent session they are actually running. These are
|
|
98
106
|
// UNVERIFIED hints — /authorize only forwards them to the consent screen; they
|
|
@@ -210,10 +210,23 @@ export function profileBootstrapRecovery(code, profileName) {
|
|
|
210
210
|
// tenant-bound token can't be repointed) or the owner isn't a member of the
|
|
211
211
|
// profile's tenant. The fix is to authenticate INTO the profile's tenant —
|
|
212
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.
|
|
213
224
|
const tenant = getAgentProfile(profileName)?.tenant;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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}`;
|
|
217
230
|
}
|
|
218
231
|
if (code === "profile_tenant_unverified") {
|
|
219
232
|
// whoami could not confirm the bound tenant, so registration was refused
|
package/src/stack.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { callToolJson } from "./api.mjs";
|
|
|
31
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
32
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
33
33
|
import { runDockerCommand, runDockerWorkflow } from "./docker-hygiene.mjs";
|
|
34
|
+
import { machineUuid } from "./machine-id.mjs";
|
|
34
35
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
35
36
|
|
|
36
37
|
export const STACK_SCHEMA_VERSION = 1;
|
|
@@ -667,6 +668,7 @@ export async function cmdUp(opts, {
|
|
|
667
668
|
localProvisionFn = localProvision,
|
|
668
669
|
waitFn = waitForLease,
|
|
669
670
|
emitResult = emit,
|
|
671
|
+
machineUuidFn = machineUuid,
|
|
670
672
|
} = {}) {
|
|
671
673
|
let slot;
|
|
672
674
|
try { slot = deriveSlot(opts); } catch (e) {
|
|
@@ -696,6 +698,13 @@ export async function cmdUp(opts, {
|
|
|
696
698
|
const auth = await authProvider();
|
|
697
699
|
if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
698
700
|
|
|
701
|
+
// BOT-1585: co-location dispatches the lease to the Helper enrolled for THIS
|
|
702
|
+
// physical machine (hardware id), since a hostname is not machine-unique. The
|
|
703
|
+
// server requires it; fail fast with an actionable message if it can't be read.
|
|
704
|
+
const hardwareUuid = machineUuidFn();
|
|
705
|
+
if (!hardwareUuid) {
|
|
706
|
+
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);
|
|
707
|
+
}
|
|
699
708
|
const req = await callTool("request_stack_lease", {
|
|
700
709
|
slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
|
|
701
710
|
ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
|
|
@@ -703,6 +712,7 @@ export async function cmdUp(opts, {
|
|
|
703
712
|
purpose: opts.purpose || undefined, idle_ttl_secs: opts.idleTtl ?? undefined,
|
|
704
713
|
stack_path: execution.stackPath,
|
|
705
714
|
worktree_root: execution.worktreeRoot,
|
|
715
|
+
machine_uuid: hardwareUuid,
|
|
706
716
|
});
|
|
707
717
|
if (!req.ok) {
|
|
708
718
|
return req.auth
|
|
@@ -1046,6 +1056,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1046
1056
|
release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
|
|
1047
1057
|
};
|
|
1048
1058
|
const auth = adapters.auth ?? await stackAuthHeader();
|
|
1059
|
+
const machineUuidFn = adapters.machineUuidFn ?? machineUuid;
|
|
1049
1060
|
const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
|
|
1050
1061
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
|
|
1051
1062
|
const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
|
|
@@ -1122,6 +1133,13 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1122
1133
|
|
|
1123
1134
|
try {
|
|
1124
1135
|
if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
|
|
1136
|
+
// BOT-1585: request_stack_lease requires this machine's hardware id so the lease
|
|
1137
|
+
// dispatches to the machine the worktree was registered on. `stack run` builds its
|
|
1138
|
+
// own payload (separate from `cmdUp`), so it must send it too.
|
|
1139
|
+
const hardwareUuid = machineUuidFn();
|
|
1140
|
+
if (!hardwareUuid) {
|
|
1141
|
+
return { exitCode: EXIT.BACKEND, outcome: "error", error: "could not determine this machine's hardware id (needed to dispatch the stack lease to the right machine)" };
|
|
1142
|
+
}
|
|
1125
1143
|
const slot = deriveSlot(opts);
|
|
1126
1144
|
const execution = resolveStackPath(process.cwd(), opts.stackPath);
|
|
1127
1145
|
const request = await api.request({
|
|
@@ -1129,6 +1147,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1129
1147
|
ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
1130
1148
|
purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
|
|
1131
1149
|
stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
|
|
1150
|
+
machine_uuid: hardwareUuid,
|
|
1132
1151
|
});
|
|
1133
1152
|
if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
|
|
1134
1153
|
if (!request.data?.success) return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
|
package/src/wait.mjs
CHANGED
|
@@ -120,12 +120,23 @@ OPTIONS
|
|
|
120
120
|
--receipt-max-bytes <n> cap the receipt (minimum 512; default 10240; payloads truncate to pointers)
|
|
121
121
|
--heartbeat keep this agent session alive while waiting (so it is not reaped)
|
|
122
122
|
--url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
|
|
123
|
-
--
|
|
123
|
+
--agent-key <token> the bb_agent_ session token to authenticate this wait — a one-liner
|
|
124
|
+
equivalent to exporting $BOTBUDDY_AGENT_KEY (which is the default).
|
|
125
|
+
Only accepts a session token; a client/profile key is refused.
|
|
126
|
+
(--session-token is the one-release legacy alias.)
|
|
124
127
|
--session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
|
|
125
|
-
|
|
126
|
-
--token <key> explicit agent key override; otherwise the profile-specific env is used
|
|
128
|
+
Unnecessary when $BOTBUDDY_AGENT_KEY / --agent-key is set (the relay derives the session from the token).
|
|
127
129
|
--help show this help
|
|
128
130
|
|
|
131
|
+
AUTH
|
|
132
|
+
A wait authenticates from the bb_agent_ session token (minted by register_agent):
|
|
133
|
+
export $BOTBUDDY_AGENT_KEY, or pass it inline with --agent-key for a one-liner
|
|
134
|
+
($BOTBUDDY_SESSION_TOKEN / --session-token still accepted for one release).
|
|
135
|
+
--token and --profile are RETIRED (BOT-1574): they were untyped and could carry a
|
|
136
|
+
machine credential; use --agent-key, which only accepts a session token. The
|
|
137
|
+
per-machine client key (botbuddy login) is a setup credential, never a wait
|
|
138
|
+
credential.
|
|
139
|
+
|
|
129
140
|
OUTPUT
|
|
130
141
|
Exactly one JSON receipt line on stdout at exit, with client semver and
|
|
131
142
|
protocol identity. Diagnostics go to stderr.
|
|
@@ -191,9 +202,19 @@ function parseArgv(argv) {
|
|
|
191
202
|
else if (a === "--since") opts.since = optionValue();
|
|
192
203
|
else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
|
|
193
204
|
else if (a === "--url") opts.url = optionValue();
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
// BOT-1574: --token and --profile are RETIRED. A wait authenticates from
|
|
206
|
+
// $BOTBUDDY_SESSION_TOKEN (minted by register_agent) alone; the per-machine
|
|
207
|
+
// client key (`botbuddy login`) is never a wait credential. Consume any
|
|
208
|
+
// following value so it is not mis-parsed as a condition, then fail fast in
|
|
209
|
+
// runWait with a migration hint (AC3).
|
|
210
|
+
else if (a === "--token" || a === "--profile") { opts.retiredFlag ??= a; optionValue(); }
|
|
211
|
+
// BOT-1574: --agent-key is the typed one-liner for the wait credential — it
|
|
212
|
+
// carries the bb_agent_ session token (equivalent to exporting
|
|
213
|
+
// $BOTBUDDY_AGENT_KEY), and NOTHING else: a client key / profile key is the
|
|
214
|
+
// wrong shape and is refused invalid_session_token, so it can never smuggle a
|
|
215
|
+
// machine credential onto a wait. --session-token is the one-release legacy
|
|
216
|
+
// alias for the same value.
|
|
217
|
+
else if (a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
|
|
197
218
|
else if (a === "--session-id") opts.sessionId = optionValue();
|
|
198
219
|
else if (a.startsWith("--")) opts.unknown = a;
|
|
199
220
|
else opts.conditions.push(a);
|
|
@@ -836,6 +857,23 @@ export async function runWait(argv) {
|
|
|
836
857
|
process.stdout.write(HELP);
|
|
837
858
|
process.exit(0);
|
|
838
859
|
}
|
|
860
|
+
// BOT-1574 (AC3): --token / --profile are retired. Emit the migration hint and
|
|
861
|
+
// a typed `unknown_option` receipt, then exit 4 — before any relay call.
|
|
862
|
+
if (opts.retiredFlag) {
|
|
863
|
+
process.stderr.write(
|
|
864
|
+
`botbuddy wait: ${opts.retiredFlag} is retired (it could carry a machine credential) — a wait authenticates from the bb_agent_ session token. `
|
|
865
|
+
+ "Pass it typed with --agent-key <token>, or export $BOTBUDDY_AGENT_KEY (from register_agent), then: botbuddy wait '<condition>'. "
|
|
866
|
+
+ "The per-machine client key (botbuddy login) is never a wait credential.\n",
|
|
867
|
+
);
|
|
868
|
+
emitReceipt({
|
|
869
|
+
schema_version: 1,
|
|
870
|
+
outcome: "error",
|
|
871
|
+
error: "unknown_option",
|
|
872
|
+
option: opts.retiredFlag,
|
|
873
|
+
recovery: `${RECOVERY.sessionToken}; then: botbuddy wait '<condition>'`,
|
|
874
|
+
});
|
|
875
|
+
process.exit(EXIT.INVALID);
|
|
876
|
+
}
|
|
839
877
|
if (opts.unknown) {
|
|
840
878
|
process.stderr.write(`bb-wait: unknown option ${opts.unknown}\n`);
|
|
841
879
|
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
|
|
@@ -1045,6 +1083,22 @@ export async function runWait(argv) {
|
|
|
1045
1083
|
process.exit(EXIT.INVALID);
|
|
1046
1084
|
}
|
|
1047
1085
|
if (err && err.auth) {
|
|
1086
|
+
// BOT-1574 (AC4): the relay refused a wait armed by the per-machine client
|
|
1087
|
+
// key (or any owner/setup credential). The fix is never profile setup —
|
|
1088
|
+
// register a work agent and export its session token. Handle it before the
|
|
1089
|
+
// profile-recovery branches, which would dereference an absent agentProfile.
|
|
1090
|
+
if (err.errorCode === "client_key_cannot_wait") {
|
|
1091
|
+
process.stderr.write(
|
|
1092
|
+
"botbuddy wait: a client key (botbuddy login) cannot arm a wait — register_agent, export BOTBUDDY_AGENT_KEY=<session_token>, then re-run\n",
|
|
1093
|
+
);
|
|
1094
|
+
emitReceipt({
|
|
1095
|
+
schema_version: 1,
|
|
1096
|
+
outcome: "error",
|
|
1097
|
+
error: "client_key_cannot_wait",
|
|
1098
|
+
recovery: `${RECOVERY.sessionToken}; then: botbuddy wait '<condition>'`,
|
|
1099
|
+
});
|
|
1100
|
+
process.exit(EXIT.AUTH);
|
|
1101
|
+
}
|
|
1048
1102
|
// BOT-1572: a session-token failure (revoked/expired/rotated, or the relay
|
|
1049
1103
|
// refusing to honour the token) is fixed by RE-REGISTERING, not by profile
|
|
1050
1104
|
// setup. Lead with that, and never touch the (absent) agentProfile.
|