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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,790 @@
1
+ import { BROWSER_CONNECTOR } from '../shared/util.ts';
2
+ import { cliHome, cliHomesEnv } from '../server/src/cli-homes.ts';
3
+ import { sandboxProblem } from '../server/src/sandbox.ts';
4
+ import { readReleaseChecks, newerRelease, botonsReleaseVersion } from '../server/src/cli-updates.ts';
5
+ // `npm run doctor [-- --json] [-- --env-path <file>]` (in Docker: `botons doctor`): checks an install and says
6
+ // what to fix. Unless --fix is explicitly confirmed it only reads: it creates no folder, opens the database read-only, starts and changes nothing, and
7
+ // prints no secret. Exit 0 when nothing failed (warnings allowed), 1 when something did, 2 when it could not run.
8
+ // Node builtins only, so it also runs before `npm ci`.
9
+ import '../server/src/env-init.ts';
10
+ import { createInterface } from 'node:readline/promises';
11
+ import { Resolver } from 'node:dns/promises';
12
+ import { connect, type Socket } from 'node:net';
13
+ import { previewCodexRepairs, repairCodexConfig } from '../server/src/codex-config-repair.ts';
14
+ import { HOST_ACTOR, recordAudit } from '../server/src/audit.ts';
15
+ import { execFileSync, spawnSync } from 'node:child_process';
16
+ import { accessSync, copyFileSync, mkdtempSync, readdirSync, rmSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { homedir, tmpdir } from 'node:os';
18
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
19
+ import { DatabaseSync } from 'node:sqlite';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { parseArgs } from 'node:util';
22
+ import { BACKUP_STALE_MS, backupTime, listBackups } from '../server/src/backup.ts';
23
+ import { type Config, ConfigError, loadConfig, botonsHome } from '../server/src/config.ts';
24
+ import { codexConfigSymlinks } from '../server/src/isolation.ts';
25
+ import { legacyEnvNoticeText } from '../shared/legacy-names.mjs';
26
+ import { legacyLeftovers } from '../server/src/legacy-upgrade.ts';
27
+ import { checkCli, checkCodexCli, cliStatus, parseClaudeLogin, terminalVersion } from '../server/src/providers/cli-check.ts';
28
+ import { isManagedBin, managedRoot } from '../server/src/managed-clis.ts';
29
+ import { cliVersion, terminalNote } from '../shared/versions.ts';
30
+ import { effortProblem } from '../shared/effort.ts';
31
+ import { SCHEMA_VERSION, UPGRADABLE_VERSIONS } from '../server/src/schema.ts';
32
+ import { ATTENTION_DAYS } from '../shared/util.ts';
33
+ import { FILES_WARN_BYTES, formatSize, INBOX_WARN_BYTES } from '../shared/files.ts';
34
+ import { folderBytesSync } from '../server/src/disk-usage.ts';
35
+ import type { TailscaleSelf } from '../server/src/tailnet.ts';
36
+ import { tailscaleSelfSync } from '../server/src/tailnet.ts';
37
+ import { defaultUnitName, serverEnv } from './install-service.ts';
38
+
39
+ export type Status = 'ok' | 'info' | 'warn' | 'fail';
40
+ export interface Finding {
41
+ check: string;
42
+ status: Status;
43
+ message: string;
44
+ /** What to do about it, when there is something to do. */
45
+ fix?: string;
46
+ }
47
+
48
+ const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
49
+ const MIN_NODE = 24;
50
+ /** The error a run cut off by a restart ends with (server/src/store.ts, failInterruptedRuns). */
51
+ const INTERRUPTED = 'Interrupted by a restart';
52
+
53
+ export interface DoctorOptions {
54
+ env: NodeJS.ProcessEnv;
55
+ /** The service env file `env` came from, if any. */
56
+ envFile?: string | null;
57
+ app?: string;
58
+ /** Default: whether this looks like a container. */
59
+ container?: boolean;
60
+ nodeVersion?: string;
61
+ probe?: () => TailscaleSelf;
62
+ /** Default: GET /api/health over loopback. */
63
+ health?: (port: number) => Promise<{ status: number; body: any } | null>;
64
+ /** Default: the CLIs' own checks (`--version`, then `auth status` / `login status`). */
65
+ skipClis?: boolean;
66
+ now?: number;
67
+ /** Default: a quick lookup and connection to a well-known site over IPv4 and over IPv6 (only when a Chromium is found). */
68
+ egress?: () => Promise<Egress | null>;
69
+ }
70
+
71
+ /** Whether this server reaches the internet over each family: what the browser can connect over. */
72
+ export interface Egress {
73
+ v4: boolean;
74
+ v6: boolean;
75
+ }
76
+
77
+ /** A well-known site with both IPv4 and IPv6 addresses. */
78
+ const EGRESS_HOST = 'example.com';
79
+ const EGRESS_MS = 2000;
80
+
81
+ /** How the egress probe looks a name up and connects: the system's own by default, stubbed in tests. */
82
+ export interface EgressNet {
83
+ /** Addresses of `host` in one family; cancel() ends every lookup still under way. */
84
+ resolve(host: string, family: 4 | 6): Promise<string[]>;
85
+ cancel(): void;
86
+ connect(host: string, port: number): Socket;
87
+ }
88
+
89
+ function systemNet(): EgressNet {
90
+ // c-ares, not getaddrinfo: a lookup that outlives the deadline is cancelled, so it can't hold the doctor open.
91
+ const resolver = new Resolver({ timeout: EGRESS_MS, tries: 1 });
92
+ return {
93
+ resolve: (host, family) => (family === 4 ? resolver.resolve4(host) : resolver.resolve6(host)),
94
+ cancel: () => resolver.cancel(),
95
+ connect: (host, port) => connect({ host, port }),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Resolves EGRESS_HOST in each family and connects to its port 443, both families within one deadline of `ms`. At the
101
+ * deadline every lookup still under way is cancelled and every socket destroyed, and nothing starts after it.
102
+ */
103
+ export function probeEgress(net: EgressNet = systemNet(), ms = EGRESS_MS): Promise<Egress> {
104
+ const sockets = new Set<Socket>();
105
+ let over = false;
106
+ let timer: ReturnType<typeof setTimeout> | undefined;
107
+ const deadline = new Promise<void>((r) => {
108
+ timer = setTimeout(r, ms);
109
+ timer.unref();
110
+ });
111
+ const seen: Egress = { v4: false, v6: false };
112
+ const one = async (family: 4 | 6) => {
113
+ const [address] = await net.resolve(EGRESS_HOST, family);
114
+ if (over || !address) return;
115
+ const ok = await new Promise<boolean>((done) => {
116
+ const sock = net.connect(address, 443);
117
+ sockets.add(sock);
118
+ const end = (v: boolean) => {
119
+ sockets.delete(sock);
120
+ sock.destroy();
121
+ done(v);
122
+ };
123
+ sock.once('connect', () => end(true));
124
+ sock.once('error', () => end(false));
125
+ sock.once('close', () => end(false));
126
+ });
127
+ if (!over) seen[family === 4 ? 'v4' : 'v6'] = ok;
128
+ };
129
+ const both = Promise.all([one(4).catch(() => {}), one(6).catch(() => {})]);
130
+ return Promise.race([both, deadline]).then(() => {
131
+ over = true;
132
+ clearTimeout(timer);
133
+ net.cancel();
134
+ for (const s of sockets) s.destroy();
135
+ sockets.clear();
136
+ return { ...seen };
137
+ });
138
+ }
139
+
140
+ /** What the browser's network is, from `e`: informational unless it reaches nothing at all. */
141
+ export function egressFinding(e: Egress): { status: Status; message: string; fix?: string } {
142
+ if (e.v4 && e.v6) return { status: 'ok', message: 'The browser reaches sites over IPv4 and IPv6' };
143
+ if (e.v4) return { status: 'info', message: 'IPv6 isn\u2019t available on this server; the browser uses IPv4' };
144
+ if (e.v6) return { status: 'info', message: 'IPv4 isn\u2019t available on this server; the browser uses IPv6' };
145
+ return { status: 'warn', message: `Couldn\u2019t reach ${EGRESS_HOST} over IPv4 or IPv6: the browser may not load any site`, fix: 'Check this server\u2019s internet connection and DNS.' };
146
+ }
147
+
148
+ function inContainer(env: NodeJS.ProcessEnv): boolean {
149
+ if (env.BOTONS_CONTAINER === '1' || existsSync('/.dockerenv') || existsSync('/run/.containerenv')) return true;
150
+ try {
151
+ return /\b(docker|containerd|kubepods|libpod)\b/.test(readFileSync('/proc/1/cgroup', 'utf8'));
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /** Whether `path` is a mount point (a volume, in a container). Null when that can't be told. */
158
+ function isMountPoint(path: string): boolean | null {
159
+ try {
160
+ const mounts = readFileSync('/proc/self/mountinfo', 'utf8')
161
+ .split('\n')
162
+ .map((l) => l.split(' ')[4]?.replace(/\\040/g, ' '));
163
+ const target = resolve(path);
164
+ return mounts.some((m) => m === target);
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function onPath(cmd: string, path: string): string | null {
171
+ for (const dir of path.split(delimiter)) {
172
+ if (!dir || !isAbsolute(dir)) continue;
173
+ try {
174
+ accessSync(join(dir, cmd), constants.X_OK);
175
+ return join(dir, cmd);
176
+ } catch {}
177
+ }
178
+ return null;
179
+ }
180
+
181
+ async function defaultHealth(port: number): Promise<{ status: number; body: any } | null> {
182
+ try {
183
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(3000) });
184
+ return { status: res.status, body: await res.json().catch(() => null) };
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ const isLoopbackHost = (h: string) => h === 'localhost' || h === '::1' || h === '[::1]' || h.startsWith('127.');
191
+ const isTailnetHost = (h: string) => h.endsWith('.ts.net') || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h);
192
+
193
+ /**
194
+ * Each Boton's saved provider, model and configured effort ("none": the CLI's default), and routine overrides.
195
+ * The engine can omit a configured level when the model list changes. CLIs can also use a different level and
196
+ * do not report which ran. This read-only check describes saved configuration, not a turn's request. Effort is saved since schema 26; before that no turn asks for one.
197
+ */
198
+ function brainFindings(db: DatabaseSync, version: number, add: (check: string, status: Status, message: string, fix?: string) => void) {
199
+ const effort = version >= 26;
200
+ let bots: Record<string, unknown>[];
201
+ let routines: Record<string, unknown>[];
202
+ try {
203
+ bots = db.prepare(`select id, name, provider, model${effort ? ', effort' : ''} from bots order by created_at, rowid`).all();
204
+ routines = db.prepare(`select bot_id, name, model${effort ? ', effort' : ''} from routines where model is not null${effort ? ' or effort is not null' : ''} order by created_at, rowid`).all();
205
+ } catch {
206
+ return;
207
+ }
208
+ for (const b of bots) {
209
+ const provider = String(b.provider);
210
+ const level = (v: unknown) => {
211
+ const e = typeof v === 'string' ? v : '';
212
+ if (!e) return 'none (the CLI\'s default)';
213
+ return effortProblem(provider, e) ? `${e} (not one of ${provider}'s levels: not asked for)` : e;
214
+ };
215
+ const own = routines
216
+ .filter((r) => r.bot_id === b.id)
217
+ .map((r) => {
218
+ const bits = [r.model ? `model ${String(r.model)}` : '', r.effort ? `configured effort ${level(r.effort)}` : ''].filter(Boolean);
219
+ return `“${String(r.name)}” ${bits.join(', ')}`;
220
+ });
221
+ const model = String(b.model ?? '') || 'default';
222
+ add('brain', 'info', `${String(b.name)}: ${provider}, model ${model}, configured effort ${level(b.effort)}${own.length ? `; routines with their own: ${own.join('; ')}` : ''}`);
223
+ }
224
+ }
225
+
226
+ export async function diagnose(o: DoctorOptions): Promise<Finding[]> {
227
+ const out: Finding[] = [];
228
+ const orNull = <T>(get: () => T): T | null => {
229
+ try {
230
+ return get();
231
+ } catch {
232
+ return null;
233
+ }
234
+ };
235
+ const workspaceUnavailable: string[] = [];
236
+ const add = (check: string, status: Status, message: string, fix?: string) => out.push({ check, status, message, ...(fix ? { fix } : {}) });
237
+ const app = o.app ?? repo;
238
+ const env = o.env;
239
+ const container = o.container ?? inContainer(env);
240
+ const managed = basename(dirname(app)) === 'releases' && existsSync(join(dirname(dirname(app)), 'installer.json'));
241
+ const pairCommand = managed ? 'botons pair' : container ? 'docker compose exec botons botons pair' : 'npm run pair --';
242
+ const now = o.now ?? Date.now();
243
+ const where = container ? 'in the container' : 'on this machine';
244
+
245
+ // Node and the install.
246
+ const nodeVersion = o.nodeVersion ?? process.versions.node;
247
+ if (Number(nodeVersion.split('.')[0]) >= MIN_NODE) add('node', 'ok', `Node ${nodeVersion}`);
248
+ else add('node', 'fail', `Node ${nodeVersion} is too old`, `Install Node ${MIN_NODE} or newer.`);
249
+ if (!existsSync(join(app, 'node_modules', 'croner'))) add('install', 'fail', `${app} has no node_modules`, managed ? 'Run `botons repair`.' : 'Run `npm ci` there.');
250
+ else if (!existsSync(join(app, 'web', 'dist', 'index.html'))) add('install', 'warn', 'The web app is not built: the server answers with a "not built yet" page', managed ? 'Run `botons repair`.' : 'Run `npm run build`.');
251
+ else add('install', 'ok', `${app} is installed and its web app built`);
252
+ if (o.envFile) add('env file', 'info', `Settings from ${o.envFile} (BOTONS_HOME is not set in this shell)`);
253
+
254
+ // Settings, exactly as the server reads them, without creating anything.
255
+ let cfg: Config;
256
+ try {
257
+ cfg = loadConfig(env, o.probe ?? tailscaleSelfSync, { create: false });
258
+ } catch (err) {
259
+ if (!(err instanceof ConfigError)) throw err;
260
+ for (const e of err.errors) add('config', 'fail', e, 'Fix it in the environment (the service env file, or .env for Docker); the server refuses to start (exit 78) until then.');
261
+ return out;
262
+ }
263
+ // Deprecated names (shared/legacy-names.mjs): one line each.
264
+ for (const n of cfg.envNotices) add('config', 'warn', legacyEnvNoticeText(n));
265
+ for (const line of legacyLeftovers({ env })) add('deprecated', 'info', line);
266
+ add('config', 'ok', `Settings are valid: port ${cfg.port}, listening on ${cfg.hosts.join(', ')}${cfg.tailscale.mode === 'bind' ? ' and the Tailscale address' : ''}`);
267
+
268
+ // Who can reach it.
269
+ if (!cfg.authEnabled) add('auth', 'fail', 'BOTONS_AUTH=off: anyone who can reach the server can use it, Botons included', 'Remove BOTONS_AUTH as soon as a device is paired.');
270
+ const everywhere = cfg.hosts.some((h) => h === '0.0.0.0' || h === '::');
271
+ if (container) {
272
+ if (!everywhere) add('exposure', 'fail', 'In a container the server must listen on 0.0.0.0 for the published port to reach it', 'Set BOTONS_HOST=0.0.0.0 (the image does by default).');
273
+ else add('exposure', 'info', 'Listens on every interface of the container; the published port (compose.yaml: 127.0.0.1 unless BOTONS_BIND says otherwise) decides who reaches it');
274
+ } else if (everywhere) {
275
+ add('exposure', 'warn', 'BOTONS_HOST listens on every interface: the LAN (or the internet, on a VPS) reaches it unless a firewall stops it', 'Prefer the default (loopback plus Tailscale), or confirm the firewall before keeping it.');
276
+ } else add('exposure', 'ok', `Listens on ${cfg.hosts.join(', ')}${cfg.tailscale.mode === 'bind' ? ' and the Tailscale address' : ''} only`);
277
+ const publicHost = new URL(cfg.publicUrl).hostname.toLowerCase();
278
+ if (cfg.publicUrl.startsWith('http://') && !isLoopbackHost(publicHost) && !isTailnetHost(publicHost)) {
279
+ add('exposure', 'warn', `The public URL ${cfg.publicUrl} is plain HTTP on a non-tailnet address: device tokens would cross the network unencrypted`, 'Put HTTPS in front (Tailscale Serve or a reverse proxy, see docs/DOCKER.md) and set BOTONS_PUBLIC_URL to it.');
280
+ }
281
+ if (cfg.hooks.port !== null) add('exposure', 'info', `Webhooks-only listener on ${cfg.hooks.hosts.join(', ')}:${cfg.hooks.port}${cfg.hooks.publicUrl ? ` (public at ${cfg.hooks.publicUrl})` : ''}`);
282
+
283
+ if (cfg.tailscale.mode === 'off') add('tailscale', 'info', container ? 'Not used inside the container (BOTONS_NO_TAILSCALE=1): devices pair with a code' : 'Not used: devices pair with a code');
284
+ else if (cfg.tailscale.initial.ip) add('tailscale', 'ok', `Up as ${cfg.tailscale.initial.dnsName ?? cfg.tailscale.initial.ip}; your own devices pair with one click`);
285
+ else add('tailscale', 'info', 'Not up: only this machine reaches the server until it is (the server notices within BOTONS_TAILSCALE_POLL_MS)');
286
+
287
+ // The data folder and the database.
288
+ const home = cfg.home;
289
+ if (!existsSync(home)) add('data', 'info', `${home} does not exist yet; the server creates it at its first start`);
290
+ else {
291
+ const st = statSync(home);
292
+ if (!st.isDirectory()) add('data', 'fail', `${home} is not a folder`);
293
+ else {
294
+ try {
295
+ accessSync(home, constants.W_OK);
296
+ if (st.mode & 0o077) add('data', 'warn', `${home} can be read by other users (mode ${(st.mode & 0o777).toString(8)})`, `chmod 700 ${home}`);
297
+ else add('data', 'ok', `${home} is private to its owner`);
298
+ } catch {
299
+ add('data', 'fail', `${home} is not writable by this user`, 'Run the server (and this) as the user that owns the data folder.');
300
+ }
301
+ }
302
+ if (container) {
303
+ const mounted = isMountPoint(home);
304
+ if (mounted === false) add('data', 'fail', `${home} is not a volume: everything in it is lost with the container`, 'Mount a volume there (compose.yaml does).');
305
+ const userHome = homedir();
306
+ if (isMountPoint(userHome) === false) add('data', 'warn', `${userHome} is not a volume: the CLI logins in it are lost with the container`, 'Mount a volume there (compose.yaml does).');
307
+ }
308
+ }
309
+ const dbPath = cfg.dbPath;
310
+ /** The names of the Botons on each brain (by provider id: claude-cli, codex-cli), oldest first. */
311
+ const botonsOn = new Map<string, string[]>();
312
+ if (existsSync(dbPath)) {
313
+ const snapshot = mkdtempSync(join(tmpdir(), 'botons-doctor-'));
314
+ try {
315
+ // SQLite read-only opens can still create WAL sidecars. Inspect a disposable copy including committed WAL.
316
+ // This diagnostic snapshot is best effort if the server is writing concurrently.
317
+ const copy = join(snapshot, 'botons.db');
318
+ copyFileSync(dbPath, copy);
319
+ for (const suffix of ['-wal', '-shm']) if (existsSync(dbPath + suffix)) copyFileSync(dbPath + suffix, copy + suffix);
320
+ const db = new DatabaseSync(copy, { readOnly: true });
321
+ try {
322
+ db.exec('pragma busy_timeout = 2000');
323
+ const version = Number(db.prepare('pragma user_version').get()!.user_version);
324
+ if (version === SCHEMA_VERSION) add('database', 'ok', `Schema v${version}`);
325
+ else if (UPGRADABLE_VERSIONS.includes(version)) add('database', 'info', `Schema v${version}; this code upgrades it to v${SCHEMA_VERSION} at its next start (back it up first)`);
326
+ else if (version > SCHEMA_VERSION) add('database', 'fail', `Schema v${version} is newer than this code's v${SCHEMA_VERSION}`, 'Run the release that wrote it, or restore an older backup.');
327
+ else add('database', 'fail', `Schema v${version} can't be opened by this code (v${SCHEMA_VERSION})`, 'Restore a backup made by this release.');
328
+ const devices = orZero(() => Number(db.prepare("select count(*) as n from auth_tokens where kind = 'device'").get()!.n));
329
+ if (devices === 0 && cfg.authEnabled) add('devices', 'warn', 'No device is paired yet', `Open the web app, which shows a code, and approve it: ${pairCommand} --approve <code>`);
330
+ else if (devices > 0) add('devices', 'ok', `${devices} paired device${devices === 1 ? '' : 's'}`);
331
+ // The owner's access mode (Settings → Access): which networks the server accepts requests from.
332
+ const mode = orNull(() => String(db.prepare("select value from settings where key = 'accessMode'").get()?.value ?? '')) || null;
333
+ const modeCmd = `${pairCommand} --access-mode <mode>`;
334
+ if (!mode) add('access', 'info', 'No access mode chosen: only the listeners decide which networks connect', `Choose one in Settings → Access, or ${modeCmd}`);
335
+ else if (mode === 'public' && !(cfg.publicUrlOverride ?? '').startsWith('https://')) add('access', 'fail', 'The access mode is public, but BOTONS_PUBLIC_URL is not https://', `Put an HTTPS proxy in front and set BOTONS_PUBLIC_URL, or ${modeCmd}`);
336
+ else add('access', 'ok', `Access mode: ${mode}`);
337
+ for (const b of orNull(() => db.prepare('select provider, name from bots order by created_at, rowid').all()) ?? []) {
338
+ const id = String(b.provider);
339
+ botonsOn.set(id, [...(botonsOn.get(id) ?? []), String(b.name)]);
340
+ }
341
+ brainFindings(db, version, add);
342
+ // A brain at its usage limit: its Botons' chats and runs wait for the reset, then carry on by themselves.
343
+ const limits = orNull(() => db.prepare('select provider, until from provider_cooldowns where until > ? order by provider').all(new Date(now).toISOString())) ?? [];
344
+ for (const l of limits) {
345
+ if (!Number.isFinite(Date.parse(String(l.until)))) {
346
+ add('limits', 'warn', 'A stored provider usage-limit reset time is invalid', 'Check the provider usage-limit state before relying on automatic recovery.');
347
+ continue;
348
+ }
349
+ const on = botonsOn.get(String(l.provider)) ?? [];
350
+ const who = on.length === 1 ? `${on[0]} waits` : on.length ? `${botonList(on)} wait` : 'its Botons wait';
351
+ const carry = on.length === 1 ? 'carries' : 'carry';
352
+ add(
353
+ 'limits',
354
+ 'info',
355
+ `${String(l.provider).replace(/-cli$/, '')} reached its usage limit: ${who} until ${utc(String(l.until))}, then ${carry} on by ${on.length === 1 ? 'itself' : 'themselves'}, unless paused (resume first)`,
356
+ 'To carry on sooner, move a Boton to another signed-in brain in its settings: what it was waiting to do runs there, in a new session, unless paused (resume first).',
357
+ );
358
+ }
359
+ connectorFindings(db, version, add);
360
+ // Routine runs that need the owner: failed and not retried (test runs, and runs the owner cancelled, aside), or waiting to try again.
361
+ // Since 23, the runs table and its meta are as this code reads them.
362
+ if (version >= 23 && version <= SCHEMA_VERSION) {
363
+ const since = new Date(now - ATTENTION_DAYS * 86_400_000).toISOString();
364
+ const failed = orNull(() =>
365
+ db
366
+ .prepare(
367
+ `select count(*) as n, coalesce(sum(error = ?), 0) as cut from routine_runs where status = 'failed' and trigger != 'test' and finished_at >= ?
368
+ and json_extract(coalesce(meta, '{}'), '$.retriedAs') is null and json_extract(coalesce(meta, '{}'), '$.cancelled') is null`,
369
+ )
370
+ .get(INTERRUPTED, since),
371
+ );
372
+ const held = orZero(() => Number(db.prepare("select count(*) as n from routine_runs where status = 'queued' and trigger != 'test' and retry_at is not null").get()!.n));
373
+ const n = Number(failed?.n ?? 0);
374
+ const cut = Number(failed?.cut ?? 0);
375
+ const days = `the last ${ATTENTION_DAYS} days`;
376
+ if (failed && n === 0) add('runs', 'ok', `No routine run failed in ${days}${held ? '' : ' or is waiting to try again'}`);
377
+ else if (failed) {
378
+ add(
379
+ 'runs',
380
+ 'info',
381
+ `${n} routine run${n === 1 ? '' : 's'} failed in ${days} and ${n === 1 ? 'was' : 'were'} not retried${cut ? ` (${cut} interrupted by a restart)` : ''}`,
382
+ 'Settings → Runs to check lists them; retry one there if it still matters (it may have partly run).',
383
+ );
384
+ }
385
+ if (held) add('runs', 'info', `${held} routine run${held === 1 ? ' is' : 's are'} waiting to try again by ${held === 1 ? 'itself' : 'themselves'} (a network error or a usage limit)`);
386
+ // Results the routine's callback URL never got, every retry spent (the server sends pending ones again at its start).
387
+ const undelivered = orZero(() =>
388
+ Number(
389
+ db
390
+ .prepare(
391
+ `select count(*) as n from routine_runs r where trigger != 'test' and finished_at >= ?
392
+ and json_extract(meta, '$.callback.ok') = 0 and coalesce(json_extract(meta, '$.callback.pending'), 0) = 0
393
+ and (select callback_url from routines t where t.id = r.routine_id) is not null`,
394
+ )
395
+ .get(since)!.n,
396
+ ),
397
+ );
398
+ if (undelivered) {
399
+ add(
400
+ 'runs',
401
+ 'info',
402
+ `${undelivered} routine run${undelivered === 1 ? '' : 's'} in ${days} could not send ${undelivered === 1 ? 'its result' : 'their results'} to the callback URL`,
403
+ 'Settings → Runs to check lists them; fix the receiver, then resend from there.',
404
+ );
405
+ }
406
+ }
407
+ storageFinding(db, cfg, add);
408
+ } finally {
409
+ db.close();
410
+ }
411
+ } catch (err) {
412
+ add('database', 'fail', `Cannot read ${dbPath}: ${(err as Error).message}`);
413
+ } finally { rmSync(snapshot, { recursive: true, force: true }); }
414
+ } else add('database', 'info', `No database at ${dbPath} yet; the server creates it at its first start`);
415
+
416
+ const backups = listBackups(cfg.backupsDir);
417
+ const newest = backups.at(-1);
418
+ if (!existsSync(dbPath)) {
419
+ // Nothing to back up yet.
420
+ } else if (!newest) add('backups', 'warn', 'No backup yet', managed ? 'Run `botons backup`; check the nightly timer with `systemctl --user status botons-backup.timer`.' : container ? 'docker compose exec botons botons backup, and schedule it (docs/DOCKER.md)' : 'npm run backup, and npm run backup:install for the nightly timer');
421
+ else {
422
+ const age = now - backupTime(newest);
423
+ if (age > BACKUP_STALE_MS) add('backups', 'warn', `The newest backup (${newest}) is ${Math.floor(age / 86_400_000)} days old`, 'Check the backup timer or cron job.');
424
+ else add('backups', 'ok', `Newest backup ${newest} (${backups.length} kept)`);
425
+ }
426
+
427
+ // The running server.
428
+ const health = await (o.health ?? defaultHealth)(cfg.port);
429
+ const holder = serverPid(home);
430
+ if (!health) add('server', 'info', `Nothing answers on 127.0.0.1:${cfg.port} ${where}: the server is not running here${holder ? ` (yet ${join(home, 'server.pid')} names live pid ${holder})` : ''}`);
431
+ else if (!holder && !container) add('server', 'warn', `A Botons server answers on port ${cfg.port}, but none is using ${home}: another install (or data folder) is on that port`, 'Check BOTONS_HOME and BOTONS_PORT.');
432
+ else if (health.status === 200) add('server', 'ok', `Running and healthy${health.body?.build ? ` (build ${String(health.body.build).slice(0, 12)})` : ''}`);
433
+ else {
434
+ const failing = Array.isArray(health.body?.failing) ? health.body.failing.join(', ') : `HTTP ${health.status}`;
435
+ add('server', 'warn', `Running but unhealthy: ${failing}`, failing.includes('providers') ? 'Sign in to a CLI (see "agent CLIs" below).' : undefined);
436
+ }
437
+
438
+ // The agent CLIs, as the server runs them.
439
+ if (!o.skipClis) {
440
+ const signIn = container ? (c: string) => `docker compose exec -it botons ${c}` : (c: string) => c;
441
+ const results = await Promise.all([
442
+ checkCli(cfg.claudeBin, ['auth', 'status'], parseClaudeLogin).then((c) => ({ name: 'claude', login: 'claude auth login', c })),
443
+ checkCodexCli(cfg.codexBin).then((c) => ({ name: 'codex', login: 'codex login', c })),
444
+ ]);
445
+ const moveThem = (n: number) => `or move ${n === 1 ? 'that Boton' : 'those Botons'} to another brain in ${n === 1 ? 'its' : 'their'} settings`;
446
+ for (const { name, login, c } of results) {
447
+ const updateFix = `Update ${name === 'claude' ? 'Claude Code' : 'Codex'} in Settings → Brains.`;
448
+ const release = readReleaseChecks(cfg.home)[`${name}-cli` as 'claude-cli' | 'codex-cli'];
449
+ if (release && newerRelease(c.version, release.latest)) add('CLI updates', 'warn', `${name}: update available (${release.latest})${release.failed ? '; latest check failed, showing saved release' : ''} — ${updateFix}`, updateFix);
450
+ if (c.workspaceProblem) workspaceUnavailable.push('Codex');
451
+ else if (name === 'codex' && c.installed && c.problem !== 'incompatible-version') add('Codex Workspace', 'ok', 'Offline filesystem helper probe passed (no login or model request).');
452
+ const id = `${name}-cli` as 'claude-cli' | 'codex-cli';
453
+ const bin = name === 'claude' ? cfg.claudeBin : cfg.codexBin;
454
+ const managed = isManagedBin(bin, id, managedRoot(env.HOME || homedir()));
455
+ const copy = { managed, terminal: managed && c.installed ? await terminalVersion(id, bin) : null };
456
+ const version = cliVersion(c.version) ?? c.version;
457
+ const label = `${name}${version ? ` ${version}` : ''} (${managed ? "Botons' own copy" : 'system copy'})`;
458
+ const note = terminalNote(id, c.version, copy);
459
+ if (note) add('CLI copies', 'info', note);
460
+ const on = botonsOn.get(`${name}-cli`) ?? [];
461
+ const uses = on.length ? `${botonList(on)} ${on.length === 1 ? 'uses' : 'use'} it` : '';
462
+ if (!c.installed) {
463
+ if (!on.length) add('agent CLIs', 'info', `${name}: not installed (${c.reason})`);
464
+ else add('agent CLIs', 'fail', `${name}: not installed (${c.reason}); ${uses} and can't answer`, `Install it where the server finds it (its PATH, or BOTONS_${name.toUpperCase()}_BIN), ${moveThem(on.length)}.`);
465
+ } else if (c.problem === 'incompatible-version') {
466
+ add('agent CLIs', on.length ? 'fail' : 'warn', `${label}: needs update${on.length ? `; ${uses} and can't answer` : ''}`, `${c.reason} ${updateFix}`);
467
+ } else if (c.loggedIn === false) {
468
+ add('agent CLIs', on.length ? 'fail' : 'warn', `${label}: not signed in${on.length ? `; ${uses} and can't answer until it is` : ''}`, `${signIn(login)} (the owner signs in; it creates a credential)${on.length ? `, ${moveThem(on.length)}` : ''}`);
469
+ } else if (c.loggedIn === null) add('agent CLIs', on.length ? 'fail' : 'warn', `${label}: ${c.reason}${on.length ? ` (${uses})` : ''}`);
470
+ else add('agent CLIs', 'ok', `${label}: signed in${on.length ? `; ${uses}` : ''}`);
471
+ }
472
+ if (!results.some((r) => cliStatus(r.c).available)) {
473
+ add('agent CLIs', [...botonsOn.keys()].some(id => id === 'claude-cli' || id === 'codex-cli') ? 'fail' : 'info', 'No agent CLI can run turns, so no Boton can answer', 'Install claude or codex and sign in to it, as the user the server runs as.');
474
+ }
475
+ }
476
+
477
+ const botonsRelease = readReleaseChecks(cfg.home).botons;
478
+ if (botonsRelease && newerRelease(botonsReleaseVersion(o.app ?? repo), botonsRelease.latest)) add('Botons updates', 'info', `Botons ${botonsRelease.latest} is available`, 'For installer installs run botons upgrade with the release source. Botons never upgrades itself automatically.');
479
+ for (const kind of ['codex', 'claude'] as const) {
480
+ if (cliHome(kind, env).pending) add(`${kind} sign-in`, 'warn', 'Botons is using your personal sign-in to keep your Botons working.', 'Open Settings → Brains to give Botons its own sign-in. Your Botons keep working until you choose.');
481
+ }
482
+ const linkedConfig = codexConfigSymlinks({ ...env, ...cliHomesEnv(env) }, env.HOME || homedir());
483
+ if (linkedConfig.length) add('Codex config', 'warn',
484
+ `Symlinked protected config paths: ${linkedConfig.join(', ')}. Workspace Codex can fail loading AGENTS.md with "Can't mount on symlink destination".`,
485
+ 'Keep Workspace sandboxing enabled. The owner can replace the named config links with regular files or directories containing reviewed copies, then retry. Run `botons doctor --fix` in an interactive terminal to review and confirm a regular-file copy, or sign in separately for Botons in Settings → Brains. Directory links need review on the server. Upgrading Codex alone may not fix this; check the server user’s CODEX_HOME, including AGENTS.md and skills.');
486
+
487
+ // The Claude sandbox that Workspace-access Botons run commands in (Linux: bubblewrap and socat).
488
+ if (process.platform === 'linux') {
489
+ const path = env.PATH ?? '';
490
+ const missing = ['bwrap', 'socat'].filter((c) => !onPath(c, path));
491
+ if (missing.length) workspaceUnavailable.push('Claude');
492
+ else {
493
+ const r = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-net', '--unshare-pid', 'true'], { encoding: 'utf8', timeout: 5000, env: { PATH: path } });
494
+ if (r.status === 0) add('sandbox', 'info', 'Claude namespace probe passed; the production Workspace tools and Codex helpers are checked separately.');
495
+ else {
496
+ workspaceUnavailable.push('Claude');
497
+ }
498
+ }
499
+ }
500
+
501
+ if (process.platform === 'linux') {
502
+ const bin = onPath('bwrap', env.PATH ?? '');
503
+ const problem = sandboxProblem(bin ?? '/nonexistent/bwrap', { quiet: true });
504
+ if (problem) workspaceUnavailable.push('API tools');
505
+ else add('Workspace tools', 'ok', 'The production overlay, private /proc and seccomp sandbox probe passed.');
506
+ }
507
+
508
+ if (workspaceUnavailable.length) add('Workspace', 'info', `Workspace is unavailable for ${workspaceUnavailable.join(', ')} on this server. Use Full computer access.`);
509
+
510
+ // The managed browser: its library, a Chromium, and that Chromium starting the way the server starts it (in a temp
511
+ // profile, removed after). Optional, so a warning at worst: Botons without the browser connector don't need it.
512
+ {
513
+ const docker = 'see "Browser" in docs/DOCKER.md';
514
+ if (!existsSync(join(app, 'node_modules', 'playwright-core', 'package.json'))) {
515
+ add('browser', 'warn', 'playwright-core is not installed: Botons get no browser', managed ? 'Run `botons repair`.' : 'Run `npm ci` in the app folder.');
516
+ } else {
517
+ const exe = findChromium(cfg.browser.path, env);
518
+ if (!exe) {
519
+ add(
520
+ 'browser',
521
+ 'warn',
522
+ cfg.browser.path ? `BOTONS_BROWSER_PATH is ${cfg.browser.path}, which is not there` : 'No Chromium found: Botons get no browser',
523
+ container ? 'The image installs one; rebuild it.' : 'Install the chromium package (needs the owner), or `npx playwright-core install chromium`, or set BOTONS_BROWSER_PATH.',
524
+ );
525
+ } else {
526
+ // Before Chromium's start, which holds this process: its timers would fire late and miss an answer.
527
+ const seen = await (o.egress ?? probeEgress)();
528
+ const net = seen ? egressFinding(seen) : null;
529
+ const r = startChromium(exe, cfg.browser.sandbox);
530
+ if (r.ok) add('browser', 'ok', `${exe} starts${cfg.browser.sandbox ? ', with its sandbox' : ' without its sandbox (BOTONS_BROWSER_SANDBOX=0)'}`);
531
+ else {
532
+ const sandbox = cfg.browser.sandbox && /sandbox|namespace|clone|setuid/i.test(r.why);
533
+ add(
534
+ 'browser',
535
+ 'warn',
536
+ `${exe} did not start (${r.why}): Botons get no browser`,
537
+ sandbox ? (container ? `Chromium's sandbox can't start in this container: ${docker}.` : "Chromium's sandbox needs unprivileged user namespaces (needs the owner).") : container ? `Rebuild the image, or ${docker}.` : 'Check that Chromium runs for this user.',
538
+ );
539
+ }
540
+ if (net) add('browser', net.status, net.message, net.fix);
541
+ }
542
+ }
543
+ }
544
+
545
+ // The systemd service, outside containers.
546
+ if (!container && process.platform === 'linux') {
547
+ const unitDir = join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user');
548
+ // botons.service, or the deprecated unit an install runs under until `npm run migrate:names` (legacy-upgrade.ts).
549
+ const unit = defaultUnitName(unitDir);
550
+ if (!existsSync(join(unitDir, `${unit}.service`))) add('service', 'info', 'No botons.service user unit (fine if you run it another way)');
551
+ else {
552
+ let state = 'unknown';
553
+ try {
554
+ state = execFileSync('systemctl', ['--user', 'is-active', unit], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
555
+ } catch (err) {
556
+ state = String((err as { stdout?: string }).stdout ?? '').trim() || 'unknown';
557
+ }
558
+ add('service', state === 'active' ? 'ok' : 'warn', `${unit}.service is ${state}`, state === 'active' ? undefined : `journalctl --user -u ${unit} -n 50`);
559
+ }
560
+ }
561
+ return out;
562
+ }
563
+
564
+ /**
565
+ * One line on what the Botons' files take: workspaces (their inboxes too), the trash and the shared folder. A warning
566
+ * past INBOX_WARN_BYTES in one inbox or FILES_WARN_BYTES in all, pointing at Clear inbox and the trash in Files.
567
+ */
568
+ function storageFinding(db: DatabaseSync, cfg: Config, add: (check: string, status: Status, message: string, fix?: string) => void) {
569
+ let bots: { name: string; workspace: string }[];
570
+ try {
571
+ bots = db.prepare('select name, workspace from bots order by created_at, rowid').all() as typeof bots;
572
+ } catch {
573
+ return;
574
+ }
575
+ let workspaces = 0;
576
+ let inboxes = 0;
577
+ let partial = false;
578
+ const bigInboxes: string[] = [];
579
+ for (const b of bots) {
580
+ const ws = resolve(cfg.workspacesDir, String(b.workspace));
581
+ const w = folderBytesSync(ws);
582
+ const i = folderBytesSync(join(ws, 'inbox'));
583
+ workspaces += w.bytes;
584
+ inboxes += i.bytes;
585
+ partial ||= w.partial || i.partial;
586
+ if (i.bytes > INBOX_WARN_BYTES) bigInboxes.push(`${String(b.name)} ${formatSize(i.bytes)}`);
587
+ }
588
+ const trash = folderBytesSync(join(cfg.home, 'trash'));
589
+ const shared = folderBytesSync(cfg.sharedDir);
590
+ const total = workspaces + trash.bytes + shared.bytes;
591
+ const at = partial || trash.partial || shared.partial ? 'at least ' : '';
592
+ const line = `Files take ${at}${formatSize(total)}: workspaces ${formatSize(workspaces)} (inboxes ${formatSize(inboxes)}), trash ${formatSize(trash.bytes)}, shared ${formatSize(shared.bytes)}`;
593
+ if (bigInboxes.length || total > FILES_WARN_BYTES) {
594
+ add('storage', 'warn', `${line}${bigInboxes.length ? `; big inbox: ${bigInboxes.join(', ')}` : ''}`, 'In a Boton\'s Files: Clear inbox moves its inbox to the trash (kept files stay), and Trash → Empty trash frees the space.');
595
+ } else add('storage', 'info', line);
596
+ }
597
+
598
+ /** The Chromium the server would run: BOTONS_BROWSER_PATH, else one Playwright downloaded, else the system's (browser.ts). */
599
+ export function findChromium(path: string, env: NodeJS.ProcessEnv): string | null {
600
+ if (path) return existsSync(path) ? path : null;
601
+ const cache = env.PLAYWRIGHT_BROWSERS_PATH || join(env.HOME || homedir(), '.cache', 'ms-playwright');
602
+ let bundled: string[] = [];
603
+ try {
604
+ bundled = readdirSync(cache)
605
+ .filter((d) => /^chromium-\d+$/.test(d))
606
+ .sort()
607
+ .reverse()
608
+ .flatMap((d) => ['chrome-linux64/chrome', 'chrome-linux/chrome', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'].map((f) => join(cache, d, f)));
609
+ } catch {}
610
+ const system = ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/opt/google/chrome/chrome'];
611
+ return [...bundled, ...system].find((p) => existsSync(p)) ?? null;
612
+ }
613
+
614
+ /** Starts it headless on a blank page in a temp profile and checks it rendered, then removes the profile. */
615
+ function startChromium(exe: string, sandbox: boolean): { ok: true } | { ok: false; why: string } {
616
+ const profile = mkdtempSync(join(tmpdir(), 'botons-doctor-browser-'));
617
+ try {
618
+ const args = ['--headless=new', '--no-first-run', '--disable-gpu', '--disable-extensions', '--disable-background-networking', `--user-data-dir=${profile}`, ...(sandbox ? [] : ['--no-sandbox']), '--dump-dom', 'data:text/html,<p>botons-browser-ok</p>'];
619
+ // Its home is the temp folder too, so nothing (a key store, a crash report) lands in this user's own.
620
+ const env = { PATH: process.env.PATH ?? '', HOME: profile, XDG_CONFIG_HOME: profile, XDG_CACHE_HOME: profile, XDG_DATA_HOME: profile };
621
+ const r = spawnSync(exe, args, { encoding: 'utf8', timeout: 30_000, env });
622
+ if (r.stdout?.includes('botons-browser-ok')) return { ok: true };
623
+ const lines = `${r.stderr ?? ''}\n${r.error?.message ?? ''}`.split('\n').map((l) => l.replace(/^\[[^\]]*\]\s*/, '').trim()).filter(Boolean);
624
+ const why = lines.find((l) => /sandbox|namespace|error|cannot|fail/i.test(l)) ?? lines[0] ?? (r.signal ? `killed by ${r.signal}` : `exit ${r.status}`);
625
+ return { ok: false, why: why.slice(0, 200) };
626
+ } finally {
627
+ rmSync(profile, { recursive: true, force: true });
628
+ }
629
+ }
630
+
631
+ /** The pid in <home>/server.pid if that process is alive (the lock's holder; see server/src/lock.ts). */
632
+ const listOf = (v: unknown): string[] => {
633
+ try {
634
+ const x = JSON.parse(String(v));
635
+ return Array.isArray(x) ? x.filter((i): i is string => typeof i === 'string') : [];
636
+ } catch {
637
+ return [];
638
+ }
639
+ };
640
+
641
+ /**
642
+ * Botons whose connectors can't be used: from 23 on, ones an upgrade took off (connectors from a CLI's own
643
+ * settings, which Botons no longer uses), ones the last turn went without, and grants of connectors that are gone
644
+ * or need signing in again; at 22, the ones the upgrade to 23 will take off.
645
+ */
646
+ export function connectorFindings(db: DatabaseSync, version: number, add: (check: string, status: Status, message: string, fix?: string) => void): void {
647
+ const rows = (sql: string) => {
648
+ try {
649
+ return db.prepare(sql).all();
650
+ } catch {
651
+ return [];
652
+ }
653
+ };
654
+ const bots = rows('select id, name, connections from bots order by created_at, rowid');
655
+ const where = 'Open the Boton\'s settings: Connectors lists each with what to do (give it a Botons connector, sign in again, or uncheck it).';
656
+ if (version === 22) {
657
+ const losing = bots.map((b) => ({ name: String(b.name), ids: listOf(b.connections).filter((id) => /^(claude|codex):/.test(id)) })).filter((b) => b.ids.length);
658
+ for (const b of losing) add('connectors', 'info', `The upgrade takes ${b.name}'s connectors from the CLIs' own settings off it: ${b.ids.join(', ')}`, 'Each becomes a notice in its settings, offering a Botons connector with the same name or address if there is one.');
659
+ return;
660
+ }
661
+ // Every version since 23 has Botons' own connectors and their notices.
662
+ if (version < 23 || version > SCHEMA_VERSION) return;
663
+ const library = new Set([BROWSER_CONNECTOR, ...rows('select name from mcp_servers').map((r) => `lib:${r.name}`)]);
664
+ const oauth = new Map(rows('select name, status from oauth_connections').map((r) => [`oauth:${r.name}`, String(r.status)]));
665
+ const notices = rows('select bot_id, connector_id, kind, reason from connector_notices order by at');
666
+ let problems = 0;
667
+ for (const b of bots) {
668
+ const removed = notices.filter((n) => n.bot_id === b.id && n.kind === 'removed').map((n) => String(n.connector_id));
669
+ const skipped = new Map(notices.filter((n) => n.bot_id === b.id && n.kind === 'skipped').map((n) => [String(n.connector_id), String(n.reason)]));
670
+ const unusable = listOf(b.connections).flatMap((id) => {
671
+ const why = library.has(id) ? null : oauth.has(id) ? (oauth.get(id) === 'connected' ? null : 'needs signing in') : 'not a Botons connector any more';
672
+ return why ? [`${id} (${why})`] : skipped.has(id) ? [`${id} (its last turn went without it: ${skipped.get(id)})`] : [];
673
+ });
674
+ if (removed.length) add('connectors', 'warn', `${b.name} lost connectors from the CLIs' own settings in the upgrade: ${removed.join(', ')}`, where);
675
+ if (unusable.length) add('connectors', 'warn', `${b.name} has connectors it can't use: ${unusable.join(', ')}`, where);
676
+ problems += removed.length + unusable.length;
677
+ }
678
+ if (!problems && bots.some((b) => listOf(b.connections).length)) add('connectors', 'ok', "Every Boton's connectors can be used");
679
+ }
680
+
681
+ function serverPid(home: string): number | null {
682
+ try {
683
+ const pid = Number(readFileSync(join(home, 'server.pid'), 'utf8').trim());
684
+ if (!Number.isInteger(pid) || pid <= 0) return null;
685
+ try {
686
+ process.kill(pid, 0);
687
+ } catch (err) {
688
+ if ((err as NodeJS.ErrnoException).code !== 'EPERM') return null;
689
+ }
690
+ return pid;
691
+ } catch {
692
+ return null;
693
+ }
694
+ }
695
+
696
+ /** "Juniper", "Juniper and Rowan", "3 Botons (Juniper, Rowan and Wren)", "5 Botons (Juniper, Rowan, Wren and 2 more)". */
697
+ function botonList(names: string[]): string {
698
+ const shown = names.length > 3 ? [...names.slice(0, 3), `${names.length - 3} more`] : names;
699
+ const list = shown.length > 1 ? `${shown.slice(0, -1).join(', ')} and ${shown.at(-1)}` : shown[0];
700
+ return names.length > 2 ? `${names.length} Botons (${list})` : list;
701
+ }
702
+
703
+ const utc = (iso: string) => `${new Date(iso).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
704
+
705
+ function orZero(fn: () => number): number {
706
+ try {
707
+ return fn();
708
+ } catch {
709
+ return 0;
710
+ }
711
+ }
712
+
713
+ /** Setup tasks do not make a healthy fresh install fail. */
714
+ export function isProblem(f: Finding): boolean { return f.status === 'fail'; }
715
+ export function report(findings: Finding[], verbose = false): string {
716
+ const groups: Record<string, string> = { access: 'Access', devices: 'Access', exposure: 'Access', tailscale: 'Access', 'agent CLIs': 'Brains', 'CLI updates': 'Brains', 'Codex config': 'Brains', sandbox: 'Sandbox', browser: 'Sandbox', backups: 'Backups' };
717
+ const lines: string[] = [];
718
+ const color = process.stdout.isTTY && !('NO_COLOR' in process.env) && !process.env.CI;
719
+ for (const section of ['Server', 'Access', 'Brains', 'Sandbox', 'Backups']) {
720
+ const items = findings.filter(f => (groups[f.check] || 'Server') === section);
721
+ if (!items.length) continue;
722
+ lines.push(section);
723
+ for (const f of items) {
724
+ const problem = isProblem(f);
725
+ const mark = problem ? '✗' : f.status === 'ok' ? '✓' : '•';
726
+ const message = f.check === 'Codex config' ? 'Review Codex config links in Settings → Brains' : f.message.split(/\r?\n/)[0].split('. Workspace Codex')[0];
727
+ lines.push(` ${color ? `\x1b[${problem ? 31 : f.status === 'ok' ? 32 : 36}m${mark}\x1b[0m` : mark} ${f.check}: ${message}`);
728
+ if (verbose) lines.push(` ${f.message}${f.fix ? `\n → ${f.fix}` : ''}`);
729
+ }
730
+ }
731
+ const problems = findings.filter(isProblem).length;
732
+ const steps = findings.filter(f => f.status !== 'ok' && !isProblem(f)).length;
733
+ lines.push(`${problems} problem${problems === 1 ? '' : 's'}, ${steps} next step${steps === 1 ? '' : 's'}.${verbose ? '' : ' Details: doctor --verbose.'}`);
734
+ return lines.join('\n');
735
+ }
736
+
737
+ async function main(): Promise<number> {
738
+ const { values } = parseArgs({ options: { verbose: { type: 'boolean' }, json: { type: 'boolean' }, 'env-path': { type: 'string' }, 'skip-clis': { type: 'boolean' }, 'skip-network': { type: 'boolean' }, fix: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } } });
739
+ if (values.help) {
740
+ console.log(`Usage: npm run doctor [-- --json] [-- --env-path <file>] [-- --skip-clis] [-- --skip-network] [-- --fix]
741
+
742
+ Checks Node, the install, the settings, exposure, the data folder and database,
743
+ routine runs to check, usage limits, backups, the running server, the agent CLIs
744
+ (and the Botons each one leaves without answers), the sandbox and the service,
745
+ and says what to fix. Read-only unless --fix is given. --skip-network leaves out
746
+ the browser's quick IPv4 and IPv6 check against example.com.
747
+ --fix previews Codex config link repairs and asks for each confirmation on a terminal. Uses BOTONS_HOME if set, else the service's env
748
+ file (--env-path, default ~/.config/botons/env), else the defaults.`);
749
+ return 0;
750
+ }
751
+ const { env, from } = serverEnv(values['env-path']);
752
+ // The CLIs' checks run with the agents' environment, which takes PATH from this process.
753
+ if (from && env.PATH) process.env.PATH = env.PATH;
754
+ if (values.fix) {
755
+ if (values.json || !process.stdin.isTTY || !process.stdout.isTTY) throw new Error('--fix requires an interactive terminal and cannot be combined with --json. Run doctor without --fix for read-only checks.');
756
+ const repairs = previewCodexRepairs(env);
757
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
758
+ try {
759
+ for (const repair of repairs) {
760
+ if (!repair.token) { console.log(`${repair.path}: ${repair.problem}`); continue; }
761
+ console.log(`Replace symlink ${JSON.stringify(repair.path)} with a regular copy of ${JSON.stringify(repair.target)}.
762
+ Keep the original symlink at ${JSON.stringify(repair.backup)}. The target stays unchanged. Relative backup links may not resolve until restored to the original path. The copy is private to the server user and stops following target changes. This repair is audited.`);
763
+ if ((await terminal.question('Type REPAIR to confirm this exact copy (anything else cancels): ')) !== 'REPAIR') { console.log('Cancelled; no files changed.'); continue; }
764
+ const cfg = loadConfig(env);
765
+ if (!existsSync(cfg.dbPath)) throw new Error('Start Botons once before repairing: the repair requires its audit database.');
766
+ const db = new DatabaseSync(cfg.dbPath);
767
+ try {
768
+ const result = repairCodexConfig(repair.id, repair.token, (phase, item) => recordAudit(db, HOST_ACTOR, {
769
+ action: `brain.config_repair_${phase}`, details: { path: item.path, target: item.target, backup: item.backup },
770
+ }, { required: true }), env);
771
+ console.log(`Repaired ${repair.path}; backup: ${repair.backup}`);
772
+ if (result.warning) console.warn(result.warning);
773
+ } finally { db.close(); }
774
+ }
775
+ } finally { terminal.close(); }
776
+ }
777
+ const findings = await diagnose({ env, envFile: from, skipClis: values['skip-clis'], egress: values['skip-network'] ? async () => null : undefined });
778
+ console.log(values.json ? JSON.stringify({ home: (() => { try { return botonsHome(env); } catch { return null; } })(), findings }, null, 2) : report(findings, values.verbose));
779
+ return findings.some(isProblem) ? 1 : 0;
780
+ }
781
+
782
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
783
+ main().then(
784
+ (code) => (process.exitCode = code),
785
+ (err) => {
786
+ console.error(`doctor: could not run: ${(err as Error).message}`);
787
+ process.exitCode = 2;
788
+ },
789
+ );
790
+ }