@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/README.md +12 -1
- package/dist/index.mjs +900 -161
- package/package.json +1 -1
- package/web/dist/assets/index-BehCAKeW.css +1 -0
- package/web/dist/assets/index-BsCZjyRj.js +8 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-C4T4JGQp.css +0 -1
- package/web/dist/assets/index-rMAhMQyM.js +0 -8
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: ${"
|
|
196
|
-
buildInfo.push(`built: 2026-03-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
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
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
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:
|
|
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
|
-
|
|
4630
|
+
break;
|
|
4193
4631
|
}
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
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
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
}
|
|
4229
|
-
await safeAppend({ chunks:
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
await safeStop({});
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
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",
|