@owloops/browserbird 1.7.1 → 1.8.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/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { parseArgs, styleText } from "node:util";
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { dirname, extname, join, resolve } from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
- import { createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
6
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
7
7
  import { connect } from "node:net";
8
8
  import { execFileSync, spawn } from "node:child_process";
9
9
  import { createServer } from "node:http";
@@ -15,6 +15,7 @@ import { LogLevel, WebClient } from "@slack/web-api";
15
15
  const COMMANDS = {
16
16
  SESSIONS: "sessions",
17
17
  BIRDS: "birds",
18
+ KEYS: "keys",
18
19
  CONFIG: "config",
19
20
  LOGS: "logs",
20
21
  JOBS: "jobs",
@@ -192,8 +193,8 @@ function unknownSubcommand(subcommand, command, validCommands) {
192
193
  /** @fileoverview ASCII banner displayed on daemon startup and in help text. */
193
194
  const pkg = createRequire(import.meta.url)("../package.json");
194
195
  const buildInfo = [];
195
- buildInfo.push(`commit: ${"15ead4a5a3d42d49a5b1c8a7bb3b4bc34d32c539".substring(0, 7)}`);
196
- buildInfo.push(`built: 2026-03-20T00:08:17+04:00`);
196
+ buildInfo.push(`commit: ${"2c657bcf3aa7f81dcfc993a561bed0f74cacffcf".substring(0, 7)}`);
197
+ buildInfo.push(`built: 2026-03-22T02:02:21+04:00`);
197
198
  const buildString = buildInfo.length > 0 ? ` (${buildInfo.join(", ")})` : "";
198
199
  const VERSION = `browserbird ${pkg.version}${buildString}`;
199
200
  const BIRD = [
@@ -644,6 +645,31 @@ const MIGRATIONS = [
644
645
  );
645
646
  CREATE INDEX IF NOT EXISTS idx_feedback_channel_thread
646
647
  ON feedback(channel_id, thread_id);
648
+ `);
649
+ }
650
+ },
651
+ {
652
+ name: "keys vault",
653
+ up(d) {
654
+ d.exec(`
655
+ CREATE TABLE IF NOT EXISTS keys (
656
+ uid TEXT PRIMARY KEY,
657
+ name TEXT NOT NULL UNIQUE,
658
+ value TEXT NOT NULL,
659
+ description TEXT,
660
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
661
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
662
+ );
663
+
664
+ CREATE TABLE IF NOT EXISTS key_bindings (
665
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
666
+ key_uid TEXT NOT NULL REFERENCES keys(uid) ON DELETE CASCADE,
667
+ target_type TEXT NOT NULL CHECK(target_type IN ('channel', 'bird')),
668
+ target_id TEXT NOT NULL,
669
+ UNIQUE(key_uid, target_type, target_id)
670
+ );
671
+ CREATE INDEX IF NOT EXISTS idx_key_bindings_target
672
+ ON key_bindings(target_type, target_id);
647
673
  `);
648
674
  }
649
675
  }
@@ -790,6 +816,7 @@ const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
790
816
  const UID_PREFIX = {
791
817
  bird: "br_",
792
818
  flight: "fl_",
819
+ key: "ky_",
793
820
  session: "ss_"
794
821
  };
795
822
  function generateUid(prefix) {
@@ -1339,6 +1366,182 @@ function insertFeedback(channelId, threadId, messageTs, userId, value) {
1339
1366
  VALUES (?, ?, ?, ?, ?)`).run(channelId, threadId ?? null, messageTs ?? null, userId, value);
1340
1367
  }
1341
1368
 
1369
+ //#endregion
1370
+ //#region src/core/crypto.ts
1371
+ /** @fileoverview AES-256-GCM encryption for vault key values. */
1372
+ const ALGORITHM = "aes-256-gcm";
1373
+ const IV_BYTES = 12;
1374
+ const ENC_PREFIX = "enc$";
1375
+ const VAULT_KEY_ENV = "BROWSERBIRD_VAULT_KEY";
1376
+ function encrypt(plaintext, keyHex) {
1377
+ const key = Buffer.from(keyHex, "hex");
1378
+ const iv = randomBytes(IV_BYTES);
1379
+ const cipher = createCipheriv(ALGORITHM, key, iv);
1380
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
1381
+ const tag = cipher.getAuthTag();
1382
+ return `${ENC_PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
1383
+ }
1384
+ function decrypt(encoded, keyHex) {
1385
+ if (!encoded.startsWith(ENC_PREFIX)) return encoded;
1386
+ const parts = encoded.slice(4).split(":");
1387
+ if (parts.length !== 3) throw new Error("malformed encrypted value");
1388
+ const iv = Buffer.from(parts[0], "base64");
1389
+ const tag = Buffer.from(parts[1], "base64");
1390
+ const ciphertext = Buffer.from(parts[2], "base64");
1391
+ const decipher = createDecipheriv(ALGORITHM, Buffer.from(keyHex, "hex"), iv);
1392
+ decipher.setAuthTag(tag);
1393
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
1394
+ }
1395
+ function isEncrypted(value) {
1396
+ return value.startsWith(ENC_PREFIX);
1397
+ }
1398
+ function generateVaultKey() {
1399
+ return randomBytes(32).toString("hex");
1400
+ }
1401
+ function getVaultKey() {
1402
+ const key = process.env[VAULT_KEY_ENV];
1403
+ if (!key) throw new Error(`${VAULT_KEY_ENV} not set. Run the daemon to auto-generate it, or set it manually.`);
1404
+ return key;
1405
+ }
1406
+ function ensureVaultKey(envPath) {
1407
+ if (process.env[VAULT_KEY_ENV]) return;
1408
+ const key = generateVaultKey();
1409
+ saveEnvFile(envPath, { [VAULT_KEY_ENV]: key });
1410
+ process.env[VAULT_KEY_ENV] = key;
1411
+ logger.info("generated vault encryption key");
1412
+ }
1413
+
1414
+ //#endregion
1415
+ //#region src/db/keys.ts
1416
+ function decryptValue(raw) {
1417
+ return isEncrypted(raw) ? decrypt(raw, getVaultKey()) : raw;
1418
+ }
1419
+ function computeHint(raw) {
1420
+ const value = decryptValue(raw);
1421
+ if (value.length <= 8) return "****";
1422
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
1423
+ }
1424
+ const KEY_SORT_COLUMNS = new Set([
1425
+ "uid",
1426
+ "name",
1427
+ "created_at",
1428
+ "updated_at"
1429
+ ]);
1430
+ const KEY_SEARCH_COLUMNS = [
1431
+ "uid",
1432
+ "name",
1433
+ "description"
1434
+ ];
1435
+ function loadBindingsMap() {
1436
+ const bindings = getDb().prepare("SELECT * FROM key_bindings ORDER BY key_uid").all();
1437
+ const map = /* @__PURE__ */ new Map();
1438
+ for (const b of bindings) {
1439
+ let arr = map.get(b.key_uid);
1440
+ if (!arr) {
1441
+ arr = [];
1442
+ map.set(b.key_uid, arr);
1443
+ }
1444
+ arr.push({
1445
+ targetType: b.target_type,
1446
+ targetId: b.target_id
1447
+ });
1448
+ }
1449
+ return map;
1450
+ }
1451
+ function enrichKeys(result) {
1452
+ const bindingsByKey = loadBindingsMap();
1453
+ return {
1454
+ ...result,
1455
+ items: result.items.map((row) => ({
1456
+ uid: row.uid,
1457
+ name: row.name,
1458
+ hint: computeHint(row.value),
1459
+ description: row.description,
1460
+ created_at: row.created_at,
1461
+ updated_at: row.updated_at,
1462
+ bindings: bindingsByKey.get(row.uid) ?? []
1463
+ }))
1464
+ };
1465
+ }
1466
+ function listKeys(page = 1, perPage = DEFAULT_PER_PAGE, sort, search) {
1467
+ return enrichKeys(paginate("keys", page, perPage, {
1468
+ defaultSort: "name ASC",
1469
+ sort,
1470
+ search,
1471
+ allowedSortColumns: KEY_SORT_COLUMNS,
1472
+ searchColumns: KEY_SEARCH_COLUMNS
1473
+ }));
1474
+ }
1475
+ function getKey(uid) {
1476
+ return getDb().prepare("SELECT * FROM keys WHERE uid = ?").get(uid);
1477
+ }
1478
+ function createKey(name, value, description) {
1479
+ const d = getDb();
1480
+ const uid = generateUid(UID_PREFIX.key);
1481
+ const encrypted = encrypt(value, getVaultKey());
1482
+ d.prepare("INSERT INTO keys (uid, name, value, description) VALUES (?, ?, ?, ?)").run(uid, name, encrypted, description ?? null);
1483
+ return getKey(uid);
1484
+ }
1485
+ function updateKey(uid, fields) {
1486
+ const d = getDb();
1487
+ const sets = [];
1488
+ const params = [];
1489
+ if (fields.name !== void 0) {
1490
+ sets.push("name = ?");
1491
+ params.push(fields.name);
1492
+ }
1493
+ if (fields.value !== void 0) {
1494
+ sets.push("value = ?");
1495
+ params.push(encrypt(fields.value, getVaultKey()));
1496
+ }
1497
+ if (fields.description !== void 0) {
1498
+ sets.push("description = ?");
1499
+ params.push(fields.description || null);
1500
+ }
1501
+ if (sets.length === 0) return getKey(uid);
1502
+ sets.push("updated_at = datetime('now')");
1503
+ params.push(uid);
1504
+ if (d.prepare(`UPDATE keys SET ${sets.join(", ")} WHERE uid = ?`).run(...params).changes === 0) return void 0;
1505
+ return getKey(uid);
1506
+ }
1507
+ function deleteKey(uid) {
1508
+ return getDb().prepare("DELETE FROM keys WHERE uid = ?").run(uid).changes > 0;
1509
+ }
1510
+ function replaceBindings(keyUid, bindings) {
1511
+ transaction(() => {
1512
+ const d = getDb();
1513
+ d.prepare("DELETE FROM key_bindings WHERE key_uid = ?").run(keyUid);
1514
+ const stmt = d.prepare("INSERT INTO key_bindings (key_uid, target_type, target_id) VALUES (?, ?, ?)");
1515
+ for (const b of bindings) stmt.run(keyUid, b.targetType, b.targetId);
1516
+ });
1517
+ }
1518
+ function getKeysForTarget(targetType, targetId) {
1519
+ return getDb().prepare(`SELECT k.name, k.value FROM keys k
1520
+ JOIN key_bindings kb ON kb.key_uid = k.uid
1521
+ WHERE kb.target_type = ? AND (kb.target_id = ? OR kb.target_id = '*')`).all(targetType, targetId).map((r) => ({
1522
+ name: r.name,
1523
+ value: decryptValue(r.value)
1524
+ }));
1525
+ }
1526
+ /** Resolves keys for one or more targets into a flat env map. Later targets override earlier ones. */
1527
+ function resolveExtraEnv(targets) {
1528
+ const env = {};
1529
+ for (const t of targets) for (const k of getKeysForTarget(t.type, t.id)) env[k.name] = k.value;
1530
+ return Object.keys(env).length > 0 ? env : void 0;
1531
+ }
1532
+ function getAllKeyValues() {
1533
+ return getDb().prepare("SELECT value FROM keys").all().map((r) => decryptValue(r.value));
1534
+ }
1535
+ function migrateUnencryptedKeys() {
1536
+ const d = getDb();
1537
+ const rows = d.prepare("SELECT uid, value FROM keys").all();
1538
+ const vaultKey = getVaultKey();
1539
+ for (const row of rows) if (!isEncrypted(row.value)) {
1540
+ const encrypted = encrypt(row.value, vaultKey);
1541
+ d.prepare("UPDATE keys SET value = ? WHERE uid = ?").run(encrypted, row.uid);
1542
+ }
1543
+ }
1544
+
1342
1545
  //#endregion
1343
1546
  //#region src/db/path.ts
1344
1547
  /** @fileoverview Database path resolution: CLI flag, env var, or default. */
@@ -1363,6 +1566,67 @@ function resolveDbPathFromArgv(argv) {
1363
1566
  return resolveDbPath();
1364
1567
  }
1365
1568
 
1569
+ //#endregion
1570
+ //#region src/core/redact.ts
1571
+ /** @fileoverview Output redaction: scrubs known secrets and token patterns from agent output. */
1572
+ const REDACTED = "[redacted]";
1573
+ const ENV_SECRET_MIN_LENGTH = 8;
1574
+ const VAULT_SECRET_MIN_LENGTH = 4;
1575
+ const SENSITIVE_NAME_RE$1 = /KEY|SECRET|TOKEN|PASSWORD/i;
1576
+ /**
1577
+ * Token prefix patterns. Each entry is [prefix, minLength] where minLength
1578
+ * is the shortest plausible token including the prefix (avoids false positives
1579
+ * on short strings that happen to start with a prefix).
1580
+ */
1581
+ const TOKEN_PATTERNS = [
1582
+ ["xoxb-", 20],
1583
+ ["xapp-", 20],
1584
+ ["sk-ant-api", 20],
1585
+ ["sk-ant-oat", 20],
1586
+ ["sk-or-", 20]
1587
+ ];
1588
+ let knownSecrets;
1589
+ function collectSecrets() {
1590
+ const secrets = [];
1591
+ for (const [name, value] of Object.entries(process.env)) if (value && SENSITIVE_NAME_RE$1.test(name) && value.length >= ENV_SECRET_MIN_LENGTH) secrets.push(value);
1592
+ secrets.sort((a, b) => b.length - a.length);
1593
+ return secrets;
1594
+ }
1595
+ function getSecrets() {
1596
+ if (!knownSecrets) knownSecrets = collectSecrets();
1597
+ return knownSecrets;
1598
+ }
1599
+ /** Merges additional secret values (e.g. vault keys) into the redactor. Additive only. */
1600
+ function addSecrets(values, minLength = ENV_SECRET_MIN_LENGTH) {
1601
+ const current = getSecrets();
1602
+ const existing = new Set(current);
1603
+ let added = false;
1604
+ for (const v of values) if (v.length >= minLength && !existing.has(v)) {
1605
+ current.push(v);
1606
+ existing.add(v);
1607
+ added = true;
1608
+ }
1609
+ if (added) current.sort((a, b) => b.length - a.length);
1610
+ }
1611
+ function escapeForRegex(s) {
1612
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1613
+ }
1614
+ function buildPatternRegex() {
1615
+ const parts = TOKEN_PATTERNS.map(([prefix, minLength]) => {
1616
+ return `${escapeForRegex(prefix)}[A-Za-z0-9_\\-]{${minLength - prefix.length},}`;
1617
+ });
1618
+ return new RegExp(parts.join("|"), "g");
1619
+ }
1620
+ const patternRegex = buildPatternRegex();
1621
+ /** Replaces known secret values and token patterns in text with [redacted]. */
1622
+ function redact(text) {
1623
+ if (!text) return text;
1624
+ let result = text;
1625
+ for (const secret of getSecrets()) if (result.includes(secret)) result = result.replaceAll(secret, REDACTED);
1626
+ result = result.replace(patternRegex, REDACTED);
1627
+ return result;
1628
+ }
1629
+
1366
1630
  //#endregion
1367
1631
  //#region src/server/auth.ts
1368
1632
  /** @fileoverview Password hashing, token signing, and verification using node:crypto. */
@@ -1855,6 +2119,61 @@ function maskSecret(value) {
1855
2119
  hint: prefix ? `${prefix}...${tail}` : `...${tail}`
1856
2120
  };
1857
2121
  }
2122
+ const KEY_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
2123
+ const BLOCKED_KEY_PREFIXES = [
2124
+ "LD_",
2125
+ "DYLD_",
2126
+ "NODE_",
2127
+ "NPM_",
2128
+ "GIT_",
2129
+ "PYTHON",
2130
+ "RUBY",
2131
+ "PERL",
2132
+ "JAVA_",
2133
+ "CLAUDE_",
2134
+ "BROWSERBIRD_"
2135
+ ];
2136
+ const BLOCKED_KEY_NAMES = new Set([
2137
+ "ANTHROPIC_API_KEY",
2138
+ "CLAUDECODE",
2139
+ "SLACK_BOT_TOKEN",
2140
+ "SLACK_APP_TOKEN",
2141
+ "PATH",
2142
+ "HOME",
2143
+ "SHELL",
2144
+ "USER",
2145
+ "LOGNAME",
2146
+ "TERM",
2147
+ "IFS",
2148
+ "CDPATH",
2149
+ "CPATH",
2150
+ "CPPATH",
2151
+ "LIBRARY_PATH",
2152
+ "TMPDIR",
2153
+ "TZDIR",
2154
+ "GCONV_PATH",
2155
+ "HOSTALIASES",
2156
+ "MALLOC_TRACE",
2157
+ "RESOLV_HOST_CONF",
2158
+ "HTTP_PROXY",
2159
+ "HTTPS_PROXY",
2160
+ "ALL_PROXY",
2161
+ "NO_PROXY",
2162
+ "CURL_CA_BUNDLE",
2163
+ "SSL_CERT_FILE",
2164
+ "SSL_CERT_DIR",
2165
+ "REQUESTS_CA_BUNDLE"
2166
+ ]);
2167
+ function isDangerousKeyName(name) {
2168
+ if (BLOCKED_KEY_NAMES.has(name)) return true;
2169
+ return BLOCKED_KEY_PREFIXES.some((p) => name.startsWith(p));
2170
+ }
2171
+ function validateKeyName(raw) {
2172
+ const name = raw.trim().toUpperCase();
2173
+ if (!KEY_NAME_RE.test(name)) return { error: "Name must match [A-Z][A-Z0-9_]* (e.g. GITHUB_TOKEN)" };
2174
+ if (isDangerousKeyName(name)) return { error: `"${name}" is a reserved or dangerous name` };
2175
+ return { name };
2176
+ }
1858
2177
  const HH_MM_RE = /^\d{2}:\d{2}$/;
1859
2178
  const ALLOWED_TOP_LEVEL_KEYS = new Set([
1860
2179
  "timezone",
@@ -2723,6 +3042,154 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
2723
3042
  jsonError(res, `Launch failed: ${err instanceof Error ? err.message : String(err)}`, 500);
2724
3043
  }
2725
3044
  }
3045
+ },
3046
+ {
3047
+ method: "GET",
3048
+ pattern: pathToRegex("/api/keys"),
3049
+ handler(req, res) {
3050
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
3051
+ const { page, perPage } = parsePagination(url);
3052
+ json(res, listKeys(page, perPage, parseSortParam(url), parseSearchParam(url)));
3053
+ }
3054
+ },
3055
+ {
3056
+ method: "POST",
3057
+ pattern: pathToRegex("/api/keys"),
3058
+ async handler(req, res) {
3059
+ let body;
3060
+ try {
3061
+ body = await readJsonBody(req);
3062
+ } catch {
3063
+ jsonError(res, "Invalid JSON body", 400);
3064
+ return;
3065
+ }
3066
+ if (!body.name || typeof body.name !== "string" || !body.name.trim()) {
3067
+ jsonError(res, "\"name\" is required", 400);
3068
+ return;
3069
+ }
3070
+ if (!body.value || typeof body.value !== "string") {
3071
+ jsonError(res, "\"value\" is required", 400);
3072
+ return;
3073
+ }
3074
+ const validated = validateKeyName(body.name);
3075
+ if ("error" in validated) {
3076
+ jsonError(res, validated.error, 400);
3077
+ return;
3078
+ }
3079
+ const { name } = validated;
3080
+ try {
3081
+ const key = createKey(name, body.value, body.description?.trim());
3082
+ addSecrets([body.value], VAULT_SECRET_MIN_LENGTH);
3083
+ if (body.bindings && body.bindings.length > 0) replaceBindings(key.uid, body.bindings);
3084
+ broadcastSSE("invalidate", { resource: "keys" });
3085
+ json(res, { uid: key.uid }, 201);
3086
+ } catch (err) {
3087
+ const msg = err instanceof Error ? err.message : String(err);
3088
+ if (msg.includes("UNIQUE constraint")) jsonError(res, `A key named "${name}" already exists`, 409);
3089
+ else jsonError(res, msg, 500);
3090
+ }
3091
+ }
3092
+ },
3093
+ {
3094
+ method: "PATCH",
3095
+ pattern: pathToRegex("/api/keys/:id"),
3096
+ async handler(req, res, params) {
3097
+ const uid = params["id"];
3098
+ if (!uid) {
3099
+ jsonError(res, "Missing key ID", 400);
3100
+ return;
3101
+ }
3102
+ if (!getKey(uid)) {
3103
+ jsonError(res, `Key ${uid} not found`, 404);
3104
+ return;
3105
+ }
3106
+ let body;
3107
+ try {
3108
+ body = await readJsonBody(req);
3109
+ } catch {
3110
+ jsonError(res, "Invalid JSON body", 400);
3111
+ return;
3112
+ }
3113
+ const fields = {};
3114
+ if (body.name !== void 0) {
3115
+ const validated = validateKeyName(body.name);
3116
+ if ("error" in validated) {
3117
+ jsonError(res, validated.error, 400);
3118
+ return;
3119
+ }
3120
+ fields.name = validated.name;
3121
+ }
3122
+ if (body.value !== void 0 && body.value !== "") fields.value = body.value;
3123
+ if (body.description !== void 0) fields.description = body.description;
3124
+ try {
3125
+ const updated = updateKey(uid, fields);
3126
+ if (!updated) {
3127
+ jsonError(res, `Key ${uid} not found`, 404);
3128
+ return;
3129
+ }
3130
+ if (fields.value) addSecrets([fields.value], VAULT_SECRET_MIN_LENGTH);
3131
+ broadcastSSE("invalidate", { resource: "keys" });
3132
+ json(res, { uid: updated.uid });
3133
+ } catch (err) {
3134
+ const msg = err instanceof Error ? err.message : String(err);
3135
+ if (msg.includes("UNIQUE constraint")) jsonError(res, `A key named "${fields.name}" already exists`, 409);
3136
+ else jsonError(res, msg, 500);
3137
+ }
3138
+ }
3139
+ },
3140
+ {
3141
+ method: "DELETE",
3142
+ pattern: pathToRegex("/api/keys/:id"),
3143
+ handler(_req, res, params) {
3144
+ const uid = params["id"];
3145
+ if (!uid) {
3146
+ jsonError(res, "Missing key ID", 400);
3147
+ return;
3148
+ }
3149
+ if (deleteKey(uid)) {
3150
+ broadcastSSE("invalidate", { resource: "keys" });
3151
+ json(res, { success: true });
3152
+ } else jsonError(res, `Key ${uid} not found`, 404);
3153
+ }
3154
+ },
3155
+ {
3156
+ method: "PUT",
3157
+ pattern: pathToRegex("/api/keys/:id/bindings"),
3158
+ async handler(req, res, params) {
3159
+ const uid = params["id"];
3160
+ if (!uid) {
3161
+ jsonError(res, "Missing key ID", 400);
3162
+ return;
3163
+ }
3164
+ if (!getKey(uid)) {
3165
+ jsonError(res, `Key ${uid} not found`, 404);
3166
+ return;
3167
+ }
3168
+ let body;
3169
+ try {
3170
+ body = await readJsonBody(req);
3171
+ } catch {
3172
+ jsonError(res, "Invalid JSON body", 400);
3173
+ return;
3174
+ }
3175
+ if (!Array.isArray(body)) {
3176
+ jsonError(res, "Body must be an array of bindings", 400);
3177
+ return;
3178
+ }
3179
+ for (const b of body) {
3180
+ if (b.targetType !== "channel" && b.targetType !== "bird") {
3181
+ jsonError(res, "\"targetType\" must be \"channel\" or \"bird\"", 400);
3182
+ return;
3183
+ }
3184
+ if (!b.targetId || typeof b.targetId !== "string") {
3185
+ jsonError(res, "\"targetId\" is required", 400);
3186
+ return;
3187
+ }
3188
+ }
3189
+ replaceBindings(uid, body);
3190
+ broadcastSSE("invalidate", { resource: "keys" });
3191
+ json(res, { success: true });
3192
+ }
2726
3193
  }
2727
3194
  ];
2728
3195
  }
@@ -3322,13 +3789,13 @@ function gracefulKill(proc) {
3322
3789
  /** Env vars that prevent nested Claude Code sessions. */
3323
3790
  const STRIPPED_ENV_VARS = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"];
3324
3791
  /** Strips env vars whose names suggest they hold credentials. */
3325
- const SENSITIVE_NAME_RE$1 = /KEY|SECRET|TOKEN|PASSWORD/i;
3792
+ const SENSITIVE_NAME_RE = /KEY|SECRET|TOKEN|PASSWORD/i;
3326
3793
  function cleanEnv() {
3327
3794
  const env = {};
3328
3795
  for (const [key, value] of Object.entries(process.env)) {
3329
3796
  if (value == null) continue;
3330
3797
  if (STRIPPED_ENV_VARS.includes(key)) continue;
3331
- if (SENSITIVE_NAME_RE$1.test(key)) continue;
3798
+ if (SENSITIVE_NAME_RE.test(key)) continue;
3332
3799
  env[key] = value;
3333
3800
  }
3334
3801
  return env;
@@ -3344,6 +3811,7 @@ function spawnProvider(options, signal) {
3344
3811
  const baseEnv = cleanEnv();
3345
3812
  if (cmd.env) for (const [k, v] of Object.entries(cmd.env)) if (v === "") delete baseEnv[k];
3346
3813
  else baseEnv[k] = v;
3814
+ if (options.extraEnv) for (const [k, v] of Object.entries(options.extraEnv)) baseEnv[k] = v;
3347
3815
  const proc = spawn(cmd.binary, cmd.args, {
3348
3816
  cwd: cmd.cwd ?? process.cwd(),
3349
3817
  stdio: [
@@ -3425,53 +3893,6 @@ async function* parseStdout(proc, buffer, setBuffer) {
3425
3893
  };
3426
3894
  }
3427
3895
 
3428
- //#endregion
3429
- //#region src/core/redact.ts
3430
- /** @fileoverview Output redaction: scrubs known secrets and token patterns from agent output. */
3431
- const REDACTED = "[redacted]";
3432
- const SENSITIVE_NAME_RE = /KEY|SECRET|TOKEN|PASSWORD/i;
3433
- /**
3434
- * Token prefix patterns. Each entry is [prefix, minLength] where minLength
3435
- * is the shortest plausible token including the prefix (avoids false positives
3436
- * on short strings that happen to start with a prefix).
3437
- */
3438
- const TOKEN_PATTERNS = [
3439
- ["xoxb-", 20],
3440
- ["xapp-", 20],
3441
- ["sk-ant-api", 20],
3442
- ["sk-ant-oat", 20],
3443
- ["sk-or-", 20]
3444
- ];
3445
- let knownSecrets;
3446
- function collectSecrets() {
3447
- const secrets = [];
3448
- for (const [name, value] of Object.entries(process.env)) if (value && SENSITIVE_NAME_RE.test(name) && value.length >= 8) secrets.push(value);
3449
- secrets.sort((a, b) => b.length - a.length);
3450
- return secrets;
3451
- }
3452
- function getSecrets() {
3453
- if (!knownSecrets) knownSecrets = collectSecrets();
3454
- return knownSecrets;
3455
- }
3456
- function escapeForRegex(s) {
3457
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3458
- }
3459
- function buildPatternRegex() {
3460
- const parts = TOKEN_PATTERNS.map(([prefix, minLength]) => {
3461
- return `${escapeForRegex(prefix)}[A-Za-z0-9_\\-]{${minLength - prefix.length},}`;
3462
- });
3463
- return new RegExp(parts.join("|"), "g");
3464
- }
3465
- const patternRegex = buildPatternRegex();
3466
- /** Replaces known secret values and token patterns in text with [redacted]. */
3467
- function redact(text) {
3468
- if (!text) return text;
3469
- let result = text;
3470
- for (const secret of getSecrets()) if (result.includes(secret)) result = result.replaceAll(secret, REDACTED);
3471
- result = result.replace(patternRegex, REDACTED);
3472
- return result;
3473
- }
3474
-
3475
3896
  //#endregion
3476
3897
  //#region src/channel/blocks.ts
3477
3898
  function plain(text) {
@@ -3844,12 +4265,23 @@ function startScheduler(getConfig, signal, deps) {
3844
4265
  const needsBrowserLock = config.browser.enabled && getBrowserMode() === "persistent";
3845
4266
  let browserLock = null;
3846
4267
  try {
4268
+ const targets = [];
4269
+ if (payload.channelId) targets.push({
4270
+ type: "channel",
4271
+ id: payload.channelId
4272
+ });
4273
+ targets.push({
4274
+ type: "bird",
4275
+ id: payload.cronJobUid
4276
+ });
4277
+ const extraEnv = resolveExtraEnv(targets);
3847
4278
  const { events, kill } = spawnProvider({
3848
4279
  message: payload.prompt,
3849
4280
  agent,
3850
4281
  mcpConfigPath: config.browser.mcpConfigPath,
3851
4282
  timezone: config.timezone,
3852
- globalTimeoutMs: config.sessions.processTimeoutMs
4283
+ globalTimeoutMs: config.sessions.processTimeoutMs,
4284
+ extraEnv
3853
4285
  }, signal);
3854
4286
  activeKills.set(payload.cronJobUid, kill);
3855
4287
  if (payload.channelId) logMessage(payload.channelId, null, agent.id, "in", payload.prompt);
@@ -4121,7 +4553,10 @@ function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId)
4121
4553
  await streamer.stop(opts);
4122
4554
  } catch (err) {
4123
4555
  if (isStreamExpired(err)) streamDead = true;
4124
- else throw err;
4556
+ else {
4557
+ await streamer.stop().catch(() => {});
4558
+ throw err;
4559
+ }
4125
4560
  }
4126
4561
  }
4127
4562
  let lastStatus = "is thinking...";
@@ -4129,118 +4564,121 @@ function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId)
4129
4564
  client.setStatus?.(channelId, threadTs, lastStatus).catch(() => {});
4130
4565
  };
4131
4566
  const statusTimer = setInterval(refreshStatus, STATUS_REFRESH_MS);
4132
- for await (const event of events) {
4133
- if (signal.aborted) break;
4134
- logger.debug(`stream event: ${event.type}`);
4135
- switch (event.type) {
4136
- case "init":
4137
- updateSessionProviderId(sessionUid, event.sessionId);
4138
- break;
4139
- case "text_delta": {
4140
- const safe = redact(event.delta);
4141
- fullText += safe;
4142
- await safeAppend({ markdown_text: safe });
4143
- break;
4144
- }
4145
- case "tool_images":
4146
- await uploadImages(event.images, channelId, threadTs);
4147
- break;
4148
- case "tool_use":
4149
- meta.onToolUse?.(event.toolName);
4150
- lastStatus = toolStatusText(event.toolName);
4151
- client.setStatus?.(channelId, threadTs, lastStatus).catch(() => {});
4152
- if (event.toolCallId !== void 0) {
4153
- toolCount++;
4154
- activeTasks.set(event.toolCallId, event.toolName);
4155
- const title = toolTaskTitle(event.toolName);
4156
- const chunks = [];
4157
- if (toolCount === 1) chunks.push({
4158
- type: "plan_update",
4159
- title: "Working on it"
4160
- });
4161
- chunks.push({
4162
- type: "task_update",
4163
- id: event.toolCallId,
4164
- title,
4165
- status: "in_progress",
4166
- ...event.details ? { details: event.details } : {}
4167
- });
4168
- await safeAppend({ chunks });
4567
+ try {
4568
+ for await (const event of events) {
4569
+ if (signal.aborted) break;
4570
+ logger.debug(`stream event: ${event.type}`);
4571
+ switch (event.type) {
4572
+ case "init":
4573
+ updateSessionProviderId(sessionUid, event.sessionId);
4574
+ break;
4575
+ case "text_delta": {
4576
+ const safe = redact(event.delta);
4577
+ fullText += safe;
4578
+ await safeAppend({ markdown_text: safe });
4579
+ break;
4169
4580
  }
4170
- break;
4171
- case "tool_result": {
4172
- const toolName = activeTasks.get(event.toolCallId);
4173
- if (toolName) {
4174
- activeTasks.delete(event.toolCallId);
4175
- const title = toolTaskTitle(toolName);
4176
- if (event.isError) toolErrors++;
4177
- else toolSuccesses++;
4178
- const chunks = [{
4179
- type: "task_update",
4180
- id: event.toolCallId,
4181
- title,
4182
- status: event.isError ? "error" : "complete",
4183
- ...event.output ? { output: event.output } : {}
4184
- }];
4185
- if (activeTasks.size === 0) {
4186
- const label = toolErrors === 0 ? `Completed (${toolCount} ${toolCount === 1 ? "step" : "steps"})` : `${toolSuccesses} passed, ${toolErrors} failed`;
4187
- chunks.push({
4581
+ case "tool_images":
4582
+ await uploadImages(event.images, channelId, threadTs);
4583
+ break;
4584
+ case "tool_use":
4585
+ meta.onToolUse?.(event.toolName);
4586
+ lastStatus = toolStatusText(event.toolName);
4587
+ client.setStatus?.(channelId, threadTs, lastStatus).catch(() => {});
4588
+ if (event.toolCallId !== void 0) {
4589
+ toolCount++;
4590
+ activeTasks.set(event.toolCallId, event.toolName);
4591
+ const title = toolTaskTitle(event.toolName);
4592
+ const chunks = [];
4593
+ if (toolCount === 1) chunks.push({
4188
4594
  type: "plan_update",
4189
- title: label
4595
+ title: "Working on it"
4190
4596
  });
4597
+ chunks.push({
4598
+ type: "task_update",
4599
+ id: event.toolCallId,
4600
+ title,
4601
+ status: "in_progress",
4602
+ ...event.details ? { details: redact(event.details) } : {}
4603
+ });
4604
+ await safeAppend({ chunks });
4605
+ }
4606
+ break;
4607
+ case "tool_result": {
4608
+ const toolName = activeTasks.get(event.toolCallId);
4609
+ if (toolName) {
4610
+ activeTasks.delete(event.toolCallId);
4611
+ const title = toolTaskTitle(toolName);
4612
+ if (event.isError) toolErrors++;
4613
+ else toolSuccesses++;
4614
+ const chunks = [{
4615
+ type: "task_update",
4616
+ id: event.toolCallId,
4617
+ title,
4618
+ status: event.isError ? "error" : "complete",
4619
+ ...event.output ? { output: redact(event.output) } : {}
4620
+ }];
4621
+ if (activeTasks.size === 0) {
4622
+ const label = toolErrors === 0 ? `Completed (${toolCount} ${toolCount === 1 ? "step" : "steps"})` : `${toolSuccesses} passed, ${toolErrors} failed`;
4623
+ chunks.push({
4624
+ type: "plan_update",
4625
+ title: label
4626
+ });
4627
+ }
4628
+ await safeAppend({ chunks });
4191
4629
  }
4192
- await safeAppend({ chunks });
4630
+ break;
4193
4631
  }
4194
- break;
4195
- }
4196
- case "completion":
4197
- completion = event;
4198
- logger.info(`completion [${event.subtype}]: ${event.tokensIn}in/${event.tokensOut}out, $${event.costUsd.toFixed(4)}, ${event.numTurns} turns`);
4199
- logMessage(channelId, threadTs, "bot", "out", fullText, event.tokensIn, event.tokensOut);
4200
- break;
4201
- case "rate_limit":
4202
- logger.debug(`rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
4203
- break;
4204
- case "error": {
4205
- hasError = true;
4206
- const safeError = redact(event.error);
4207
- logger.error(`agent error: ${safeError}`);
4208
- insertLog("error", "spawn", safeError, channelId);
4209
- await safeAppend({ markdown_text: `\n\nError: ${safeError}` });
4210
- break;
4632
+ case "completion":
4633
+ completion = event;
4634
+ logger.info(`completion [${event.subtype}]: ${event.tokensIn}in/${event.tokensOut}out, $${event.costUsd.toFixed(4)}, ${event.numTurns} turns`);
4635
+ logMessage(channelId, threadTs, "bot", "out", fullText, event.tokensIn, event.tokensOut);
4636
+ break;
4637
+ case "rate_limit":
4638
+ logger.debug(`rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
4639
+ break;
4640
+ case "error": {
4641
+ hasError = true;
4642
+ const safeError = redact(event.error);
4643
+ logger.error(`agent error: ${safeError}`);
4644
+ insertLog("error", "spawn", safeError, channelId);
4645
+ await safeAppend({ markdown_text: `\n\nError: ${safeError}` });
4646
+ break;
4647
+ }
4648
+ case "timeout":
4649
+ timedOut = true;
4650
+ timedOutMs = event.timeoutMs;
4651
+ logger.warn(`session timed out after ${event.timeoutMs}ms`);
4652
+ break;
4211
4653
  }
4212
- case "timeout":
4213
- timedOut = true;
4214
- timedOutMs = event.timeoutMs;
4215
- logger.warn(`session timed out after ${event.timeoutMs}ms`);
4216
- break;
4217
4654
  }
4218
- }
4219
- clearInterval(statusTimer);
4220
- client.setStatus?.(channelId, threadTs, "").catch(() => {});
4221
- if (activeTasks.size > 0) {
4222
- const staleChunks = [];
4223
- for (const [id, toolName] of activeTasks) staleChunks.push({
4224
- type: "task_update",
4225
- id,
4226
- title: toolTaskTitle(toolName),
4227
- status: "error"
4228
- });
4229
- await safeAppend({ chunks: staleChunks });
4230
- activeTasks.clear();
4231
- }
4232
- if (toolCount > 0 && (hasError || activeTasks.size > 0)) await safeAppend({ chunks: [{
4233
- type: "plan_update",
4234
- title: hasError ? "Finished with errors" : `Interrupted (${toolCount} ${toolCount === 1 ? "step" : "steps"})`
4235
- }] });
4236
- if (timedOut) {
4237
- await safeStop({});
4238
- const blocks = sessionTimeoutBlocks(timedOutMs, { sessionUid });
4239
- await client.postMessage(channelId, threadTs, `Session timed out after ${Math.round(timedOutMs / 6e4)} minutes.`, { blocks });
4240
- } else if (completion) await safeStop({ blocks: completionFooterBlocks(completion, hasError, meta.birdName, userId) });
4241
- else {
4242
- if (!fullText) await safeAppend({ markdown_text: "_Stopped._" });
4243
- await safeStop({});
4655
+ if (activeTasks.size > 0) {
4656
+ const staleChunks = [];
4657
+ for (const [id, toolName] of activeTasks) staleChunks.push({
4658
+ type: "task_update",
4659
+ id,
4660
+ title: toolTaskTitle(toolName),
4661
+ status: "error"
4662
+ });
4663
+ await safeAppend({ chunks: staleChunks });
4664
+ activeTasks.clear();
4665
+ }
4666
+ if (toolCount > 0 && (hasError || activeTasks.size > 0)) await safeAppend({ chunks: [{
4667
+ type: "plan_update",
4668
+ title: hasError ? "Finished with errors" : `Interrupted (${toolCount} ${toolCount === 1 ? "step" : "steps"})`
4669
+ }] });
4670
+ if (timedOut) {
4671
+ await safeStop({});
4672
+ const blocks = sessionTimeoutBlocks(timedOutMs, { sessionUid });
4673
+ await client.postMessage(channelId, threadTs, `Session timed out after ${Math.round(timedOutMs / 6e4)} minutes.`, { blocks });
4674
+ } else if (completion) await safeStop({ blocks: completionFooterBlocks(completion, hasError, meta.birdName, userId) });
4675
+ else {
4676
+ if (!fullText) await safeAppend({ markdown_text: "_Stopped._" });
4677
+ await safeStop({});
4678
+ }
4679
+ } finally {
4680
+ clearInterval(statusTimer);
4681
+ client.setStatus?.(channelId, threadTs, "").catch(() => {});
4244
4682
  }
4245
4683
  }
4246
4684
  async function uploadImages(images, channelId, threadTs) {
@@ -4293,13 +4731,18 @@ function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId)
4293
4731
  broadcastSSE("invalidate", { resource: "sessions" });
4294
4732
  const prompt = formatPrompt(messages);
4295
4733
  const userId = messages[messages.length - 1].userId;
4734
+ const extraEnv = resolveExtraEnv([{
4735
+ type: "channel",
4736
+ id: channelId
4737
+ }]);
4296
4738
  const { events, kill } = spawnProvider({
4297
4739
  message: prompt,
4298
4740
  sessionId: isNew ? void 0 : session.provider_session_id || void 0,
4299
4741
  agent,
4300
4742
  mcpConfigPath: config.browser.mcpConfigPath,
4301
4743
  timezone: config.timezone,
4302
- globalTimeoutMs: config.sessions.processTimeoutMs
4744
+ globalTimeoutMs: config.sessions.processTimeoutMs,
4745
+ extraEnv
4303
4746
  }, signal);
4304
4747
  lock.killCurrent = kill;
4305
4748
  client.setStatus?.(channelId, threadTs, "is thinking...").catch(() => {});
@@ -5057,9 +5500,13 @@ async function startDaemon(options) {
5057
5500
  const configDir = dirname(configPath);
5058
5501
  const envPath = resolve(configDir, ".env");
5059
5502
  openDatabase(resolveDbPath(options.flags.db));
5503
+ loadDotEnv(envPath);
5504
+ ensureVaultKey(envPath);
5505
+ migrateUnencryptedKeys();
5506
+ const vaultValues = getAllKeyValues();
5507
+ if (vaultValues.length > 0) addSecrets(vaultValues, VAULT_SECRET_MIN_LENGTH);
5060
5508
  clearBrowserLock();
5061
5509
  startWorker(controller.signal);
5062
- loadDotEnv(envPath);
5063
5510
  let currentConfig;
5064
5511
  let slackHandle = null;
5065
5512
  let schedulerStarted = false;
@@ -5606,6 +6053,288 @@ function handleBirds(argv) {
5606
6053
  }
5607
6054
  }
5608
6055
 
6056
+ //#endregion
6057
+ //#region src/cli/keys.ts
6058
+ /** @fileoverview Keys command: manage vault keys for agent sessions. */
6059
+ const KEYS_HELP = `
6060
+ ${c("cyan", "usage:")} browserbird keys <subcommand> [options]
6061
+
6062
+ manage vault keys (env vars injected into agent sessions).
6063
+
6064
+ ${c("dim", "subcommands:")}
6065
+
6066
+ ${c("cyan", "list")} list all keys
6067
+ ${c("cyan", "add")} <name> add a new key (prompts for value)
6068
+ ${c("cyan", "edit")} <name> update a key's value or description
6069
+ ${c("cyan", "remove")} <name> remove a key
6070
+ ${c("cyan", "bind")} <name> <type> <target> bind a key to a channel or bird
6071
+ ${c("cyan", "unbind")} <name> <type> <target> unbind a key from a target
6072
+
6073
+ ${c("dim", "options:")}
6074
+
6075
+ ${c("yellow", "--value")} <secret> secret value (if omitted, prompts interactively)
6076
+ ${c("yellow", "--description")} <text> description for the key
6077
+ ${c("yellow", "--json")} output as JSON (with list)
6078
+ ${c("yellow", "--db")} <path> database file path (env: BROWSERBIRD_DB)
6079
+ ${c("yellow", "-h, --help")} show this help
6080
+
6081
+ ${c("dim", "examples:")}
6082
+
6083
+ browserbird keys add GITHUB_TOKEN
6084
+ browserbird keys add GITHUB_TOKEN --value ghp_abc123
6085
+ browserbird keys bind GITHUB_TOKEN channel '*'
6086
+ browserbird keys bind GITHUB_TOKEN bird br_abc1234
6087
+ browserbird keys unbind GITHUB_TOKEN channel '*'
6088
+ browserbird keys remove GITHUB_TOKEN
6089
+ `.trim();
6090
+ function resolveKey(nameOrUid) {
6091
+ const d = getDb();
6092
+ const byName = d.prepare("SELECT * FROM keys WHERE name = ?").get(nameOrUid.toUpperCase());
6093
+ if (byName) return byName;
6094
+ const byUid = d.prepare("SELECT * FROM keys WHERE uid = ? OR uid LIKE ?").get(nameOrUid, `${nameOrUid}%`);
6095
+ if (byUid) return byUid;
6096
+ logger.error(`key "${nameOrUid}" not found`);
6097
+ process.exitCode = 1;
6098
+ }
6099
+ function loadBindings(keyUid) {
6100
+ return getDb().prepare("SELECT target_type, target_id FROM key_bindings WHERE key_uid = ?").all(keyUid).map((r) => ({
6101
+ targetType: r.target_type,
6102
+ targetId: r.target_id
6103
+ }));
6104
+ }
6105
+ function promptSecret(prompt) {
6106
+ return new Promise((resolve) => {
6107
+ process.stderr.write(prompt);
6108
+ const stdin = process.stdin;
6109
+ if (!stdin.isTTY) {
6110
+ let data = "";
6111
+ stdin.setEncoding("utf-8");
6112
+ stdin.on("data", (chunk) => {
6113
+ data += chunk;
6114
+ });
6115
+ stdin.on("end", () => resolve(data.trim()));
6116
+ return;
6117
+ }
6118
+ const wasRaw = stdin.isRaw;
6119
+ stdin.setRawMode(true);
6120
+ stdin.resume();
6121
+ stdin.setEncoding("utf-8");
6122
+ let value = "";
6123
+ const onData = (ch) => {
6124
+ if (ch === "\r" || ch === "\n") {
6125
+ stdin.removeListener("data", onData);
6126
+ stdin.setRawMode(wasRaw ?? false);
6127
+ stdin.pause();
6128
+ process.stderr.write("\n");
6129
+ resolve(value);
6130
+ } else if (ch === "") {
6131
+ stdin.removeListener("data", onData);
6132
+ stdin.setRawMode(wasRaw ?? false);
6133
+ process.stderr.write("\n");
6134
+ process.exit(130);
6135
+ } else if (ch === "" || ch === "\b") value = value.slice(0, -1);
6136
+ else value += ch;
6137
+ };
6138
+ stdin.on("data", onData);
6139
+ });
6140
+ }
6141
+ function initVaultKey() {
6142
+ const envPath = resolve(dirname(resolve(process.env["BROWSERBIRD_CONFIG"] ?? "browserbird.json")), ".env");
6143
+ loadDotEnv(envPath);
6144
+ ensureVaultKey(envPath);
6145
+ }
6146
+ async function handleKeys(argv) {
6147
+ const subcommand = argv[0] ?? "list";
6148
+ const { values, positionals } = parseArgs({
6149
+ args: argv.slice(1),
6150
+ options: {
6151
+ value: { type: "string" },
6152
+ description: { type: "string" },
6153
+ json: {
6154
+ type: "boolean",
6155
+ default: false
6156
+ }
6157
+ },
6158
+ allowPositionals: true,
6159
+ strict: false
6160
+ });
6161
+ openDatabase(resolveDbPathFromArgv(argv));
6162
+ initVaultKey();
6163
+ try {
6164
+ switch (subcommand) {
6165
+ case "list": {
6166
+ const result = listKeys(1, 100);
6167
+ if (values.json) {
6168
+ console.log(JSON.stringify(result.items, null, 2));
6169
+ break;
6170
+ }
6171
+ console.log(`keys (${result.totalItems} total):`);
6172
+ if (result.items.length === 0) {
6173
+ console.log("\n no keys stored");
6174
+ return;
6175
+ }
6176
+ console.log("");
6177
+ printTable([
6178
+ "name",
6179
+ "value",
6180
+ "description",
6181
+ "bindings"
6182
+ ], result.items.map((key) => {
6183
+ const bindings = key.bindings.map((b) => `${b.targetType}:${b.targetId}`).join(", ") || "-";
6184
+ return [
6185
+ key.name,
6186
+ key.hint,
6187
+ key.description ?? "-",
6188
+ bindings
6189
+ ];
6190
+ }), [
6191
+ void 0,
6192
+ void 0,
6193
+ 30,
6194
+ 40
6195
+ ]);
6196
+ break;
6197
+ }
6198
+ case "add": {
6199
+ const name = positionals[0];
6200
+ if (!name) {
6201
+ logger.error("usage: browserbird keys add <name> [--value <secret>] [--description <text>]");
6202
+ process.exitCode = 1;
6203
+ return;
6204
+ }
6205
+ let secret = values.value;
6206
+ if (!secret) {
6207
+ secret = await promptSecret(`value for ${name.toUpperCase()}: `);
6208
+ if (!secret) {
6209
+ logger.error("value cannot be empty");
6210
+ process.exitCode = 1;
6211
+ return;
6212
+ }
6213
+ }
6214
+ try {
6215
+ const key = createKey(name.toUpperCase(), secret, values.description?.trim());
6216
+ logger.success(`key ${key.name} created`);
6217
+ process.stderr.write(c("dim", ` hint: run 'browserbird keys bind ${key.name} channel *' to bind it`) + "\n");
6218
+ } catch (err) {
6219
+ const msg = err instanceof Error ? err.message : String(err);
6220
+ if (msg.includes("UNIQUE constraint")) logger.error(`a key named "${name.toUpperCase()}" already exists`);
6221
+ else logger.error(msg);
6222
+ process.exitCode = 1;
6223
+ }
6224
+ break;
6225
+ }
6226
+ case "edit": {
6227
+ const nameOrUid = positionals[0];
6228
+ if (!nameOrUid) {
6229
+ logger.error("usage: browserbird keys edit <name> [--value <secret>] [--description <text>]");
6230
+ process.exitCode = 1;
6231
+ return;
6232
+ }
6233
+ const key = resolveKey(nameOrUid);
6234
+ if (!key) return;
6235
+ const fields = {};
6236
+ if (values.value !== void 0) fields.value = values.value;
6237
+ if (values.description !== void 0) fields.description = values.description.trim();
6238
+ if (!fields.value && !("description" in fields)) {
6239
+ const secret = await promptSecret(`new value for ${key.name}: `);
6240
+ if (secret) fields.value = secret;
6241
+ }
6242
+ if (!fields.value && !("description" in fields)) {
6243
+ logger.error("provide --value, --description, or enter a value when prompted");
6244
+ process.exitCode = 1;
6245
+ return;
6246
+ }
6247
+ if (updateKey(key.uid, fields)) logger.success(`key ${key.name} updated`);
6248
+ else {
6249
+ logger.error(`key ${key.name} not found`);
6250
+ process.exitCode = 1;
6251
+ }
6252
+ break;
6253
+ }
6254
+ case "remove": {
6255
+ const nameOrUid = positionals[0];
6256
+ if (!nameOrUid) {
6257
+ logger.error("usage: browserbird keys remove <name>");
6258
+ process.exitCode = 1;
6259
+ return;
6260
+ }
6261
+ const key = resolveKey(nameOrUid);
6262
+ if (!key) return;
6263
+ if (deleteKey(key.uid)) logger.success(`key ${key.name} removed`);
6264
+ else {
6265
+ logger.error(`key ${key.name} not found`);
6266
+ process.exitCode = 1;
6267
+ }
6268
+ break;
6269
+ }
6270
+ case "bind": {
6271
+ const nameOrUid = positionals[0];
6272
+ const targetType = positionals[1];
6273
+ const targetId = positionals[2];
6274
+ if (!nameOrUid || !targetType || !targetId) {
6275
+ logger.error("usage: browserbird keys bind <name> <channel|bird> <target>\n example: browserbird keys bind GITHUB_TOKEN channel '*'");
6276
+ process.exitCode = 1;
6277
+ return;
6278
+ }
6279
+ if (targetType !== "channel" && targetType !== "bird") {
6280
+ logger.error("target type must be \"channel\" or \"bird\"");
6281
+ process.exitCode = 1;
6282
+ return;
6283
+ }
6284
+ const key = resolveKey(nameOrUid);
6285
+ if (!key) return;
6286
+ const existing = loadBindings(key.uid);
6287
+ if (existing.some((b) => b.targetType === targetType && b.targetId === targetId)) {
6288
+ logger.warn(`key ${key.name} is already bound to ${targetType} ${targetId}`);
6289
+ return;
6290
+ }
6291
+ replaceBindings(key.uid, [...existing, {
6292
+ targetType,
6293
+ targetId
6294
+ }]);
6295
+ logger.success(`key ${key.name} bound to ${targetType} ${targetId}`);
6296
+ break;
6297
+ }
6298
+ case "unbind": {
6299
+ const nameOrUid = positionals[0];
6300
+ const targetType = positionals[1];
6301
+ const targetId = positionals[2];
6302
+ if (!nameOrUid || !targetType || !targetId) {
6303
+ logger.error("usage: browserbird keys unbind <name> <channel|bird> <target>");
6304
+ process.exitCode = 1;
6305
+ return;
6306
+ }
6307
+ if (targetType !== "channel" && targetType !== "bird") {
6308
+ logger.error("target type must be \"channel\" or \"bird\"");
6309
+ process.exitCode = 1;
6310
+ return;
6311
+ }
6312
+ const key = resolveKey(nameOrUid);
6313
+ if (!key) return;
6314
+ const existing = loadBindings(key.uid);
6315
+ const filtered = existing.filter((b) => !(b.targetType === targetType && b.targetId === targetId));
6316
+ if (filtered.length === existing.length) {
6317
+ logger.warn(`key ${key.name} is not bound to ${targetType} ${targetId}`);
6318
+ return;
6319
+ }
6320
+ replaceBindings(key.uid, filtered);
6321
+ logger.success(`key ${key.name} unbound from ${targetType} ${targetId}`);
6322
+ break;
6323
+ }
6324
+ default: unknownSubcommand(subcommand, "keys", [
6325
+ "list",
6326
+ "add",
6327
+ "edit",
6328
+ "remove",
6329
+ "bind",
6330
+ "unbind"
6331
+ ]);
6332
+ }
6333
+ } finally {
6334
+ closeDatabase();
6335
+ }
6336
+ }
6337
+
5609
6338
  //#endregion
5610
6339
  //#region src/cli/logs.ts
5611
6340
  /** @fileoverview Logs command: show recent log entries from the database. */
@@ -5917,6 +6646,7 @@ ${c("dim", "commands:")}
5917
6646
 
5918
6647
  ${c("cyan", "sessions")} manage sessions
5919
6648
  ${c("cyan", "birds")} manage scheduled birds
6649
+ ${c("cyan", "keys")} manage vault keys
5920
6650
  ${c("cyan", "config")} view configuration
5921
6651
  ${c("cyan", "logs")} show recent log entries
5922
6652
  ${c("cyan", "jobs")} inspect and manage the job queue
@@ -5934,6 +6664,7 @@ run 'browserbird <command> --help' for command-specific options.`.trimEnd();
5934
6664
  const COMMAND_HELP = {
5935
6665
  sessions: SESSIONS_HELP,
5936
6666
  birds: BIRDS_HELP,
6667
+ keys: KEYS_HELP,
5937
6668
  config: CONFIG_HELP,
5938
6669
  logs: LOGS_HELP,
5939
6670
  jobs: JOBS_HELP,
@@ -5974,6 +6705,13 @@ async function run(argv) {
5974
6705
  }
5975
6706
  handleBirds(rest);
5976
6707
  break;
6708
+ case COMMANDS.KEYS:
6709
+ if (isHelp) {
6710
+ console.log(COMMAND_HELP.keys);
6711
+ return;
6712
+ }
6713
+ await handleKeys(rest);
6714
+ break;
5977
6715
  case COMMANDS.CONFIG:
5978
6716
  if (isHelp) {
5979
6717
  console.log(COMMAND_HELP.config);
@@ -6001,6 +6739,7 @@ async function run(argv) {
6001
6739
  default: unknownSubcommand(command, "", [
6002
6740
  "sessions",
6003
6741
  "birds",
6742
+ "keys",
6004
6743
  "config",
6005
6744
  "logs",
6006
6745
  "jobs",