@botbuddy/cli 1.60.5 → 1.60.7
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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +4 -2
- package/src/commands.mjs +28 -0
- package/src/pw/args.mjs +17 -0
- package/src/pw/coordinator.mjs +5 -1
- package/src/pw/run.mjs +73 -9
- package/src/pw/staging-auth.mjs +393 -0
- package/src/staging-pool/binding.mjs +122 -0
- package/src/staging-pool/checkouts.mjs +120 -0
- package/src/staging-pool/cli.mjs +548 -0
- package/src/staging-pool/config.mjs +110 -0
- package/src/staging-pool/errors.mjs +434 -0
- package/src/staging-pool/health.mjs +84 -0
- package/src/staging-pool/help.mjs +125 -0
- package/src/staging-pool/job.mjs +7 -0
- package/src/staging-pool/launchd.mjs +92 -0
- package/src/staging-pool/password-source.mjs +65 -0
- package/src/staging-pool/policy.mjs +186 -0
- package/src/staging-pool/pool.mjs +666 -0
- package/src/staging-pool/signin-gotrue-totp.mjs +198 -0
- package/src/staging-pool/state-file.mjs +166 -0
- package/src/staging-pool/totp-botbuddy.mjs +106 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// BOT-2084 (lifted from supplyspark-ent ENT-5120 launchd.mjs): the human-owned
|
|
2
|
+
// launchd job (a per-user LaunchAgent; label from the repo binding).
|
|
3
|
+
// Node-stdlib-only; launchctl and the filesystem are injected.
|
|
4
|
+
|
|
5
|
+
const xmlEscape = (value) =>
|
|
6
|
+
String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
7
|
+
|
|
8
|
+
const string = (value) => `<string>${xmlEscape(value)}</string>`;
|
|
9
|
+
|
|
10
|
+
export function buildLaunchAgentPlist({
|
|
11
|
+
label,
|
|
12
|
+
programArguments,
|
|
13
|
+
environment,
|
|
14
|
+
stdoutPath,
|
|
15
|
+
stderrPath,
|
|
16
|
+
workingDirectory,
|
|
17
|
+
intervalSeconds,
|
|
18
|
+
runAtLoad = true,
|
|
19
|
+
secretValues = [],
|
|
20
|
+
}) {
|
|
21
|
+
if (!label || !Array.isArray(programArguments) || programArguments.length === 0) {
|
|
22
|
+
throw new Error("A LaunchAgent needs a label and program arguments.");
|
|
23
|
+
}
|
|
24
|
+
for (const [name, value] of Object.entries(environment ?? {})) {
|
|
25
|
+
if (/password|secret|token|key/i.test(name) || /SeedPass|eyJ|bb_sess_|bb_mcp_|bb_cli_|sb_secret_/.test(String(value))) {
|
|
26
|
+
throw new Error(`Refusing to write a secret-looking environment variable (${name}) into a plist.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// No argument or environment value may carry a known secret (e.g. a persona password).
|
|
30
|
+
const known = secretValues.filter((secret) => typeof secret === "string" && secret.length >= 6);
|
|
31
|
+
for (const value of [...programArguments, ...Object.values(environment ?? {})]) {
|
|
32
|
+
if (known.some((secret) => String(value).includes(secret)) || /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(String(value))) {
|
|
33
|
+
throw new Error("Refusing to write a secret value into a plist.");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const lines = [
|
|
37
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
38
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
39
|
+
'<plist version="1.0">',
|
|
40
|
+
"<dict>",
|
|
41
|
+
` <key>Label</key>${string(label)}`,
|
|
42
|
+
" <key>ProgramArguments</key>",
|
|
43
|
+
" <array>",
|
|
44
|
+
...programArguments.map((arg) => ` ${string(arg)}`),
|
|
45
|
+
" </array>",
|
|
46
|
+
];
|
|
47
|
+
if (environment && Object.keys(environment).length > 0) {
|
|
48
|
+
lines.push(" <key>EnvironmentVariables</key>", " <dict>");
|
|
49
|
+
for (const [name, value] of Object.entries(environment)) {
|
|
50
|
+
lines.push(` <key>${xmlEscape(name)}</key>${string(value)}`);
|
|
51
|
+
}
|
|
52
|
+
lines.push(" </dict>");
|
|
53
|
+
}
|
|
54
|
+
if (workingDirectory) lines.push(` <key>WorkingDirectory</key>${string(workingDirectory)}`);
|
|
55
|
+
if (Number.isInteger(intervalSeconds)) lines.push(` <key>StartInterval</key><integer>${intervalSeconds}</integer>`);
|
|
56
|
+
lines.push(` <key>RunAtLoad</key><${runAtLoad ? "true" : "false"}/>`);
|
|
57
|
+
lines.push(" <key>ProcessType</key><string>Background</string>");
|
|
58
|
+
lines.push(" <key>Umask</key><integer>63</integer>"); // 077: files the job creates are owner-only
|
|
59
|
+
if (stdoutPath) lines.push(` <key>StandardOutPath</key>${string(stdoutPath)}`);
|
|
60
|
+
if (stderrPath) lines.push(` <key>StandardErrorPath</key>${string(stderrPath)}`);
|
|
61
|
+
lines.push("</dict>", "</plist>", "");
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createLaunchctl({ exec, uid }) {
|
|
66
|
+
const domain = `gui/${uid}`;
|
|
67
|
+
return {
|
|
68
|
+
domain,
|
|
69
|
+
async bootout(label) {
|
|
70
|
+
try {
|
|
71
|
+
await exec("/bin/launchctl", ["bootout", `${domain}/${label}`]);
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false; // not loaded
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async bootstrap(plistPath) {
|
|
78
|
+
await exec("/bin/launchctl", ["bootstrap", domain, plistPath]);
|
|
79
|
+
},
|
|
80
|
+
async kickstart(label) {
|
|
81
|
+
await exec("/bin/launchctl", ["kickstart", `${domain}/${label}`]);
|
|
82
|
+
},
|
|
83
|
+
async isLoaded(label) {
|
|
84
|
+
try {
|
|
85
|
+
await exec("/bin/launchctl", ["print", `${domain}/${label}`]);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// BOT-2084: the seeded persona's password, read by the human-installed job at
|
|
2
|
+
// mint time from the binding's env file (e.g. ~/.config/botbuddy-web/.env.local.shared).
|
|
3
|
+
//
|
|
4
|
+
// Node-stdlib-only and self-contained. The value is returned to the sign-in
|
|
5
|
+
// adapter only: it is never logged, written to a plist (launchd.mjs refuses), put
|
|
6
|
+
// in a receipt or passed on argv. The file must be a regular file owned by this
|
|
7
|
+
// user and not readable by group/others (0600 or stricter).
|
|
8
|
+
|
|
9
|
+
import { readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export class PasswordSourceError extends Error {
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "PasswordSourceError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function expandHome(path, home = homedir()) {
|
|
22
|
+
return String(path).startsWith("~/") ? join(home, String(path).slice(2)) : String(path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseEnvFile(text) {
|
|
26
|
+
const env = {};
|
|
27
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
28
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
29
|
+
if (!match) continue;
|
|
30
|
+
let value = match[2].trim();
|
|
31
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
32
|
+
env[match[1]] = value;
|
|
33
|
+
}
|
|
34
|
+
return env;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createEnvFilePasswordSource({
|
|
38
|
+
envFile,
|
|
39
|
+
personas,
|
|
40
|
+
home = homedir(),
|
|
41
|
+
read = readFileSync,
|
|
42
|
+
stat = statSync,
|
|
43
|
+
uid = typeof process.getuid === "function" ? process.getuid() : null,
|
|
44
|
+
} = {}) {
|
|
45
|
+
if (!envFile) throw new PasswordSourceError("config_missing", "The binding names no password_env_file.");
|
|
46
|
+
const path = expandHome(envFile, home);
|
|
47
|
+
return async function passwordFor({ persona }) {
|
|
48
|
+
const entry = Object.values(personas ?? {}).find((candidate) => candidate.email === persona);
|
|
49
|
+
if (!entry) throw new PasswordSourceError("persona_not_allowed", `No password_env is configured for ${persona}.`);
|
|
50
|
+
let info;
|
|
51
|
+
try {
|
|
52
|
+
info = stat(path);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new PasswordSourceError("password_source_missing", `Password env file ${path} is missing (${error?.code ?? "error"}); the human restores it (never commit it).`);
|
|
55
|
+
}
|
|
56
|
+
if (!info.isFile()) throw new PasswordSourceError("password_source_invalid", `Password env file ${path} is not a regular file.`);
|
|
57
|
+
if ((info.mode & 0o077) !== 0) throw new PasswordSourceError("password_source_permissions", `Password env file ${path} is readable by group/others; the human runs chmod 600 on it.`);
|
|
58
|
+
if (uid !== null && info.uid !== uid) throw new PasswordSourceError("password_source_permissions", `Password env file ${path} is not owned by this user.`);
|
|
59
|
+
const value = parseEnvFile(read(path, "utf8"))[entry.passwordEnv];
|
|
60
|
+
if (typeof value !== "string" || value.length < 8) {
|
|
61
|
+
throw new PasswordSourceError("password_source_missing", `Password env file ${path} has no ${entry.passwordEnv} value.`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// BOT-2084 (lifted from supplyspark-ent ENT-5120 scripts/staging-session-pool/policy.mjs):
|
|
2
|
+
// who may mint, against what, for whom.
|
|
3
|
+
//
|
|
4
|
+
// Node-stdlib-only and self-contained (the human-installed job runs a copy of
|
|
5
|
+
// this directory). The mint target and persona allowlist come ONLY from the
|
|
6
|
+
// repository's committed `.botbuddy-agent.json` `staging_session_pool` binding
|
|
7
|
+
// (binding.mjs): no flag, environment variable or pool-home file can add a mint
|
|
8
|
+
// host or persona. The staging target must be https, not loopback, and not a
|
|
9
|
+
// production origin; loopback ("local") targets exist for local proofs/tests.
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
export class MintRefusedError extends Error {
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "MintRefusedError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Production origins refused for every repo, whatever its binding says. A binding
|
|
22
|
+
// adds its own product's production origins on top (production_origins).
|
|
23
|
+
export const BUILTIN_PRODUCTION_ORIGINS = Object.freeze([
|
|
24
|
+
"https://app.bot-buddy.ai",
|
|
25
|
+
"https://api.bot-buddy.ai",
|
|
26
|
+
"https://bot-buddy.ai",
|
|
27
|
+
"https://xymjddoobrfnsmawmtbc.supabase.co",
|
|
28
|
+
"https://app.supply-guard.io",
|
|
29
|
+
"https://supply-guard.io",
|
|
30
|
+
"https://enterprise.vercel.app",
|
|
31
|
+
"https://hhukfannuankqtswiqdq.supabase.co",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
35
|
+
|
|
36
|
+
export function isLoopbackHostname(hostname) {
|
|
37
|
+
return LOOPBACK_HOSTNAMES.has(String(hostname ?? "").toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseOrigin(value, label) {
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = new URL(String(value));
|
|
44
|
+
} catch {
|
|
45
|
+
throw new MintRefusedError("origin_not_allowed", `${label} is not a valid URL.`);
|
|
46
|
+
}
|
|
47
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
48
|
+
throw new MintRefusedError("origin_not_allowed", `${label} must be http(s).`);
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The staging target, frozen from the binding. Every field is checked here, not
|
|
54
|
+
// only when the binding is parsed, so a tampered pool-home copy is refused too.
|
|
55
|
+
export function stagingTargetFromBinding(binding) {
|
|
56
|
+
if (!binding || typeof binding !== "object") throw new MintRefusedError("config_missing", "No staging_session_pool binding.");
|
|
57
|
+
const production = new Set([...BUILTIN_PRODUCTION_ORIGINS, ...(binding.productionOrigins ?? [])]);
|
|
58
|
+
const app = parseOrigin(binding.appOrigin, "App origin");
|
|
59
|
+
const supabase = parseOrigin(binding.supabaseUrl, "Supabase URL");
|
|
60
|
+
for (const [parsed, label] of [[app, "App origin"], [supabase, "Supabase URL"]]) {
|
|
61
|
+
if (parsed.protocol !== "https:") throw new MintRefusedError("origin_not_allowed", `${label} ${parsed.origin} must be https for the staging target.`);
|
|
62
|
+
if (isLoopbackHostname(parsed.hostname)) throw new MintRefusedError("origin_not_allowed", `${label} ${parsed.origin} is loopback; use the local target.`);
|
|
63
|
+
if (production.has(parsed.origin)) throw new MintRefusedError("origin_not_allowed", `${label} ${parsed.origin} is a production origin and never a mint target.`);
|
|
64
|
+
if (parsed.origin !== String(label === "App origin" ? binding.appOrigin : binding.supabaseUrl).replace(/\/$/, "")) {
|
|
65
|
+
throw new MintRefusedError("origin_not_allowed", `${label} must be a bare origin.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
name: "staging",
|
|
70
|
+
appOrigin: app.origin,
|
|
71
|
+
supabaseUrl: supabase.origin,
|
|
72
|
+
publishableKey: binding.publishableKey,
|
|
73
|
+
storageKey: binding.storageKey,
|
|
74
|
+
totpEnvironment: binding.environment,
|
|
75
|
+
appSlug: binding.appSlug,
|
|
76
|
+
tenant: binding.tenant,
|
|
77
|
+
personaEmails: Object.freeze(Object.values(binding.personas).map((persona) => persona.email)),
|
|
78
|
+
loopback: false,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Mirrors supabase-js's default storage key for a loopback Supabase URL.
|
|
83
|
+
function loopbackStorageKey(parsed) {
|
|
84
|
+
const host = parsed.hostname.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
85
|
+
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
86
|
+
return `sb-${host}-${port}-auth-token`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function localTarget({ appOrigin, supabaseUrl, publishableKey, personaEmails, storageKey } = {}) {
|
|
90
|
+
if (!appOrigin || !supabaseUrl || !publishableKey) {
|
|
91
|
+
throw new MintRefusedError("config_missing", "A local target needs --app-origin, --supabase-url and --publishable-key (all loopback).");
|
|
92
|
+
}
|
|
93
|
+
const app = parseOrigin(appOrigin, "App origin");
|
|
94
|
+
const supabase = parseOrigin(supabaseUrl, "Supabase URL");
|
|
95
|
+
for (const [parsed, label] of [[app, "App origin"], [supabase, "Supabase URL"]]) {
|
|
96
|
+
if (!isLoopbackHostname(parsed.hostname)) throw new MintRefusedError("origin_not_allowed", `${label} ${parsed.origin} is not loopback.`);
|
|
97
|
+
}
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
name: "local",
|
|
100
|
+
appOrigin: app.origin,
|
|
101
|
+
supabaseUrl: supabase.origin,
|
|
102
|
+
publishableKey: String(publishableKey),
|
|
103
|
+
storageKey: storageKey ?? loopbackStorageKey(supabase),
|
|
104
|
+
totpEnvironment: "local",
|
|
105
|
+
personaEmails: Object.freeze([...(personaEmails ?? [])]),
|
|
106
|
+
loopback: true,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function assertPersonaAllowed(target, email) {
|
|
111
|
+
const value = typeof email === "string" ? email : "";
|
|
112
|
+
if (!target?.personaEmails?.includes(value)) {
|
|
113
|
+
throw new MintRefusedError(
|
|
114
|
+
"persona_not_allowed",
|
|
115
|
+
`Persona "${value}" is not an allowlisted seeded persona for ${target?.appOrigin ?? "this pool"}. Allowed: ${(target?.personaEmails ?? []).join(", ")}.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Claude Code and Codex set these in every tool subprocess.
|
|
122
|
+
export const AGENT_CONTEXT_ENV_MARKERS = Object.freeze([
|
|
123
|
+
"CLAUDECODE",
|
|
124
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
125
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
126
|
+
"CODEX_SANDBOX",
|
|
127
|
+
"CODEX_SANDBOX_NETWORK_DISABLED",
|
|
128
|
+
"CODEX_THREAD_ID",
|
|
129
|
+
"CODEX_CI",
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
// An agent can scrub its env, but not its parent processes: a mint launched from
|
|
133
|
+
// a Claude/Codex app or CLI has that binary in its ancestry.
|
|
134
|
+
const AGENT_ANCESTOR_PATTERN = /(^|\/)(claude|codex)(\.app\/|$)|\/(claude|codex)\.app\//i;
|
|
135
|
+
|
|
136
|
+
export function listProcessAncestors({ pid = process.pid, exec = execFileSync } = {}) {
|
|
137
|
+
const table = String(exec("/bin/ps", ["-A", "-o", "pid=,ppid=,comm="], { encoding: "utf8" }));
|
|
138
|
+
const byPid = new Map();
|
|
139
|
+
for (const line of table.split("\n")) {
|
|
140
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
141
|
+
if (match) byPid.set(Number(match[1]), { ppid: Number(match[2]), comm: match[3].trim() });
|
|
142
|
+
}
|
|
143
|
+
const ancestors = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
let current = byPid.get(pid)?.ppid;
|
|
146
|
+
while (current && current > 1 && !seen.has(current)) {
|
|
147
|
+
seen.add(current);
|
|
148
|
+
const entry = byPid.get(current);
|
|
149
|
+
if (!entry) break;
|
|
150
|
+
ancestors.push(entry.comm);
|
|
151
|
+
current = entry.ppid;
|
|
152
|
+
}
|
|
153
|
+
return ancestors;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function detectAgentContext({ env = process.env, listAncestors = listProcessAncestors } = {}) {
|
|
157
|
+
const markers = AGENT_CONTEXT_ENV_MARKERS.filter((name) => Object.hasOwn(env, name)).map((name) => `env:${name}`);
|
|
158
|
+
for (const comm of listAncestors()) {
|
|
159
|
+
if (AGENT_ANCESTOR_PATTERN.test(comm)) markers.push(`ancestor:${comm.split("/").pop()}`);
|
|
160
|
+
}
|
|
161
|
+
return markers;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Mint, tick, install and uninstall are human/job-only for a non-loopback target.
|
|
165
|
+
// A heuristic, not a security boundary: it stops accidental agent mints.
|
|
166
|
+
export function assertMintAllowed({ target, env = process.env, listAncestors = listProcessAncestors }) {
|
|
167
|
+
if (target.loopback) return;
|
|
168
|
+
let markers;
|
|
169
|
+
try {
|
|
170
|
+
markers = detectAgentContext({ env, listAncestors });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new MintRefusedError(
|
|
173
|
+
"agent_context_unknown",
|
|
174
|
+
`Could not establish that this is not an agent session (${error?.message ?? error}); refusing a ${target.name} mint.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (markers.length > 0) {
|
|
178
|
+
const error = new MintRefusedError(
|
|
179
|
+
"agent_context",
|
|
180
|
+
`Refusing a ${target.name} mint from an agent session (${markers.join(", ")}). Agents never sign in to ${target.appOrigin}: ` +
|
|
181
|
+
"report this blocker and its remedy once, then stop; ask the human to run the pool job.",
|
|
182
|
+
);
|
|
183
|
+
error.markers = markers;
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|