@botbuddy/cli 1.14.0 → 1.16.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/auth.mjs +7 -1
- package/src/commands.mjs +81 -5
- package/src/oauth-loopback.mjs +45 -1
package/package.json
CHANGED
package/src/auth.mjs
CHANGED
|
@@ -69,6 +69,12 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
69
69
|
const noBrowser = Boolean(options.noBrowser);
|
|
70
70
|
// BOT-1566: optional tenant preselection (validated by the argument parser).
|
|
71
71
|
const tenant = typeof options.tenant === "string" && options.tenant ? options.tenant : null;
|
|
72
|
+
// BOT-1580: display-only caller identity hints. Forwarded to the authorization
|
|
73
|
+
// screen so the approver knows who requested the login; sanitized inside
|
|
74
|
+
// buildAuthorizeUrl. These never influence tenant, scope, or token minting.
|
|
75
|
+
const caller = typeof options.caller === "string" && options.caller ? options.caller : null;
|
|
76
|
+
const callerHarness = typeof options.callerHarness === "string" && options.callerHarness ? options.callerHarness : null;
|
|
77
|
+
const callerTicket = typeof options.callerTicket === "string" && options.callerTicket ? options.callerTicket : null;
|
|
72
78
|
|
|
73
79
|
log(`${bold("BotBuddy OAuth Login")}\n`);
|
|
74
80
|
|
|
@@ -108,7 +114,7 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
108
114
|
|
|
109
115
|
// AC-6/AC-7: build the authorization URL and always print it (headless users
|
|
110
116
|
// open it themselves). The CLI never fetches /authorize.
|
|
111
|
-
const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, tenant });
|
|
117
|
+
const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, tenant, caller, callerHarness, callerTicket });
|
|
112
118
|
log("");
|
|
113
119
|
log(dim("→ Authorize BotBuddy in your browser:"));
|
|
114
120
|
log(` ${cyan(authUrl)}`);
|
package/src/commands.mjs
CHANGED
|
@@ -91,7 +91,7 @@ ${bold("OPTIONS")}
|
|
|
91
91
|
--no-server Don't auto-start codex app-server
|
|
92
92
|
|
|
93
93
|
${bold("AUTH")}
|
|
94
|
-
login [--no-browser] [--tenant <slug>]
|
|
94
|
+
login [--no-browser] [--tenant <slug>] [--caller <name>] [--caller-harness <h>] [--caller-ticket <ref>]
|
|
95
95
|
Authenticate via OAuth (opens browser + localhost callback)
|
|
96
96
|
logout Remove saved credentials
|
|
97
97
|
status Show current auth status (local metadata + server check)
|
|
@@ -176,7 +176,16 @@ export class LoginUsageError extends Error {
|
|
|
176
176
|
// preselects the tenant on the OAuth picker; a malformed slug is a usage
|
|
177
177
|
// error with exit code 2 (never sent to the server).
|
|
178
178
|
export function parseLoginArgs(args) {
|
|
179
|
-
const out = { noBrowser: false, tenant: null, help: false };
|
|
179
|
+
const out = { noBrowser: false, tenant: null, help: false, caller: null, callerHarness: null, callerTicket: null };
|
|
180
|
+
// BOT-1580: display-only caller flags. Unlike --tenant they are free text (an
|
|
181
|
+
// unverified hint shown on the consent screen), so they are never validated
|
|
182
|
+
// into a usage error here — sanitization/length-capping happens when the URL is
|
|
183
|
+
// built. A value is taken verbatim from `--flag value` or `--flag=value`.
|
|
184
|
+
const CALLER_FLAGS = {
|
|
185
|
+
"--caller": "caller",
|
|
186
|
+
"--caller-harness": "callerHarness",
|
|
187
|
+
"--caller-ticket": "callerTicket",
|
|
188
|
+
};
|
|
180
189
|
for (let i = 0; i < args.length; i++) {
|
|
181
190
|
const arg = args[i];
|
|
182
191
|
if (arg === "--help" || arg === "-h") { out.help = true; continue; }
|
|
@@ -189,6 +198,31 @@ export function parseLoginArgs(args) {
|
|
|
189
198
|
out.tenant = value;
|
|
190
199
|
continue;
|
|
191
200
|
}
|
|
201
|
+
let matchedCaller = false;
|
|
202
|
+
for (const [flag, key] of Object.entries(CALLER_FLAGS)) {
|
|
203
|
+
if (arg === flag || arg.startsWith(`${flag}=`)) {
|
|
204
|
+
let value;
|
|
205
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
206
|
+
// `--flag=value` takes the value verbatim (an explicit leading dash is
|
|
207
|
+
// the user's own choice, e.g. --caller=--weird).
|
|
208
|
+
value = arg.slice(`${flag}=`.length);
|
|
209
|
+
} else {
|
|
210
|
+
// `--flag value`: the next token is the value only if it exists and is
|
|
211
|
+
// not itself an option. Otherwise it would silently swallow the next
|
|
212
|
+
// flag (e.g. `--caller --no-browser` would set caller="--no-browser"
|
|
213
|
+
// and drop --no-browser) — reject that as a usage error instead.
|
|
214
|
+
value = args[i + 1];
|
|
215
|
+
if (typeof value !== "string" || value.startsWith("-")) {
|
|
216
|
+
throw new LoginUsageError(`error: ${flag} requires a value`, { exitCode: 2 });
|
|
217
|
+
}
|
|
218
|
+
i++;
|
|
219
|
+
}
|
|
220
|
+
out[key] = value;
|
|
221
|
+
matchedCaller = true;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (matchedCaller) continue;
|
|
192
226
|
if (arg.startsWith("-")) {
|
|
193
227
|
throw new LoginUsageError(`Unknown login option: ${arg}. Run ${cyan("botbuddy login --help")}.`);
|
|
194
228
|
}
|
|
@@ -196,6 +230,26 @@ export function parseLoginArgs(args) {
|
|
|
196
230
|
return out;
|
|
197
231
|
}
|
|
198
232
|
|
|
233
|
+
// BOT-1580: resolve the caller identity from parsed flags, falling back to the
|
|
234
|
+
// BOTBUDDY_CALLER* environment variables so an agent harness can advertise who it
|
|
235
|
+
// is without every wrapper having to pass flags. A flag always wins over the env.
|
|
236
|
+
// Returns only the three caller fields; empty values collapse to null.
|
|
237
|
+
export function resolveCaller(parsed = {}, env = {}) {
|
|
238
|
+
const pick = (flagVal, envKey) => {
|
|
239
|
+
// A flag that was PROVIDED wins over the environment, even when it is an
|
|
240
|
+
// explicit empty `--caller=` — that is how a user clears a stale inherited
|
|
241
|
+
// identity, so it must NOT fall back to the env. `null` means the flag was
|
|
242
|
+
// omitted → fall back to the env var. Either way an empty result is null.
|
|
243
|
+
const raw = flagVal != null ? flagVal : env[envKey];
|
|
244
|
+
return typeof raw === "string" && raw !== "" ? raw : null;
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
caller: pick(parsed.caller, "BOTBUDDY_CALLER"),
|
|
248
|
+
callerHarness: pick(parsed.callerHarness, "BOTBUDDY_CALLER_HARNESS"),
|
|
249
|
+
callerTicket: pick(parsed.callerTicket, "BOTBUDDY_CALLER_TICKET"),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
199
253
|
// BOT-1383: login now runs the RFC 8252 loopback flow (opens the browser,
|
|
200
254
|
// waits for the localhost callback). Parse --no-browser / --tenant and print
|
|
201
255
|
// focused help.
|
|
@@ -209,8 +263,10 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
|
|
|
209
263
|
die(err.message);
|
|
210
264
|
}
|
|
211
265
|
if (options.help) return loginHelp();
|
|
266
|
+
// BOT-1580: merge caller flags with the BOTBUDDY_CALLER* env fallback.
|
|
267
|
+
const caller = resolveCaller(options, process.env);
|
|
212
268
|
try {
|
|
213
|
-
await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant });
|
|
269
|
+
await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant, ...caller });
|
|
214
270
|
} catch (err) {
|
|
215
271
|
die(err.message);
|
|
216
272
|
}
|
|
@@ -221,6 +277,7 @@ function loginHelp() {
|
|
|
221
277
|
|
|
222
278
|
${bold("USAGE")}
|
|
223
279
|
botbuddy login [--no-browser] [--tenant <slug>]
|
|
280
|
+
[--caller <name>] [--caller-harness <harness>] [--caller-ticket <ref>]
|
|
224
281
|
|
|
225
282
|
${bold("HOW IT WORKS")}
|
|
226
283
|
Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
|
|
@@ -238,6 +295,20 @@ ${bold("OPTIONS")}
|
|
|
238
295
|
Preselect a tenant (e.g. ${cyan("supply-guard")}) on the sign-in page when
|
|
239
296
|
your account belongs to more than one. Must be a lowercase slug;
|
|
240
297
|
membership is still enforced by the server.
|
|
298
|
+
--caller <name>
|
|
299
|
+
Show WHO requested this login on the authorization screen, so
|
|
300
|
+
the human approving it can confirm the request came from the
|
|
301
|
+
agent/session they are actually running. Display-only and
|
|
302
|
+
unverified — it never affects the tenant, scope, or token.
|
|
303
|
+
--caller-harness <harness>
|
|
304
|
+
Optional harness label shown next to the caller (e.g.
|
|
305
|
+
${cyan("claude-code")}, ${cyan("codex")}).
|
|
306
|
+
--caller-ticket <ref>
|
|
307
|
+
Optional ticket the caller is working on. A full
|
|
308
|
+
${dim("https://linear.app/…")} URL is shown as a link; anything else is
|
|
309
|
+
shown as plain text.
|
|
310
|
+
${dim("Each --caller* value also falls back to $BOTBUDDY_CALLER,")}
|
|
311
|
+
${dim("$BOTBUDDY_CALLER_HARNESS and $BOTBUDDY_CALLER_TICKET.")}
|
|
241
312
|
|
|
242
313
|
${bold("NOTES")}
|
|
243
314
|
• The authorization URL is always printed so you can open it manually.
|
|
@@ -264,14 +335,19 @@ async function cmdStart(args) {
|
|
|
264
335
|
// (owner token from `login`, agent key from `profile setup`), so probe those
|
|
265
336
|
// rather than a plaintext config secret.
|
|
266
337
|
try {
|
|
338
|
+
// BOT-1580: the auto-login path has no --caller flags of its own, but a
|
|
339
|
+
// harness that exports BOTBUDDY_CALLER* should still be identified on the
|
|
340
|
+
// authorization screen. Resolve the caller from the environment and forward
|
|
341
|
+
// it, exactly as `botbuddy login` does.
|
|
342
|
+
const caller = resolveCaller({}, process.env);
|
|
267
343
|
const owner = await resolveOwnerToken({ getConfig });
|
|
268
344
|
const agentKey = owner ? null : await resolveAgentKey();
|
|
269
345
|
if (!owner && !agentKey) {
|
|
270
346
|
console.log(dim("→ No credentials found. Starting login...\n"));
|
|
271
|
-
await doLogin();
|
|
347
|
+
await doLogin({ ...caller });
|
|
272
348
|
} else if (owner && owner.expiresAt && owner.expiresAt <= Date.now() && !(await resolveAgentKey())) {
|
|
273
349
|
console.log(dim("→ Token expired. Re-authenticating...\n"));
|
|
274
|
-
await doLogin();
|
|
350
|
+
await doLogin({ ...caller });
|
|
275
351
|
}
|
|
276
352
|
} catch (err) {
|
|
277
353
|
die(err.message);
|
package/src/oauth-loopback.mjs
CHANGED
|
@@ -44,9 +44,43 @@ export function generateState() {
|
|
|
44
44
|
return randomBytes(16).toString("hex");
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// BOT-1580: display-only caller identity is UNTRUSTED text. Sanitize it before
|
|
48
|
+
// it ever enters the authorize URL: strip control characters (they can break the
|
|
49
|
+
// consent-screen layout or smuggle terminal escapes into any log that echoes the
|
|
50
|
+
// URL) and cap the length. Returns the cleaned string, or null when nothing
|
|
51
|
+
// printable remains — so an absent/blank/garbage field is simply omitted rather
|
|
52
|
+
// than rendered as an empty or fabricated identity. This is defense in depth; the
|
|
53
|
+
// web consent screen sanitizes again at render time and never trusts this hint
|
|
54
|
+
// for tenant selection, scope, or token minting.
|
|
55
|
+
export function sanitizeCallerField(value, maxLen = 200) {
|
|
56
|
+
if (typeof value !== "string") return null;
|
|
57
|
+
// Strip Unicode control (C0/C1/DEL), format (bidi overrides, zero-width, BOM,
|
|
58
|
+
// soft hyphen), and line/paragraph separators — an untrusted caller must not be
|
|
59
|
+
// able to alter line breaking or visual ordering on the consent screen — while
|
|
60
|
+
// preserving legitimate visible non-ASCII in names.
|
|
61
|
+
const cleaned = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "").trim();
|
|
62
|
+
if (!cleaned) return null;
|
|
63
|
+
return cleaned.slice(0, maxLen);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// BOT-1580: length caps per caller field. Names and harnesses are short labels;
|
|
67
|
+
// a ticket may be a full Linear URL, so it gets more headroom.
|
|
68
|
+
export const CALLER_FIELD_LIMITS = { name: 80, harness: 40, ticket: 200 };
|
|
69
|
+
|
|
47
70
|
// AC-6/AC-7: build the complete /authorize URL. The browser follows it; the CLI
|
|
48
71
|
// never fetches it. `redirectUri` must be the exact loopback callback we bound.
|
|
49
|
-
export function buildAuthorizeUrl({
|
|
72
|
+
export function buildAuthorizeUrl({
|
|
73
|
+
serverUrl,
|
|
74
|
+
clientId,
|
|
75
|
+
redirectUri,
|
|
76
|
+
state,
|
|
77
|
+
codeChallenge,
|
|
78
|
+
scope = "read write lock",
|
|
79
|
+
tenant = null,
|
|
80
|
+
caller = null,
|
|
81
|
+
callerHarness = null,
|
|
82
|
+
callerTicket = null,
|
|
83
|
+
}) {
|
|
50
84
|
const url = new URL(`${serverUrl}/authorize`);
|
|
51
85
|
url.searchParams.set("client_id", clientId);
|
|
52
86
|
url.searchParams.set("redirect_uri", redirectUri);
|
|
@@ -59,6 +93,16 @@ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, cod
|
|
|
59
93
|
// it to the /mcp-auth picker as `requestedTenant`; membership is still
|
|
60
94
|
// enforced server-side at authorize_complete — this only preselects.
|
|
61
95
|
if (tenant) url.searchParams.set("tenant", tenant);
|
|
96
|
+
// BOT-1580: display-only caller identity, so the human approving the login can
|
|
97
|
+
// check it against the agent session they are actually running. These are
|
|
98
|
+
// UNVERIFIED hints — /authorize only forwards them to the consent screen; they
|
|
99
|
+
// never influence tenant, scope, or the minted token.
|
|
100
|
+
const callerName = sanitizeCallerField(caller, CALLER_FIELD_LIMITS.name);
|
|
101
|
+
const harness = sanitizeCallerField(callerHarness, CALLER_FIELD_LIMITS.harness);
|
|
102
|
+
const ticket = sanitizeCallerField(callerTicket, CALLER_FIELD_LIMITS.ticket);
|
|
103
|
+
if (callerName) url.searchParams.set("caller", callerName);
|
|
104
|
+
if (harness) url.searchParams.set("caller_harness", harness);
|
|
105
|
+
if (ticket) url.searchParams.set("caller_ticket", ticket);
|
|
62
106
|
return url.toString();
|
|
63
107
|
}
|
|
64
108
|
|