getbotons 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const WINDOW_MS = 3_600_000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sliding one-hour windows on runs (or asks) started: per key and in total.
|
|
5
|
+
* In memory, so a restart starts them afresh.
|
|
6
|
+
*/
|
|
7
|
+
export class RunLimiter {
|
|
8
|
+
#perKey: number;
|
|
9
|
+
#total: number;
|
|
10
|
+
#byKey = new Map<string, number[]>();
|
|
11
|
+
#all: number[] = [];
|
|
12
|
+
|
|
13
|
+
constructor(perKey: number, total: number) {
|
|
14
|
+
this.#perKey = perKey;
|
|
15
|
+
this.#total = total;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Null if `key` may start another now; otherwise which limit it hit and when a slot frees up. */
|
|
19
|
+
check(key: string): { limit: 'routine' | 'total'; max: number; retryAfterMs: number } | null {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const mine = this.#prune(this.#byKey.get(key) ?? [], now);
|
|
22
|
+
if (mine.length) this.#byKey.set(key, mine);
|
|
23
|
+
else this.#byKey.delete(key);
|
|
24
|
+
this.#all = this.#prune(this.#all, now);
|
|
25
|
+
if (mine.length >= this.#perKey) return { limit: 'routine', max: this.#perKey, retryAfterMs: mine[mine.length - this.#perKey] + WINDOW_MS - now };
|
|
26
|
+
if (this.#all.length >= this.#total) return { limit: 'total', max: this.#total, retryAfterMs: this.#all[this.#all.length - this.#total] + WINDOW_MS - now };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
record(key: string): void {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const mine = this.#byKey.get(key) ?? [];
|
|
33
|
+
mine.push(now);
|
|
34
|
+
this.#byKey.set(key, mine);
|
|
35
|
+
this.#all.push(now);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#prune(times: number[], now: number): number[] {
|
|
39
|
+
const from = now - WINDOW_MS;
|
|
40
|
+
let i = 0;
|
|
41
|
+
while (i < times.length && times[i] <= from) i++;
|
|
42
|
+
return i ? times.slice(i) : times;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
|
|
5
|
+
export class DataDirInUseError extends Error {
|
|
6
|
+
name = 'DataDirInUseError';
|
|
7
|
+
constructor(home: string, pid: number | null) {
|
|
8
|
+
super(
|
|
9
|
+
`Botons data dir in use: another Botons server${pid ? ` (pid ${pid})` : ''} is using ${home}. ` +
|
|
10
|
+
'Stop it first, or set BOTONS_HOME to another folder. Nothing was changed.',
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DataDirLock {
|
|
16
|
+
release(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function livePid(pidFile: string): number | null {
|
|
20
|
+
let pid: number;
|
|
21
|
+
try {
|
|
22
|
+
pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return pid;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
// EPERM: it exists but belongs to another user.
|
|
32
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM' ? pid : null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Each held lock's connection, so dropping a lock's handle can't let GC close it and free the data dir. */
|
|
37
|
+
const heldLocks = new Set<DatabaseSync>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Only one server may open the database. The lock is an open EXCLUSIVE
|
|
41
|
+
* transaction on `<home>/server.lock`: the kernel drops its fcntl lock when the
|
|
42
|
+
* process dies however it dies, so it never goes stale. server.pid only names
|
|
43
|
+
* the holder in the error.
|
|
44
|
+
*/
|
|
45
|
+
export function lockDataDir(home: string): DataDirLock {
|
|
46
|
+
const lockFile = join(home, 'server.lock');
|
|
47
|
+
const pidFile = join(home, 'server.pid');
|
|
48
|
+
const db = new DatabaseSync(lockFile, { timeout: 0 });
|
|
49
|
+
try {
|
|
50
|
+
// Journal in memory, so no server.lock-journal appears.
|
|
51
|
+
db.exec('pragma journal_mode = memory; begin exclusive');
|
|
52
|
+
} catch (err) {
|
|
53
|
+
db.close();
|
|
54
|
+
// SQLITE_BUSY (5), possibly as an extended code.
|
|
55
|
+
if ((((err as { errcode?: number }).errcode ?? 0) & 0xff) === 5) throw new DataDirInUseError(home, livePid(pidFile));
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
heldLocks.add(db);
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(pidFile, `${process.pid}\n`);
|
|
61
|
+
} catch {}
|
|
62
|
+
let held = true;
|
|
63
|
+
return {
|
|
64
|
+
release() {
|
|
65
|
+
if (!held) return;
|
|
66
|
+
held = false;
|
|
67
|
+
// While still holding the lock, so it never removes the next holder's pid file.
|
|
68
|
+
try {
|
|
69
|
+
if (readFileSync(pidFile, 'utf8').trim() === String(process.pid)) rmSync(pidFile, { force: true });
|
|
70
|
+
} catch {}
|
|
71
|
+
heldLocks.delete(db);
|
|
72
|
+
db.close();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { fstatSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// Node builtins only: scripts/backup.ts imports config.ts, which imports this, without node_modules.
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* One JSON object per line on stdout. Never log secrets (tokens, webhook keys,
|
|
7
|
+
* Authorization headers, ?key= values), chat text or webhook payloads; known
|
|
8
|
+
* token shapes are masked anyway, as a last line of defense.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
12
|
+
export const LOG_LEVELS: readonly LogLevel[] = ['debug', 'info', 'warn', 'error'];
|
|
13
|
+
|
|
14
|
+
/** `err` (any thrown value) is written as `error` and `stack`. */
|
|
15
|
+
export type LogFields = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
export interface LogLine {
|
|
18
|
+
ts: string;
|
|
19
|
+
level: LogLevel;
|
|
20
|
+
msg: string;
|
|
21
|
+
[field: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** syslog priorities, for the `<N>` prefix journald reads (and strips) when stdout is the journal. */
|
|
25
|
+
const PRIORITY: Record<LogLevel, number> = { debug: 7, info: 6, warn: 4, error: 3 };
|
|
26
|
+
|
|
27
|
+
const envLevel = process.env.BOTONS_LOG_LEVEL?.trim().toLowerCase();
|
|
28
|
+
/** BOTONS_LOG_LEVEL until the server applies its config. */
|
|
29
|
+
let threshold: LogLevel = LOG_LEVELS.find((l) => l === envLevel) ?? 'info';
|
|
30
|
+
/** Set while a test captures lines. */
|
|
31
|
+
let sink: ((line: string) => void) | null = null;
|
|
32
|
+
/** Called with every line written (never debug), before it is serialized: Settings → Server events. */
|
|
33
|
+
const taps = new Set<(level: LogLevel, msg: string, fields: LogFields, at: Date) => void>();
|
|
34
|
+
|
|
35
|
+
/** Sees each info, warn and error line the threshold lets through; returns the function that stops it. A tap that throws costs nothing. */
|
|
36
|
+
export function tapLogs(fn: (level: LogLevel, msg: string, fields: LogFields, at: Date) => void): () => void {
|
|
37
|
+
taps.add(fn);
|
|
38
|
+
return () => void taps.delete(fn);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setLogLevel(level: LogLevel): void {
|
|
42
|
+
threshold = level;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether JOURNAL_STREAM (`<device>:<inode>`) names stdout, so `journalctl -p` works and `-o cat` still shows plain JSON. */
|
|
46
|
+
export function isJournalStream(journalStream: string | undefined, stat: () => { dev: bigint; ino: bigint }): boolean {
|
|
47
|
+
const m = /^(\d+):(\d+)$/.exec(journalStream?.trim() ?? '');
|
|
48
|
+
if (!m) return false;
|
|
49
|
+
try {
|
|
50
|
+
const st = stat();
|
|
51
|
+
return st.dev === BigInt(m[1]) && st.ino === BigInt(m[2]);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let toJournal: boolean | undefined;
|
|
58
|
+
|
|
59
|
+
function errorFields(err: unknown): { error: string; stack?: string } {
|
|
60
|
+
if (err instanceof Error) return err.stack ? { error: err.message, stack: err.stack } : { error: err.message };
|
|
61
|
+
return { error: typeof err === 'string' ? err : safeString(err) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeString(v: unknown): string {
|
|
65
|
+
try {
|
|
66
|
+
return typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v);
|
|
67
|
+
} catch {
|
|
68
|
+
return Object.prototype.toString.call(v);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Runs on the JSON text, and never introduces a quote or backslash. */
|
|
73
|
+
export function redact(text: string): string {
|
|
74
|
+
return text
|
|
75
|
+
.replace(/\b(md|mm)t_[A-Za-z0-9_-]{8,}/g, '$1t_[redacted]')
|
|
76
|
+
.replace(/(\bBearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[redacted]')
|
|
77
|
+
.replace(/([?&]key=)[^&\s"\\#]+/gi, '$1[redacted]');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const replacer = (_key: string, value: unknown) => {
|
|
81
|
+
if (typeof value === 'bigint') return value.toString();
|
|
82
|
+
if (value instanceof Error) return errorFields(value);
|
|
83
|
+
return value;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function formatLine(level: LogLevel, msg: string, fields: LogFields = {}, now = new Date()): string {
|
|
87
|
+
const out: Record<string, unknown> = { ts: now.toISOString(), level, msg };
|
|
88
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
89
|
+
if (v === undefined || k === 'ts' || k === 'level' || k === 'msg') continue;
|
|
90
|
+
if (k === 'err') Object.assign(out, errorFields(v));
|
|
91
|
+
else out[k] = v;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return redact(JSON.stringify(out, replacer));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// A field that can't be serialized (a cycle, say) must not cost the line.
|
|
97
|
+
return redact(JSON.stringify({ ts: out.ts, level, msg, logError: `fields not logged: ${(err as Error).message}` }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function emit(level: LogLevel, msg: string, fields?: LogFields): void {
|
|
102
|
+
if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(threshold)) return;
|
|
103
|
+
const now = new Date();
|
|
104
|
+
if (level !== 'debug') {
|
|
105
|
+
for (const tap of taps) {
|
|
106
|
+
try {
|
|
107
|
+
tap(level, msg, fields ?? {}, now);
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const line = formatLine(level, msg, fields, now);
|
|
112
|
+
if (sink) return sink(line);
|
|
113
|
+
toJournal ??= isJournalStream(process.env.JOURNAL_STREAM, () => fstatSync(1, { bigint: true }));
|
|
114
|
+
process.stdout.write(`${toJournal ? `<${PRIORITY[level]}>` : ''}${line}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const log = {
|
|
118
|
+
debug: (msg: string, fields?: LogFields) => emit('debug', msg, fields),
|
|
119
|
+
info: (msg: string, fields?: LogFields) => emit('info', msg, fields),
|
|
120
|
+
warn: (msg: string, fields?: LogFields) => emit('warn', msg, fields),
|
|
121
|
+
error: (msg: string, fields?: LogFields) => emit('error', msg, fields),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** For tests: collect every line (all levels) instead of writing it, until restore(). */
|
|
125
|
+
export function captureLogs(): { lines: LogLine[]; restore(): void } {
|
|
126
|
+
const lines: LogLine[] = [];
|
|
127
|
+
const prev = { sink, threshold };
|
|
128
|
+
sink = (line) => lines.push(JSON.parse(line) as LogLine);
|
|
129
|
+
threshold = 'debug';
|
|
130
|
+
return {
|
|
131
|
+
lines,
|
|
132
|
+
restore() {
|
|
133
|
+
sink = prev.sink;
|
|
134
|
+
threshold = prev.threshold;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync, existsSync, lstatSync, readdirSync, rmSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, isAbsolute, delimiter } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
export type ManagedProvider = 'codex-cli' | 'claude-cli';
|
|
5
|
+
export const managedRoot = (userHome = homedir()) => join(userHome, '.local/share/botons');
|
|
6
|
+
const cliName = (provider: ManagedProvider) => (provider === 'codex-cli' ? 'codex' : 'claude');
|
|
7
|
+
|
|
8
|
+
/** The first executable `name` on `path`, as a shell would find it; null when there is none. */
|
|
9
|
+
export function onPath(name: string, path = process.env.PATH ?? ''): string | null {
|
|
10
|
+
for (const dir of path.split(delimiter)) {
|
|
11
|
+
if (!dir || !isAbsolute(dir)) continue;
|
|
12
|
+
try { const file = join(dir, name); if (statSync(file).isFile() && (statSync(file).mode & 0o111)) return file; } catch {}
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether two CLI settings ("codex", "/usr/bin/codex", a symlink to it) run the same file. */
|
|
18
|
+
export function sameCli(a: string, b: string, path = process.env.PATH ?? ''): boolean {
|
|
19
|
+
if (a === b) return true;
|
|
20
|
+
const real = (bin: string) => { try { const file = bin.includes('/') ? bin : onPath(bin, path); return file ? realpathSync(file) : null; } catch { return null; } };
|
|
21
|
+
const x = real(a);
|
|
22
|
+
return x !== null && x === real(b);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Botons' own copy: one Settings → Brains installed, or the installer's, both under the managed root. */
|
|
26
|
+
export function isManagedBin(bin: string, provider: ManagedProvider, root = managedRoot()): boolean {
|
|
27
|
+
const path = relative(join(root, 'clis'), bin), name = cliName(provider);
|
|
28
|
+
return isAbsolute(bin) && (path === `${name}/bin/${name}` || new RegExp(`^${provider}/releases/[a-zA-Z0-9-]+/bin/${name}$`).test(path));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The copy a terminal on this machine runs as `codex` or `claude`, when it is another file than Botons' `bin`. */
|
|
32
|
+
export function terminalCli(provider: ManagedProvider, bin: string, path = process.env.PATH ?? ''): string | null {
|
|
33
|
+
const found = onPath(cliName(provider), path);
|
|
34
|
+
return found && !sameCli(found, bin, path) ? found : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function managedBin(home: string, provider: ManagedProvider, fallback: string, root = managedRoot(), searchPath = process.env.PATH ?? ''): string {
|
|
38
|
+
try {
|
|
39
|
+
const record = JSON.parse(readFileSync(join(home, 'managed-clis.json'), 'utf8'))[provider];
|
|
40
|
+
if (!record || typeof record.bin !== 'string') return fallback;
|
|
41
|
+
// The same system copy, named another way ("codex" or its full path), keeps Botons' own; another copy wins.
|
|
42
|
+
if (fallback !== record.bin && !(typeof record.previous === 'string' && sameCli(fallback, record.previous, searchPath))) return fallback;
|
|
43
|
+
const path = relative(join(root, 'clis', provider, 'releases'), record.bin);
|
|
44
|
+
if (isAbsolute(path) || path.startsWith('..') || !/^[a-zA-Z0-9-]+\/bin\/(codex|claude)$/.test(path)) return fallback;
|
|
45
|
+
return record.bin;
|
|
46
|
+
} catch { return fallback; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Call only after turns drain, or at startup before turns begin. Never clean system paths or links. */
|
|
50
|
+
export function pruneManagedReleases(root: string, provider: ManagedProvider, current: string): void {
|
|
51
|
+
const releases = join(root, 'clis', provider, 'releases');
|
|
52
|
+
const selected = relative(releases, current);
|
|
53
|
+
if (!/^[0-9a-f-]{36}\/bin\/(codex|claude)$/.test(selected) || !existsSync(current)) return;
|
|
54
|
+
try {
|
|
55
|
+
for (const path of [root, join(root, 'clis'), join(root, 'clis', provider), releases]) if (lstatSync(path).isSymbolicLink()) return;
|
|
56
|
+
const selectedRelease = join(releases, selected.split('/')[0]);
|
|
57
|
+
if (lstatSync(selectedRelease).isSymbolicLink() || lstatSync(join(selectedRelease, 'bin')).isSymbolicLink()) return;
|
|
58
|
+
const backing = relative(selectedRelease, realpathSync(current));
|
|
59
|
+
if (isAbsolute(backing) || backing === '..' || backing.startsWith('../')) return;
|
|
60
|
+
for (const entry of readdirSync(releases, {withFileTypes:true})) {
|
|
61
|
+
if (!entry.isDirectory() || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(entry.name) || selected.startsWith(entry.name + '/')) continue;
|
|
62
|
+
rmSync(join(releases, entry.name), {recursive:true, force:true});
|
|
63
|
+
}
|
|
64
|
+
} catch { /* Retaining an old release must not prevent startup or undo activation. */ }
|
|
65
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { VERSION } from '../../shared/app-version.mjs';
|
|
2
|
+
import { hostOf, OutboundError, outbound } from './outbound.ts';
|
|
3
|
+
import { redact, redactJson, sanitize } from './secrets.ts';
|
|
4
|
+
import { refusedText } from '../../shared/services.ts';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Just enough of an MCP client (Streamable HTTP) for the server's own calls with a connector's token: initialize,
|
|
8
|
+
* list tools, call one. Each answer may be JSON or a short event stream; both are bounded by outbound().
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const PROTOCOL = '2025-06-18';
|
|
12
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const MAX_TOOL_PAGES = 20;
|
|
14
|
+
|
|
15
|
+
export class McpError extends Error {
|
|
16
|
+
/** auth: the token was refused (401/403); rpc: the server answered with an error; transport: anything else. */
|
|
17
|
+
readonly kind: 'auth' | 'rpc' | 'transport';
|
|
18
|
+
constructor(kind: McpError['kind'], message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The secrets in a set of request headers: each value, and the credential after an auth scheme ("Bearer x" → x), so
|
|
26
|
+
* a server that echoes them back in an error or a tool name can't get them into a result, a log line or the audit.
|
|
27
|
+
*/
|
|
28
|
+
export function secretsOf(headers: Record<string, string>): string[] {
|
|
29
|
+
const out = new Set<string>();
|
|
30
|
+
for (const v of Object.values(headers)) {
|
|
31
|
+
out.add(v);
|
|
32
|
+
const m = /^\S+\s+(\S.*)$/.exec(v);
|
|
33
|
+
if (m) out.add(m[1]);
|
|
34
|
+
}
|
|
35
|
+
return [...out].filter((v) => v.length > 0).sort((a, b) => b.length - a.length);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { redact };
|
|
39
|
+
|
|
40
|
+
/** Every secret of the session taken out of a result's text, whatever its length. */
|
|
41
|
+
function sanitizeText(text: string, secrets: string[]): string {
|
|
42
|
+
let out = text;
|
|
43
|
+
for (const v of secrets) if (v.length >= 4) out = out.split(v).join('[redacted]');
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a server said about its tools or a call, with the session's secrets out of all of it (structured content, a
|
|
49
|
+
* tool's description and input schema, not only text) before anything else reads it. Names stay as they are, for the
|
|
50
|
+
* caller to judge (an API brain leaves out a tool whose name holds one: it can't be redacted and still be called).
|
|
51
|
+
*/
|
|
52
|
+
export function cleanTools(tools: McpTool[], secrets: string[]): McpTool[] {
|
|
53
|
+
const scrub = (t: string) => sanitizeText(t, secrets);
|
|
54
|
+
return tools.map((t) => ({ name: t.name, inputSchema: t.inputSchema ? (redactJson(t.inputSchema, scrub) as Record<string, unknown>) : null, description: typeof t.description === 'string' ? scrub(t.description) : null }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether `text` holds one of `secrets` (as cleanTools and cleanResult take them out). */
|
|
58
|
+
export function holdsSecret(text: string, secrets: string[]): boolean {
|
|
59
|
+
return secrets.some((v) => v.length >= 4 && text.includes(v));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A tools/call result with the session's secrets out of it, and `also` (the caller's own, such as an API brain's
|
|
64
|
+
* keys) applied too: both to the whole text, before it is cut, so no part of a secret is left at the cut.
|
|
65
|
+
*/
|
|
66
|
+
export function cleanResult(r: Record<string, unknown>, secrets: string[], also: (text: string) => string = (t) => t): ToolResult {
|
|
67
|
+
const scrub = (t: string) => also(sanitizeText(t, secrets));
|
|
68
|
+
const sc = r.structuredContent;
|
|
69
|
+
return {
|
|
70
|
+
isError: r.isError === true,
|
|
71
|
+
structured: sc && typeof sc === 'object' && !Array.isArray(sc) ? (redactJson(sc, scrub) as Record<string, unknown>) : null,
|
|
72
|
+
text: resultText(r.content, scrub),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface McpTool {
|
|
77
|
+
name: string;
|
|
78
|
+
inputSchema: Record<string, unknown> | null;
|
|
79
|
+
/** The server's own words for it, when a string (for a model to read; never trusted). */
|
|
80
|
+
description?: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ToolResult {
|
|
84
|
+
isError: boolean;
|
|
85
|
+
structured: Record<string, unknown> | null;
|
|
86
|
+
/** Its text content blocks, joined, bounded (other kinds of content are named, not passed on). */
|
|
87
|
+
text?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const MAX_RESULT_TEXT = 64 * 1024;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A tools/call result's content as text: text blocks as they are, anything else by its kind, joined; `scrub` goes
|
|
94
|
+
* over the whole of it (across the blocks' joins too) before it is cut at MAX_RESULT_TEXT.
|
|
95
|
+
*/
|
|
96
|
+
export function resultText(content: unknown, scrub: (text: string) => string = (t) => t): string {
|
|
97
|
+
const parts: string[] = [];
|
|
98
|
+
for (const b of Array.isArray(content) ? content : []) {
|
|
99
|
+
const block = b as { type?: unknown; text?: unknown };
|
|
100
|
+
if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text);
|
|
101
|
+
else if (typeof block?.type === 'string') parts.push(`[${block.type.slice(0, 40)} content not shown]`);
|
|
102
|
+
}
|
|
103
|
+
const text = scrub(parts.join('\n'));
|
|
104
|
+
return text.length > MAX_RESULT_TEXT ? `${text.slice(0, MAX_RESULT_TEXT)}\n[… cut at ${MAX_RESULT_TEXT} characters]` : text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The JSON-RPC answer with `id`, from a JSON body or an SSE stream. */
|
|
108
|
+
function answerOf(body: string, contentType: string, id: number): Record<string, unknown> | null {
|
|
109
|
+
const pick = (v: unknown) => {
|
|
110
|
+
const list = Array.isArray(v) ? v : [v];
|
|
111
|
+
return (list.find((m) => m && typeof m === 'object' && (m as Record<string, unknown>).id === id) as Record<string, unknown> | undefined) ?? null;
|
|
112
|
+
};
|
|
113
|
+
if (contentType.includes('text/event-stream')) {
|
|
114
|
+
for (const block of body.split(/\r?\n\r?\n/)) {
|
|
115
|
+
const data = block
|
|
116
|
+
.split(/\r?\n/)
|
|
117
|
+
.filter((l) => l.startsWith('data:'))
|
|
118
|
+
.map((l) => l.slice(5).trimStart())
|
|
119
|
+
.join('\n');
|
|
120
|
+
if (!data) continue;
|
|
121
|
+
try {
|
|
122
|
+
const found = pick(JSON.parse(data));
|
|
123
|
+
if (found) return found;
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return pick(JSON.parse(body));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class McpSession {
|
|
136
|
+
#url: string;
|
|
137
|
+
#headers: Record<string, string>;
|
|
138
|
+
#allow: string[];
|
|
139
|
+
#signal?: AbortSignal;
|
|
140
|
+
#session: string | null = null;
|
|
141
|
+
#version = PROTOCOL;
|
|
142
|
+
#id = 0;
|
|
143
|
+
#authority?: () => void;
|
|
144
|
+
/** How long one request may take; tools/call of an API brain's connector may take longer than a test's. */
|
|
145
|
+
timeoutMs = 20_000;
|
|
146
|
+
|
|
147
|
+
/** `auth`: a bearer token, or the headers a library server was saved with (the protocol's own headers win). */
|
|
148
|
+
constructor(url: string, auth: string | Record<string, string>, allow: string[], signal?: AbortSignal, authority?: () => void) {
|
|
149
|
+
this.#url = url;
|
|
150
|
+
this.#headers = {};
|
|
151
|
+
if (typeof auth === 'string') this.#headers.authorization = `Bearer ${auth}`;
|
|
152
|
+
else for (const [k, v] of Object.entries(auth)) this.#headers[k.toLowerCase()] = v;
|
|
153
|
+
this.#allow = allow;
|
|
154
|
+
this.#signal = signal;
|
|
155
|
+
this.#authority = authority;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What this session sends that must not come back out in text: see secretsOf(). */
|
|
159
|
+
get secrets(): string[] {
|
|
160
|
+
return secretsOf(this.#headers);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #post(body: Record<string, unknown>, expectAnswer: boolean): Promise<Record<string, unknown> | null> {
|
|
164
|
+
// Includes initialization notifications and each discovery page, not only top-level tool calls.
|
|
165
|
+
this.#authority?.();
|
|
166
|
+
const headers: Record<string, string> = {
|
|
167
|
+
...this.#headers,
|
|
168
|
+
'content-type': 'application/json',
|
|
169
|
+
accept: 'application/json, text/event-stream',
|
|
170
|
+
'mcp-protocol-version': this.#version,
|
|
171
|
+
};
|
|
172
|
+
delete headers['mcp-session-id'];
|
|
173
|
+
if (this.#session) headers['mcp-session-id'] = this.#session;
|
|
174
|
+
let res;
|
|
175
|
+
try {
|
|
176
|
+
res = await outbound(this.#url, { method: 'POST', allow: this.#allow, headers, body: JSON.stringify(body), maxBytes: MAX_BYTES, timeoutMs: this.timeoutMs, signal: this.#signal });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new McpError('transport', sanitize(err instanceof OutboundError ? err.message : String(err), this.secrets, 300));
|
|
179
|
+
}
|
|
180
|
+
this.#authority?.();
|
|
181
|
+
if (res.status === 401 || res.status === 403) throw new McpError('auth', refusedText(this.#url, res.status, 'authorization' in this.#headers));
|
|
182
|
+
const sid = res.headers['mcp-session-id'];
|
|
183
|
+
if (typeof sid === 'string' && /^[\x21-\x7e]{1,200}$/.test(sid)) this.#session = sid;
|
|
184
|
+
if (!expectAnswer) {
|
|
185
|
+
if (res.status >= 300) throw new McpError('transport', `${hostOf(this.#url)} answered ${res.status}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (res.status !== 200) throw new McpError('transport', `${hostOf(this.#url)} answered ${res.status}`);
|
|
189
|
+
const answer = answerOf(res.body, String(res.headers['content-type'] ?? ''), body.id as number);
|
|
190
|
+
if (!answer) throw new McpError('transport', `${hostOf(this.#url)} sent no answer to ${String(body.method)}`);
|
|
191
|
+
if (answer.error) {
|
|
192
|
+
const e = answer.error as { message?: unknown };
|
|
193
|
+
throw new McpError('rpc', `${hostOf(this.#url)}: ${typeof e?.message === 'string' ? sanitize(e.message, this.secrets, 200) : 'error'}`);
|
|
194
|
+
}
|
|
195
|
+
const result = answer.result;
|
|
196
|
+
return result && typeof result === 'object' && !Array.isArray(result) ? (result as Record<string, unknown>) : {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#request(method: string, params: Record<string, unknown>) {
|
|
200
|
+
return this.#post({ jsonrpc: '2.0', id: ++this.#id, method, params }, true) as Promise<Record<string, unknown>>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async initialize(): Promise<void> {
|
|
204
|
+
const r = await this.#request('initialize', { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'Botons', version: VERSION } });
|
|
205
|
+
if (typeof r.protocolVersion === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(r.protocolVersion)) this.#version = r.protocolVersion;
|
|
206
|
+
await this.#post({ jsonrpc: '2.0', method: 'notifications/initialized' }, false);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Every tool, following nextCursor (bounded). */
|
|
210
|
+
async listTools(): Promise<McpTool[]> {
|
|
211
|
+
const tools: McpTool[] = [];
|
|
212
|
+
let cursor: unknown;
|
|
213
|
+
for (let page = 0; page < MAX_TOOL_PAGES; page++) {
|
|
214
|
+
const r = await this.#request('tools/list', cursor === undefined ? {} : { cursor });
|
|
215
|
+
for (const t of Array.isArray(r.tools) ? r.tools : []) {
|
|
216
|
+
if (t && typeof t.name === 'string')
|
|
217
|
+
tools.push({ name: t.name, inputSchema: t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : null, description: typeof t.description === 'string' ? t.description : null });
|
|
218
|
+
}
|
|
219
|
+
if (typeof r.nextCursor !== 'string' || !r.nextCursor) break;
|
|
220
|
+
cursor = r.nextCursor;
|
|
221
|
+
}
|
|
222
|
+
return cleanTools(tools, this.secrets);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** `also`: more to take out of the result before it is cut (see cleanResult). */
|
|
226
|
+
async callTool(name: string, args: Record<string, unknown>, also?: (text: string) => string): Promise<ToolResult> {
|
|
227
|
+
return cleanResult(await this.#request('tools/call', { name, arguments: args }), this.secrets, also);
|
|
228
|
+
}
|
|
229
|
+
}
|