@botbuddy/cli 1.2.2 → 1.4.1

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/src/locks.mjs ADDED
@@ -0,0 +1,154 @@
1
+ import { hostname } from "node:os";
2
+
3
+ export class LocksUsageError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "LocksUsageError";
7
+ }
8
+ }
9
+
10
+ function readValue(args, index, flag) {
11
+ const value = args[index + 1];
12
+ if (!value || value.startsWith("-")) {
13
+ throw new LocksUsageError(`${flag} requires a value`);
14
+ }
15
+ return value;
16
+ }
17
+
18
+ function requireHost(host) {
19
+ if (!host || host === "local" || host === "localhost" || host === "127.0.0.1") {
20
+ throw new LocksUsageError("--host must be a real machine hostname for typed local resources");
21
+ }
22
+ return host;
23
+ }
24
+
25
+ export function buildAcquireResourcesPayload(args, options = {}) {
26
+ const defaultHost = options.defaultHost || hostname();
27
+ let host = defaultHost;
28
+ let ticketId;
29
+ let ticketUrl;
30
+ let prId;
31
+ let prUrl;
32
+ let noTicketReason;
33
+ let noPrReason;
34
+ let environment;
35
+ let laneSlot;
36
+ let vitePort;
37
+ let backendPort;
38
+ let url;
39
+ const requestedPorts = [];
40
+ let wantsMcp = false;
41
+
42
+ for (let i = 0; i < args.length; i++) {
43
+ const arg = args[i];
44
+ switch (arg) {
45
+ case "-p":
46
+ case "--port": {
47
+ const next = args[i + 1];
48
+ const portType = next && !next.startsWith("-") ? next : "frontend";
49
+ if (next && !next.startsWith("-")) i++;
50
+ if (!["frontend", "backend"].includes(portType)) {
51
+ throw new LocksUsageError("--port must be frontend or backend");
52
+ }
53
+ requestedPorts.push(portType);
54
+ break;
55
+ }
56
+ case "-m":
57
+ case "--mcp":
58
+ wantsMcp = true;
59
+ break;
60
+ case "--host":
61
+ host = readValue(args, i, arg);
62
+ i++;
63
+ break;
64
+ case "--lane":
65
+ case "--slot":
66
+ laneSlot = readValue(args, i, arg);
67
+ i++;
68
+ break;
69
+ case "--vite-port":
70
+ vitePort = readValue(args, i, arg);
71
+ i++;
72
+ break;
73
+ case "--backend-port":
74
+ backendPort = readValue(args, i, arg);
75
+ i++;
76
+ break;
77
+ case "--url":
78
+ url = readValue(args, i, arg);
79
+ i++;
80
+ break;
81
+ case "-t":
82
+ case "--ticket":
83
+ ticketId = readValue(args, i, arg);
84
+ i++;
85
+ break;
86
+ case "--ticket-url":
87
+ ticketUrl = readValue(args, i, arg);
88
+ i++;
89
+ break;
90
+ case "--pr":
91
+ prId = readValue(args, i, arg);
92
+ i++;
93
+ break;
94
+ case "--pr-url":
95
+ prUrl = readValue(args, i, arg);
96
+ i++;
97
+ break;
98
+ case "--no-ticket-reason":
99
+ noTicketReason = readValue(args, i, arg);
100
+ i++;
101
+ break;
102
+ case "--no-pr-reason":
103
+ noPrReason = readValue(args, i, arg);
104
+ i++;
105
+ break;
106
+ case "--environment":
107
+ environment = readValue(args, i, arg);
108
+ i++;
109
+ break;
110
+ default:
111
+ throw new LocksUsageError(`Unknown locks option: ${arg}`);
112
+ }
113
+ }
114
+
115
+ const resources = [];
116
+ const typedHost = requireHost(host);
117
+
118
+ for (const portType of requestedPorts) {
119
+ const subtype = portType === "backend" ? "backend_port" : "vite_port";
120
+ const slot = portType === "backend" ? backendPort : vitePort;
121
+ resources.push({
122
+ resource_type: "port",
123
+ port_type: portType,
124
+ subtype,
125
+ host: typedHost,
126
+ ...(slot ? { slot } : {}),
127
+ ...(url ? { url } : {}),
128
+ });
129
+ }
130
+
131
+ if (wantsMcp) {
132
+ resources.push({
133
+ resource_type: "mcp_server",
134
+ subtype: "playwright_lane",
135
+ host: typedHost,
136
+ ...(laneSlot ? { slot: laneSlot } : {}),
137
+ });
138
+ }
139
+
140
+ if (!resources.length) {
141
+ throw new LocksUsageError("Usage: botbuddy locks -p [frontend|backend] -m [--host hostname] [--lane slot] [--vite-port port] [-t ticket] [--pr id]");
142
+ }
143
+
144
+ return {
145
+ resources,
146
+ ...(ticketId ? { ticket_id: ticketId } : {}),
147
+ ...(ticketUrl ? { ticket_url: ticketUrl } : {}),
148
+ ...(noTicketReason ? { no_ticket_reason: noTicketReason } : {}),
149
+ ...(prId ? { pr_id: prId } : {}),
150
+ ...(prUrl ? { pr_url: prUrl } : {}),
151
+ ...(noPrReason ? { no_pr_reason: noPrReason } : {}),
152
+ ...(environment ? { environment } : {}),
153
+ };
154
+ }
@@ -0,0 +1,60 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { buildAcquireResourcesPayload } from "./locks.mjs";
5
+
6
+ test("locks --mcp emits a typed host-scoped Playwright lane request", () => {
7
+ const payload = buildAcquireResourcesPayload(["--mcp", "--ticket", "BOT-660"], {
8
+ defaultHost: "macbook-pro",
9
+ });
10
+
11
+ assert.deepEqual(payload, {
12
+ resources: [
13
+ {
14
+ resource_type: "mcp_server",
15
+ subtype: "playwright_lane",
16
+ host: "macbook-pro",
17
+ },
18
+ ],
19
+ ticket_id: "BOT-660",
20
+ });
21
+ });
22
+
23
+ test("locks --mcp can pin a lane slot and preserve PR metadata", () => {
24
+ const payload = buildAcquireResourcesPayload(
25
+ ["--mcp", "--host", "Jonos-MBP.localdomain", "--lane", "4", "--pr", "#197"],
26
+ { defaultHost: "ignored-host" },
27
+ );
28
+
29
+ assert.deepEqual(payload, {
30
+ resources: [
31
+ {
32
+ resource_type: "mcp_server",
33
+ subtype: "playwright_lane",
34
+ host: "Jonos-MBP.localdomain",
35
+ slot: "4",
36
+ },
37
+ ],
38
+ pr_id: "#197",
39
+ });
40
+ });
41
+
42
+ test("locks --port emits typed Vite port metadata with slot and URL", () => {
43
+ const payload = buildAcquireResourcesPayload(
44
+ ["--port", "frontend", "--vite-port", "4179", "--url", "http://127.0.0.1:4179"],
45
+ { defaultHost: "macbook-pro" },
46
+ );
47
+
48
+ assert.deepEqual(payload, {
49
+ resources: [
50
+ {
51
+ resource_type: "port",
52
+ port_type: "frontend",
53
+ subtype: "vite_port",
54
+ host: "macbook-pro",
55
+ slot: "4179",
56
+ url: "http://127.0.0.1:4179",
57
+ },
58
+ ],
59
+ });
60
+ });
@@ -0,0 +1,228 @@
1
+ // BOT-1383: RFC 8252 loopback receiver for the CLI OAuth authorization-code
2
+ // flow. The old `botbuddy login` fetched /authorize itself and searched the
3
+ // first redirect for a code — but that first redirect only points the human at
4
+ // the sign-in page, so login always failed with "No authorization code in
5
+ // redirect". The browser owns the redirects and the human sign-in; the CLI's
6
+ // only job is to listen on a loopback socket for the final callback the browser
7
+ // is redirected to, validate it, and exchange the code.
8
+ //
9
+ // Everything here is dependency-free and testable: PKCE/state generation, URL
10
+ // construction, the ephemeral 127.0.0.1 listener, browser opening, and the
11
+ // bounded wait are all separate functions with injectable seams.
12
+
13
+ import { createServer } from "node:http";
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ import { spawn as realSpawn } from "node:child_process";
16
+
17
+ // Bind the literal IPv4 loopback address, never a hostname. `localhost` can
18
+ // resolve to ::1 while the OS hands us an IPv4 socket, so the registered
19
+ // redirect URI and the listener must both speak 127.0.0.1 (edge case in ticket).
20
+ export const LOOPBACK_HOST = "127.0.0.1";
21
+
22
+ // AC-11: bounded default wait of five minutes.
23
+ export const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
24
+
25
+ // A callback that could not be accepted. `code` is a stable machine reason, not
26
+ // a secret — never carries the verifier, token, or authorization code.
27
+ export class OAuthCallbackError extends Error {
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.name = "OAuthCallbackError";
31
+ this.code = code;
32
+ }
33
+ }
34
+
35
+ // AC-4: PKCE S256 with a cryptographically random verifier.
36
+ export function generatePkce() {
37
+ const codeVerifier = randomBytes(32).toString("base64url").slice(0, 43);
38
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
39
+ return { codeVerifier, codeChallenge };
40
+ }
41
+
42
+ // AC-4: cryptographically random state, fresh per login attempt.
43
+ export function generateState() {
44
+ return randomBytes(16).toString("hex");
45
+ }
46
+
47
+ // AC-6/AC-7: build the complete /authorize URL. The browser follows it; the CLI
48
+ // never fetches it. `redirectUri` must be the exact loopback callback we bound.
49
+ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, scope = "read write lock" }) {
50
+ const url = new URL(`${serverUrl}/authorize`);
51
+ url.searchParams.set("client_id", clientId);
52
+ url.searchParams.set("redirect_uri", redirectUri);
53
+ url.searchParams.set("response_type", "code");
54
+ url.searchParams.set("scope", scope);
55
+ url.searchParams.set("state", state);
56
+ url.searchParams.set("code_challenge", codeChallenge);
57
+ url.searchParams.set("code_challenge_method", "S256");
58
+ return url.toString();
59
+ }
60
+
61
+ // AC-9: the browser-facing page. Deliberately discloses nothing — no code,
62
+ // state, verifier, or token — just a "you can close this tab" message.
63
+ function htmlPage(message) {
64
+ return `<!doctype html><html><head><meta charset="utf-8"><title>BotBuddy</title></head>`
65
+ + `<body style="font-family:system-ui,-apple-system,sans-serif;max-width:32rem;margin:4rem auto;text-align:center">`
66
+ + `<h1>BotBuddy CLI</h1><p>${message}</p></body></html>`;
67
+ }
68
+
69
+ function respond(res, status, message) {
70
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
71
+ res.end(htmlPage(message));
72
+ }
73
+
74
+ // Create (but do not yet bind) a loopback receiver bound to the given expected
75
+ // state. Usage:
76
+ // const receiver = createLoopbackReceiver({ expectedState });
77
+ // const { port, redirectUri } = await receiver.listen();
78
+ // ... register client with redirectUri, open browser at the authorize URL ...
79
+ // const { code } = await receiver.waitForCallback({ timeoutMs });
80
+ // await receiver.close(); // always, in a finally
81
+ export function createLoopbackReceiver({ expectedState } = {}) {
82
+ let settled = false;
83
+ let resolveCb;
84
+ let rejectCb;
85
+ const done = new Promise((resolve, reject) => { resolveCb = resolve; rejectCb = reject; });
86
+ // Swallow the rejection if nobody has awaited yet; waitForCallback re-races it.
87
+ done.catch(() => {});
88
+
89
+ const finish = (fn, value) => {
90
+ if (settled) return;
91
+ settled = true;
92
+ fn(value);
93
+ };
94
+
95
+ const server = createServer((req, res) => {
96
+ let reqUrl;
97
+ try {
98
+ reqUrl = new URL(req.url, `http://${LOOPBACK_HOST}`);
99
+ } catch {
100
+ respond(res, 400, "Bad request.");
101
+ return;
102
+ }
103
+
104
+ // AC-8: accept only GET /callback. /favicon.ico and any other stray path is
105
+ // answered but ignored, and the listener keeps waiting.
106
+ if (req.method !== "GET" || reqUrl.pathname !== "/callback") {
107
+ respond(res, 404, "Not found.");
108
+ return;
109
+ }
110
+
111
+ const params = reqUrl.searchParams;
112
+ const returnedState = params.get("state");
113
+ const error = params.get("error");
114
+ const code = params.get("code");
115
+
116
+ // Validate state BEFORE trusting anything else (AC-8). A request that does
117
+ // not carry our exact state is untrusted noise — a stale/malicious tab, a
118
+ // localhost probe, a favicon follow. Reject THAT request with a 400 but keep
119
+ // listening: it must not settle the receiver, or a stray probe could abort a
120
+ // login whose legitimate callback has not arrived yet. Only a correct-state
121
+ // request below is trusted to complete or fail the login.
122
+ if (!returnedState || returnedState !== expectedState) {
123
+ respond(res, 400, "This request didn't match the current login. You can close this tab.");
124
+ return;
125
+ }
126
+
127
+ // AC-8: surface OAuth error/error_description (e.g. the user denied access).
128
+ if (error) {
129
+ const description = params.get("error_description") || "";
130
+ respond(res, 400, "Authentication failed. You can close this tab and return to the terminal.");
131
+ finish(rejectCb, new OAuthCallbackError(error, description || `Authorization error: ${error}`));
132
+ return;
133
+ }
134
+
135
+ if (!code) {
136
+ respond(res, 400, "Authentication failed: no authorization code. You can close this tab.");
137
+ finish(rejectCb, new OAuthCallbackError("missing_code", "Callback did not include an authorization code"));
138
+ return;
139
+ }
140
+
141
+ // AC-8: complete at most once. A retried/duplicate callback gets a friendly
142
+ // page but never triggers a second token exchange.
143
+ if (settled) {
144
+ respond(res, 200, "Sign-in already received. You can close this tab.");
145
+ return;
146
+ }
147
+ // Only the authorization has arrived here — the token exchange happens next
148
+ // in the CLI and may still fail. Don't claim final success in the browser or
149
+ // it would contradict a terminal token error; point the user to the terminal.
150
+ respond(res, 200, "Sign-in received. Return to your terminal to finish — you can close this tab.");
151
+ finish(resolveCb, { code });
152
+ });
153
+
154
+ function listen() {
155
+ return new Promise((resolve, reject) => {
156
+ const onError = (err) => reject(err);
157
+ server.once("error", onError);
158
+ // AC-2: 127.0.0.1 + OS-assigned ephemeral port (port 0). Never 0.0.0.0.
159
+ server.listen(0, LOOPBACK_HOST, () => {
160
+ server.removeListener("error", onError);
161
+ const { port } = server.address();
162
+ resolve({ port, redirectUri: `http://${LOOPBACK_HOST}:${port}/callback` });
163
+ });
164
+ });
165
+ }
166
+
167
+ function waitForCallback({ timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS, setTimeout: setT = setTimeout, clearTimeout: clearT = clearTimeout } = {}) {
168
+ let timer;
169
+ const timeout = new Promise((_resolve, reject) => {
170
+ timer = setT(() => {
171
+ finish(reject, new OAuthCallbackError("timeout", `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the authorization callback`));
172
+ }, timeoutMs);
173
+ if (typeof timer?.unref === "function") timer.unref();
174
+ });
175
+ return Promise.race([done, timeout]).finally(() => clearT(timer));
176
+ }
177
+
178
+ function close() {
179
+ return new Promise((resolve) => {
180
+ // closeAllConnections (Node 18.2+) drops lingering keep-alive sockets so
181
+ // close() resolves promptly; optional-chained for older 18.x.
182
+ server.closeAllConnections?.();
183
+ if (!server.listening) { resolve(); return; }
184
+ server.close(() => resolve());
185
+ });
186
+ }
187
+
188
+ return { listen, waitForCallback, close };
189
+ }
190
+
191
+ // AC-5: open the URL in the user's default browser using argument-based process
192
+ // spawning — the URL is always a discrete argv entry, never shell-interpolated.
193
+ // Cross-platform without a runtime dependency. Resolves once the child has
194
+ // spawned; rejects if the opener is unavailable (headless / missing binary).
195
+ export function openBrowser(url, { platform = process.platform, spawn = realSpawn } = {}) {
196
+ let command;
197
+ let args;
198
+ if (platform === "darwin") {
199
+ command = "open";
200
+ args = [url];
201
+ } else if (platform === "win32") {
202
+ // Never route the URL through cmd.exe: `cmd /c start` re-parses the URL's
203
+ // unescaped `&` query separators as command separators, so `start` would
204
+ // receive only the URL prefix and the OAuth params would be dropped.
205
+ // rundll32's FileProtocolHandler hands the whole URL to the default browser
206
+ // as a single, unparsed argument — no shell, no `&` splitting.
207
+ command = "rundll32";
208
+ args = ["url.dll,FileProtocolHandler", url];
209
+ } else {
210
+ command = "xdg-open";
211
+ args = [url];
212
+ }
213
+
214
+ return new Promise((resolve, reject) => {
215
+ let child;
216
+ try {
217
+ child = spawn(command, args, { stdio: "ignore", detached: true });
218
+ } catch (err) {
219
+ reject(err);
220
+ return;
221
+ }
222
+ child.once("error", reject);
223
+ child.once("spawn", () => {
224
+ if (typeof child.unref === "function") child.unref();
225
+ resolve();
226
+ });
227
+ });
228
+ }
@@ -0,0 +1,104 @@
1
+ import { hostname } from "node:os";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ import { ensureProfileCredentialBackend, readProfileIdentity, readProfileRetryIdentity, recordProfileRetryIdentity, installProfileCredential, profileCredentialEnvironment } from "./agent-credential-store.mjs";
5
+ import { callToolJson } from "./api.mjs";
6
+
7
+ const PROFILES = Object.freeze({
8
+ "botbuddy-dev": Object.freeze({ tenant: "botbuddy" }),
9
+ "supplyguard-dev": Object.freeze({ tenant: "supply-guard" }),
10
+ });
11
+
12
+ function getAgentProfile(name) {
13
+ return PROFILES[name] ?? null;
14
+ }
15
+
16
+ export class ProfileBootstrapError extends Error {
17
+ constructor(code) {
18
+ super(code);
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ export function defaultProfileAgentName(profile) {
24
+ const host = hostname().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
25
+ const suffix = randomUUID().replace(/-/g, "").slice(0, 12);
26
+ return `${profile}-${host || "host"}-${suffix}`;
27
+ }
28
+
29
+ function registrationCredential(data) {
30
+ return typeof data?.agent_api_key === "string"
31
+ ? data.agent_api_key
32
+ : typeof data?.api_key === "string"
33
+ ? data.api_key
34
+ : null;
35
+ }
36
+
37
+ export async function bootstrapProfile(profileName, {
38
+ name = null,
39
+ call = callToolJson,
40
+ readIdentity = readProfileIdentity,
41
+ readRetryIdentity = readProfileRetryIdentity,
42
+ recordRetryIdentity = recordProfileRetryIdentity,
43
+ store = installProfileCredential,
44
+ ensureBackend = ensureProfileCredentialBackend,
45
+ } = {}) {
46
+ const profile = getAgentProfile(profileName);
47
+ if (!profile) throw new ProfileBootstrapError("profile_required");
48
+ try { ensureBackend(); } catch { throw new ProfileBootstrapError("profile_agent_required"); }
49
+
50
+ const existing = await readIdentity(profileName);
51
+ const retryIdentity = existing ? null : await readRetryIdentity(profileName);
52
+ // A mismatched local entry is never reused. A fresh server-attested
53
+ // registration replaces it atomically, so the documented recovery command
54
+ // cannot loop forever on stale metadata.
55
+ const reusableIdentity = existing?.tenant === profile.tenant ? existing.agentId : retryIdentity?.agentId ?? null;
56
+ const agentName = name ?? (existing?.tenant === profile.tenant ? existing.name : retryIdentity?.name ?? defaultProfileAgentName(profileName));
57
+ const args = {
58
+ name: agentName,
59
+ type: "codex",
60
+ tenant_id: profile.tenant,
61
+ ...(reusableIdentity ? { agent_id: reusableIdentity } : {}),
62
+ };
63
+ const response = await call("register_agent", args);
64
+ const data = response?.data;
65
+ if (!response?.ok || (response.isError && data?.code !== "FRESH_CONNECTION_REQUIRED")) {
66
+ throw new ProfileBootstrapError("profile_agent_required");
67
+ }
68
+ if (!data?.agent_id || typeof data.agent_id !== "string") {
69
+ throw new ProfileBootstrapError("profile_agent_required");
70
+ }
71
+ if (typeof data.tenant_id !== "string") {
72
+ await recordRetryIdentity({ profile: profileName, agentId: data.agent_id, name: agentName });
73
+ throw new ProfileBootstrapError("profile_tenant_attestation_missing");
74
+ }
75
+ if (data.tenant_id !== profile.tenant) {
76
+ throw new ProfileBootstrapError("profile_credential_wrong_tenant");
77
+ }
78
+ const token = registrationCredential(data);
79
+ if (!token) throw new ProfileBootstrapError("profile_agent_required");
80
+
81
+ await store({
82
+ profile: profileName,
83
+ tenant: profile.tenant,
84
+ agentId: data.agent_id,
85
+ name: agentName,
86
+ token,
87
+ });
88
+ return {
89
+ schema_version: 1,
90
+ outcome: "installed",
91
+ profile: profileName,
92
+ tenant_id: profile.tenant,
93
+ agent_id: data.agent_id,
94
+ credential_source: "keychain_profile_slot",
95
+ shell_refresh: `source <(npx --yes @botbuddy/cli@latest profile env ${profileName})`,
96
+ gui_refresh: "$HOME/.local/bin/botbuddy-mcp-env.sh",
97
+ };
98
+ }
99
+
100
+ export function profileShellRefresh(profileName) {
101
+ const tokenEnv = profileCredentialEnvironment(profileName);
102
+ if (!tokenEnv) throw new ProfileBootstrapError("profile_required");
103
+ 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`;
104
+ }