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,546 @@
1
+ import { isManagedBin, managedRoot, pruneManagedReleases } from './managed-clis.ts';
2
+ import { CliInstaller, type InstallOptions } from './cli-install.ts';
3
+ import { ClaudeCliProvider } from './providers/claude-cli.ts';
4
+ import { CodexCliProvider } from './providers/codex-cli.ts';
5
+ import { ReleaseChecker } from './cli-updates.ts';
6
+ import { BrainLogin } from './brain-login.ts';
7
+ import { join } from 'node:path';
8
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
9
+ import { buildRouter, VERSION } from './api.ts';
10
+ import { ApiKeys, type ApiKeysOptions } from './api-keys.ts';
11
+ import { Approvals } from './approvals.ts';
12
+ import { Cards } from './cards.ts';
13
+ import { Reactions } from './reactions.ts';
14
+ import { Audit, SYSTEM_ACTOR } from './audit.ts';
15
+ import { Auth, type Principal } from './auth.ts';
16
+ import { Browser } from './browser.ts';
17
+ import { BrowserHandoffs } from './browser-handoff.ts';
18
+ import { drivesBrowser } from './browser-policy.ts';
19
+ import { Callbacks } from './callback.ts';
20
+ import { allowedHostNames, applyTailnet, type Config, formatDuration } from './config.ts';
21
+ import { Connectors } from './connectors.ts';
22
+ import { asTurn } from './acting.ts';
23
+ import { Engine } from './engine.ts';
24
+ import { EventHub } from './events.ts';
25
+ import { HealthMonitor } from './health.ts';
26
+ import { guardRequest, makeCtx, type Router, safeDecode, sendJson, serveStatic } from './http.ts';
27
+ import { type DataDirLock, lockDataDir } from './lock.ts';
28
+ import { log } from './log.ts';
29
+ import { Notifier } from './notify.ts';
30
+ import { CALLBACK_PATH, callbackPage, OAuthConnections } from './oauth-connections.ts';
31
+ import { ApiBrainProvider } from './providers/api-brain.ts';
32
+ import { HistoryStore } from './providers/api-history.ts';
33
+ import { anthropicWire, openaiWire } from './providers/api-wire.ts';
34
+ import { ProviderRegistry } from './providers/index.ts';
35
+ import { Spending } from './spend.ts';
36
+ import { RecentLogs } from './recent-logs.ts';
37
+ import { RestoreRehearsals } from './rehearsals.ts';
38
+ import { reapOrphanGroups } from './providers/process.ts';
39
+ import { startScheduler } from './scheduler.ts';
40
+ import { Setup } from './setup.ts';
41
+ import { Skills } from './skills.ts';
42
+ import { StarterGate } from './starter-gate.ts';
43
+ import { HttpError, Store } from './store.ts';
44
+ import { WorkFeed } from './work-feed.ts';
45
+ import { Trash, type TrashHooks } from './trash.ts';
46
+ import { plainAddress, type Tailnet, type TailscaleProbe, type TailscaleSelf, tailscaleCli, tailscaleSelf } from './tailnet.ts';
47
+
48
+ export interface App {
49
+ store: Store;
50
+ engine: Engine;
51
+ connectors: Connectors;
52
+ skills: Skills;
53
+ approvals: Approvals;
54
+ cards: Cards;
55
+ reactions: Reactions;
56
+ browser: Browser;
57
+ oauth: OAuthConnections;
58
+ workFeed: WorkFeed;
59
+ providers: ProviderRegistry;
60
+ hub: EventHub;
61
+ auth: Auth;
62
+ audit: Audit;
63
+ setup: Setup;
64
+ health: HealthMonitor;
65
+ recentLogs: RecentLogs;
66
+ rehearsals: RestoreRehearsals;
67
+ apiKeys: ApiKeys;
68
+ spending: Spending;
69
+ trash: Trash;
70
+ listen(): Promise<Server[]>;
71
+ /** Running turns get `drainMs` (default 0) to finish; the rest are stopped and left for the next process. */
72
+ close(opts?: { drainMs?: number }): Promise<{ interrupted: number }>;
73
+ }
74
+
75
+ /** Never logs the key, query, headers (signatures) or payload. */
76
+ function logHook(req: IncomingMessage, res: ServerResponse, url: URL, listener: 'main' | 'public'): void {
77
+ const started = Date.now();
78
+ const remote = plainAddress(req.socket.remoteAddress ?? '');
79
+ const id = /^\/hooks\/routines\/([^/]+)(?:\/|$)/.exec(url.pathname)?.[1];
80
+ const routineId = id === undefined ? undefined : (safeDecode(id) ?? undefined);
81
+ res.once('close', () => {
82
+ const status = res.headersSent ? res.statusCode : null;
83
+ const fields = { routineId, method: req.method, path: url.pathname, listener, remote, status, durationMs: Date.now() - started };
84
+ if (status === null) log.info('webhook caller hung up before the answer', fields);
85
+ else if (status >= 500) log.error('webhook', fields);
86
+ else if (status >= 400) log.warn('webhook', fields);
87
+ else log.info('webhook', fields);
88
+ });
89
+ }
90
+
91
+ async function settle(open: Set<ServerResponse>, ms: number): Promise<void> {
92
+ const end = Date.now() + ms;
93
+ // At least one turn of the event loop: a write just made goes out with the next tick.
94
+ await new Promise((r) => setImmediate(r));
95
+ while (open.size && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
96
+ }
97
+
98
+ function closeServers(servers: Server[]): Promise<void[]> {
99
+ return Promise.all(
100
+ servers.map(
101
+ (s) =>
102
+ new Promise<void>((resolve) => {
103
+ s.closeAllConnections();
104
+ s.close(() => resolve());
105
+ }),
106
+ ),
107
+ );
108
+ }
109
+
110
+ interface AppOptions {
111
+ tailscale?: TailscaleProbe;
112
+ tailscaleRetryMs?: number;
113
+ /** Default: the `tailscale` CLI, or none with BOTONS_NO_TAILSCALE=1. */
114
+ tailnet?: Tailnet | null;
115
+ /** Restore rehearsals' limits and time limit (tests make them small). */
116
+ rehearsal?: Pick<ConstructorParameters<typeof RestoreRehearsals>[0], 'limits' | 'timeoutMs'>;
117
+ /** API keys: the environment to read ANTHROPIC_API_KEY and OPENAI_API_KEY from (default: the server's), and tests' loopback endpoints. */
118
+ apiKeys?: Partial<Pick<ApiKeysOptions, 'env' | 'testEndpoints' | 'timeoutMs'>>;
119
+ /** Tests only: the clock API-key requests are counted by (their day). */
120
+ spendClock?: () => Date;
121
+ /** The trash and inbox cleanup's clock (tests move it on by days). */
122
+ storageClock?: () => number;
123
+ /** Tests: a Boton's moves interleaved where a race with a delete or restore would hit (trash.ts). */
124
+ trashHooks?: TrashHooks;
125
+ /** Production entrypoint enables external release checks; fixtures stay offline. */
126
+ releaseChecks?: boolean;
127
+ /** In-code fixture runners only; never configured through HTTP or environment. */
128
+ cliInstaller?: Pick<InstallOptions, 'root' | 'node' | 'run' | 'verify'>;
129
+ }
130
+
131
+ /** Throws DataDirInUseError before opening the database if another server holds cfg.home. */
132
+ export function createApp(cfg: Config, lock: DataDirLock = lockDataDir(cfg.home), opts: AppOptions = {}): App {
133
+ // First, so the owner's view has what starting up logged.
134
+ const recentLogs = new RecentLogs({ threshold: () => cfg.logLevel }).start();
135
+ const hub = new EventHub();
136
+ pruneManagedReleases(managedRoot(), 'codex-cli', cfg.codexBin);
137
+ pruneManagedReleases(managedRoot(), 'claude-cli', cfg.claudeBin);
138
+ const providers = new ProviderRegistry({ enableMock: cfg.enableMock, claudeBin: cfg.claudeBin, codexBin: cfg.codexBin, managed: { claude: isManagedBin(cfg.claudeBin, 'claude-cli'), codex: isManagedBin(cfg.codexBin, 'codex-cli') } });
139
+ const engine = new Engine(cfg, hub, providers);
140
+ providers.setUsageLimits((id) => engine.usageLimit(id)?.until ?? null);
141
+ let store: Store;
142
+ try {
143
+ store = new Store(cfg, hub, engine.statusOf);
144
+ } catch (err) {
145
+ recentLogs.stop();
146
+ lock.release();
147
+ throw err;
148
+ }
149
+ engine.store = store;
150
+ store.effortCaps = (provider, model) => providers.modelEfforts(provider, model);
151
+ const connectors = new Connectors(store.db, { home: cfg.home, providers, store, allow: cfg.oauth.allow });
152
+ engine.connectors = connectors;
153
+ const skills = new Skills(store.db, { hub, store, packagesDir: cfg.skillPackagesDir });
154
+ engine.skills = skills;
155
+ const auth = new Auth(store.db, {
156
+ enabled: cfg.authEnabled,
157
+ ownerLogin: cfg.ownerLogin,
158
+ tailnet: opts.tailnet !== undefined ? opts.tailnet : cfg.tailscale.enabled ? tailscaleCli() : null,
159
+ hostname: cfg.hostname,
160
+ container: cfg.container,
161
+ });
162
+ auth.purgeBotonTokens();
163
+ const audit = new Audit(store.db);
164
+ const setup = new Setup(store.db, cfg, () => store.getSettings().ownerName);
165
+ engine.auth = auth;
166
+ // Read per push: the public URL follows the tailnet.
167
+ const notifier = new Notifier({ config: () => store.ntfyConfig(), publicUrl: () => cfg.publicUrl });
168
+ engine.notifier = notifier;
169
+ const approvals = new Approvals({
170
+ store,
171
+ hub,
172
+ notifier,
173
+ wake: (botId) => engine.queueChat(botId),
174
+ origin: (botId) => engine.turnOrigin(botId),
175
+ access: (botId) => engine.turnAccess(botId),
176
+ waitMs: cfg.approvalWaitMs,
177
+ });
178
+ approvals.skills = skills;
179
+ approvals.connectors = connectors;
180
+ skills.approvals = approvals;
181
+ engine.approvals = approvals;
182
+ const handoffs = new BrowserHandoffs({ store, push: (req) => notifier.push(req), wake: (botId) => engine.queueChat(botId) });
183
+ const browser = new Browser({
184
+ cfg,
185
+ store,
186
+ approvals,
187
+ usesConnectors: (provider) => drivesBrowser(providers.get(provider)),
188
+ turnAccess: (botId) => engine.turnAccess(botId),
189
+ share: (botId, path, title) => engine.shareFile(botId, path, title),
190
+ audit,
191
+ trackDevice: (principal, close) => auth.trackStream(principal, close),
192
+ handoffs,
193
+ });
194
+ engine.browser = browser;
195
+ // A handover never outlives the server: a handoff taken when it stopped was handed back by the stop.
196
+ handoffs.recover();
197
+ approvals.browser = browser;
198
+ // The built-in browser connector; whether this server has a Chromium is found once, at start.
199
+ connectors.browser = { server: () => browser.server(), unavailable: () => browser.known?.why ?? null };
200
+ void browser.availability().then((a) => {
201
+ if (!a.available) log.info('the managed browser is not available', { why: a.why });
202
+ });
203
+ const oauth = new OAuthConnections({ db: store.db, cfg, store, hub, notifier });
204
+ const connected = (id: string) => {
205
+ const c = oauth.list().find((x) => x.id === id);
206
+ audit.record(SYSTEM_ACTOR, { action: 'oauth.connected', target: { kind: 'oauth', id, label: c?.name ?? id } });
207
+ };
208
+ connectors.oauth = oauth;
209
+ // Before anything can refresh: a refresh cut off by the last stop is never sent again.
210
+ oauth.recoverInterrupted();
211
+ const workFeed = new WorkFeed({ db: store.db, cfg, store, oauth, wake: (botId) => engine.queueChat(botId), authority: (id) => connectors.authority(id) });
212
+ engine.workFeed = workFeed;
213
+ oauth.onConnected = (id) => {
214
+ // A new sign-in is a new grant: a test of the earlier one, done or still running, isn't about it.
215
+ const name = oauth.record(id)?.name;
216
+ if (name) connectors.forgetTest(`oauth:${name}`);
217
+ connected(id);
218
+ void workFeed.detect(id);
219
+ };
220
+ const starterGate = new StarterGate({ store, connectors, skills, browser });
221
+ engine.starterGate = starterGate;
222
+ const health = new HealthMonitor({ cfg, store, engine, providers });
223
+ const callbacks = new Callbacks(cfg.callbacks, store, hub);
224
+ const rehearsals = new RestoreRehearsals({ db: store.db, audit, home: cfg.home, backupsDir: cfg.backupsDir, version: VERSION, build: cfg.build, ...opts.rehearsal });
225
+ const cards = new Cards({ store, connectors, audit, wake: (botId) => engine.queueChat(botId), origin: (botId) => engine.turnOrigin(botId) });
226
+ const apiKeys = new ApiKeys({ db: store.db, hub, env: opts.apiKeys?.env ?? process.env, testEndpoints: opts.apiKeys?.testEndpoints, timeoutMs: opts.apiKeys?.timeoutMs });
227
+ const reactions = new Reactions({ store, wake: (botId) => engine.queueChat(botId) });
228
+ engine.reactions = reactions;
229
+ // Botons on the owner's API keys: the loop, tools and caps are the server's own (providers/api-brain.ts).
230
+ const spending = new Spending(store.db, { now: opts.spendClock });
231
+ spending.prune();
232
+ const apiHistory = new HistoryStore(join(cfg.home, 'api-sessions'));
233
+ for (const wire of [anthropicWire, openaiWire]) providers.register(new ApiBrainProvider(wire, { keys: apiKeys, history: apiHistory, spending, allow: cfg.oauth.allow }));
234
+ const releases = new ReleaseChecker(cfg.home);
235
+ if (!cfg.enableMock && opts.releaseChecks === true) releases.start();
236
+ const brainLogin = new BrainLogin(cfg.codexBin);
237
+ const cliInstaller = new CliInstaller({
238
+ home: cfg.home, ...(cfg.enableMock ? {root:join(cfg.home, 'fixture-managed-clis')} : {}), ...opts.cliInstaller,
239
+ current: id => id === 'codex-cli' ? cfg.codexBin : cfg.claudeBin,
240
+ maintain: run => engine.maintain(run),
241
+ activate: (id, bin) => {
242
+ if (id === 'codex-cli') { brainLogin.setBin(bin); cfg.codexBin = bin; providers.register(new CodexCliProvider(bin, undefined, true)); }
243
+ else { cfg.claudeBin = bin; providers.register(new ClaudeCliProvider(bin, true)); }
244
+ },
245
+ });
246
+ // The server's trash; nothing a delete takes may hold these folders.
247
+ const trash = new Trash({ home: cfg.home, store, audit, sharedDir: cfg.sharedDir, hooks: opts.trashHooks, protectedDirs: [cfg.home, cfg.workspacesDir, cfg.sharedDir, cfg.templatesDir, cfg.skillPackagesDir, cfg.backupsDir], now: opts.storageClock });
248
+ const router = buildRouter({ cfg, store, engine, hub, providers, auth, health, connectors, skills, approvals, cards, reactions, browser, oauth, audit, setup, callbacks, recentLogs, rehearsals, apiKeys, brainLogin, starterGate, spending, releases, cliInstaller, trash });
249
+
250
+ // Recomputed when the server starts listening on its Tailscale address.
251
+ let allowedHosts = allowedHostNames(cfg);
252
+ const publicOrigins = new Set([cfg.publicUrlOverride, cfg.oauth.publicUrl]
253
+ .filter((value): value is string => !!value).map((value) => new URL(value).origin.toLowerCase()));
254
+
255
+ const parseUrl = (req: IncomingMessage) => {
256
+ try {
257
+ return new URL(req.url ?? '/', 'http://local');
258
+ } catch {
259
+ throw new HttpError(400, 'Malformed URL');
260
+ }
261
+ };
262
+
263
+ const dispatch = async (req: IncomingMessage, res: ServerResponse, url: URL, match: ReturnType<Router['match']>, principal: Principal | null) => {
264
+ if (match === null) return sendJson(res, 404, { error: 'Not found' });
265
+ if (match === 'method') return sendJson(res, 405, { error: 'Method not allowed' });
266
+ const ctx = makeCtx(req, res, url, match.params, principal, match.maxBody);
267
+ // A Boton's request: what it posts in the chat belongs to the turn it is running.
268
+ const out = await (principal?.kind === 'boton' ? asTurn(principal.botId, engine.turnOrigin(principal.botId)?.turnId, () => match.handler(ctx)) : match.handler(ctx));
269
+ if (res.headersSent) return;
270
+ if (out === undefined) sendJson(res, 204, {});
271
+ else sendJson(res, 200, out);
272
+ };
273
+
274
+ const handle = async (req: IncomingMessage, res: ServerResponse) => {
275
+ const url = parseUrl(req);
276
+ if (url.pathname.startsWith('/hooks/')) logHook(req, res, url, 'main');
277
+ // The owner's access mode: requests from networks it doesn't name are refused before anything else.
278
+ const refused = setup.refusal(auth.remoteAddress(req));
279
+ if (refused) return sendJson(res, 403, { error: refused });
280
+ const blocked = guardRequest(req, url, allowedHosts, publicOrigins);
281
+ if (blocked) return sendJson(res, 403, { error: blocked });
282
+ const path = safeDecode(url.pathname);
283
+ if (path === null) throw new HttpError(400, 'Malformed URL encoding');
284
+ // The OAuth redirect: exactly this path, before the app's fallback. No device token: the browser comes back
285
+ // from the authorization server, and the one-time state it carries is what is checked.
286
+ if (url.pathname === CALLBACK_PATH || url.pathname.startsWith('/oauth/')) {
287
+ if (url.pathname !== CALLBACK_PATH) return sendJson(res, 404, { error: 'Not found' });
288
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'Method not allowed' });
289
+ const page = callbackPage(await oauth.callback(url.searchParams));
290
+ res.writeHead(page.status, page.headers);
291
+ return void res.end(page.body);
292
+ }
293
+ if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/hooks/')) {
294
+ if (req.method !== 'GET' && req.method !== 'HEAD') return sendJson(res, 405, { error: 'Method not allowed' });
295
+ return serveStatic(cfg.webDist, path, res);
296
+ }
297
+ const match = router.match(req.method ?? 'GET', url.pathname);
298
+ // /hooks routes check their routine keys themselves.
299
+ let principal: Principal | null = null;
300
+ if (url.pathname.startsWith('/api/')) {
301
+ const route = match && match !== 'method' ? match : null;
302
+ principal = auth.authenticate(req, { url, urlKey: route?.access.urlKey });
303
+ auth.authorize(principal, route?.access ?? {}, route?.params ?? {}, req);
304
+ }
305
+ return dispatch(req, res, url, match, principal);
306
+ };
307
+
308
+ // BOTONS_HOOKS_PORT, meant for the internet: webhook routes only. No Host
309
+ // check: callers use the proxy's name, and no browser session rides on these routes.
310
+ const handleHooks = async (req: IncomingMessage, res: ServerResponse) => {
311
+ const url = parseUrl(req);
312
+ if (!url.pathname.startsWith('/hooks/')) return sendJson(res, 404, { error: 'Not found' });
313
+ logHook(req, res, url, 'public');
314
+ if (safeDecode(url.pathname) === null) throw new HttpError(400, 'Malformed URL encoding');
315
+ return dispatch(req, res, url, router.match(req.method ?? 'GET', url.pathname), null);
316
+ };
317
+
318
+ // Node ignores the promise a request listener returns, so every request goes
319
+ // through this one catch. `open`: responses close() lets finish before it cuts connections.
320
+ const open = new Set<ServerResponse>();
321
+ const guarded = (handle: (req: IncomingMessage, res: ServerResponse) => Promise<unknown>) => async (req: IncomingMessage, res: ServerResponse) => {
322
+ open.add(res);
323
+ res.once('close', () => open.delete(res));
324
+ try {
325
+ await handle(req, res);
326
+ } catch (err) {
327
+ try {
328
+ if (err instanceof HttpError) return sendJson(res, err.status, { error: err.message }, err.headers);
329
+ // Path only: the query can carry a webhook's ?key= secret.
330
+ log.error('request failed', { method: req.method, path: (req.url ?? '/').split(/[?#]/, 1)[0], err });
331
+ sendJson(res, 500, { error: err instanceof Error ? err.message : 'Internal error' });
332
+ } catch (sendErr) {
333
+ log.error('could not send the error response', { err: sendErr });
334
+ res.destroy();
335
+ }
336
+ }
337
+ };
338
+
339
+ const handler = guarded(handle);
340
+ const listenOn = (host: string) =>
341
+ new Promise<Server>((resolve, reject) => {
342
+ const s = createServer(handler);
343
+ s.requestTimeout = 0; // webhooks with ?wait=1 and SSE stay open
344
+ s.once('error', reject);
345
+ s.listen(cfg.port, host, () => resolve(s));
346
+ });
347
+
348
+ // A caller that stalls sending its request is answered 408 and cut off, so slow senders can't pile up sockets.
349
+ const listenHooksOn = (host: string, port: number) =>
350
+ new Promise<Server>((resolve, reject) => {
351
+ const { timeoutMs } = cfg.hooks;
352
+ const s = createServer({ requestTimeout: timeoutMs, headersTimeout: timeoutMs, connectionsCheckingInterval: Math.min(1000, timeoutMs) }, guarded(handleHooks));
353
+ s.once('error', reject);
354
+ s.listen(port, host, () => resolve(s));
355
+ });
356
+
357
+ let servers: Server[] = [];
358
+ let hookServers: Server[] = [];
359
+ let stopScheduler: (() => void) | null = null;
360
+ let stopTailnet: (() => void) | null = null;
361
+
362
+ /**
363
+ * Poll `tailscale status`; once an address appears, listen on it (bind mode)
364
+ * and use its names. `tailscale status` can list the address a moment before
365
+ * the kernel has it, so a failed bind is retried soon until the next poll is due.
366
+ */
367
+ const followTailnet = async () => {
368
+ const { mode, pollMs, initial } = cfg.tailscale;
369
+ if (mode === 'off') return;
370
+ const probe = opts.tailscale ?? tailscaleSelf;
371
+ const retryMs = Math.min(opts.tailscaleRetryMs ?? 2000, pollMs);
372
+ const every = formatDuration(pollMs);
373
+ const retrying = retryMs < pollMs ? `retrying every ${formatDuration(retryMs)}, then every ${every}` : `retrying every ${every}`;
374
+ let live: TailscaleSelf = { ip: null, dnsName: null };
375
+ let bindError = '';
376
+ let failing: { ip: string; since: number } | null = null;
377
+ let retry: NodeJS.Timeout | null = null;
378
+ let warnedAt = 0;
379
+ let started = false;
380
+ let busy = false;
381
+ let stopped = false;
382
+ const use = (ts: TailscaleSelf) => {
383
+ if (ts.ip === live.ip && ts.dnsName === live.dnsName) return;
384
+ const first = !live.ip;
385
+ live = ts;
386
+ applyTailnet(cfg, live);
387
+ allowedHosts = allowedHostNames(cfg);
388
+ if (first && started) log.info(`Tailscale is up: ${mode === 'bind' ? `listening on ${ts.ip}:${cfg.port} too` : 'using its names'} (${cfg.publicUrl})`);
389
+ else if (!first) log.info(`the Tailscale address is now ${ts.ip} (${ts.dnsName ?? 'no MagicDNS name'})`);
390
+ };
391
+ const retrySoon = () => {
392
+ if (retry || stopped) return;
393
+ retry = setTimeout(() => {
394
+ retry = null;
395
+ void tick();
396
+ }, retryMs);
397
+ retry.unref();
398
+ };
399
+ const check = async (ts: TailscaleSelf) => {
400
+ // A failed lookup (say, tailscaled restarting) changes nothing: a listener outlives its address going away.
401
+ if (!ts.ip) return;
402
+ if (mode === 'bind' && !servers.some((s) => (s.address() as { address?: string } | null)?.address === ts.ip)) {
403
+ try {
404
+ const s = await listenOn(ts.ip);
405
+ if (stopped) return void s.close();
406
+ servers.push(s);
407
+ bindError = '';
408
+ failing = null;
409
+ } catch (err) {
410
+ const code = (err as NodeJS.ErrnoException).code ?? (err as Error).message;
411
+ if (code !== bindError) log.warn(`cannot listen on ${ts.ip}:${cfg.port} yet (${code}); ${retrying}`);
412
+ bindError = code;
413
+ if (failing?.ip !== ts.ip) failing = { ip: ts.ip, since: Date.now() };
414
+ if (Date.now() - failing.since < pollMs) retrySoon();
415
+ return;
416
+ }
417
+ }
418
+ use(ts);
419
+ };
420
+ const tick = async () => {
421
+ if (busy || stopped) return;
422
+ busy = true;
423
+ try {
424
+ await check(await probe());
425
+ } catch (err) {
426
+ log.error('Tailscale check failed', { err });
427
+ } finally {
428
+ busy = false;
429
+ }
430
+ if (mode === 'bind' && !live.ip && !stopped && Date.now() - warnedAt >= 5 * 60_000) {
431
+ warnedAt = Date.now();
432
+ log.warn(`Tailscale is not up: Botons is serving on ${cfg.hosts.join(', ')} only, so other devices cannot reach it. Checking again every ${every}.`);
433
+ }
434
+ };
435
+ // Loopback only until a Tailscale address is bound (or, in names mode, known).
436
+ applyTailnet(cfg, live);
437
+ allowedHosts = allowedHostNames(cfg);
438
+ busy = true;
439
+ try {
440
+ await check(initial);
441
+ } finally {
442
+ busy = false;
443
+ started = true;
444
+ }
445
+ const timer = setInterval(tick, pollMs);
446
+ timer.unref();
447
+ stopTailnet = () => {
448
+ stopped = true;
449
+ clearInterval(timer);
450
+ if (retry) clearTimeout(retry);
451
+ };
452
+ if (mode === 'bind' && !live.ip) void tick(); // warn now, and maybe it just came up
453
+ };
454
+
455
+ return {
456
+ store,
457
+ engine,
458
+ connectors,
459
+ skills,
460
+ approvals,
461
+ cards,
462
+ reactions,
463
+ browser,
464
+ oauth,
465
+ workFeed,
466
+ providers,
467
+ hub,
468
+ auth,
469
+ audit,
470
+ setup,
471
+ health,
472
+ recentLogs,
473
+ rehearsals,
474
+ apiKeys,
475
+ spending,
476
+ trash,
477
+ async listen() {
478
+ const { port: hooksPort, hosts: hooksHosts } = cfg.hooks;
479
+ const results = await Promise.allSettled([
480
+ ...cfg.hosts.map(listenOn),
481
+ ...(hooksPort === null ? [] : hooksHosts.map((h) => listenHooksOn(h, hooksPort))),
482
+ ]);
483
+ const bound = results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
484
+ const failed = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
485
+ if (failed) {
486
+ await closeServers(bound);
487
+ throw failed.reason;
488
+ }
489
+ servers = bound.slice(0, cfg.hosts.length);
490
+ hookServers = bound.slice(cfg.hosts.length);
491
+ if (hooksPort !== null) {
492
+ log.info(`webhooks only (no API, no app) on ${hooksHosts.map((h) => `${h}:${hooksPort}`).join(', ')}`, { webhookUrl: cfg.hooks.publicUrl ?? undefined });
493
+ if (!cfg.hooks.publicUrl) log.warn(`BOTONS_HOOKS_PORT is set but BOTONS_HOOKS_PUBLIC_URL is not, so webhook URLs point at the main listener's URL (${cfg.publicUrl}), not this port. Set BOTONS_HOOKS_PUBLIC_URL to the public URL that reaches port ${hooksPort} (e.g. its Tailscale Funnel URL).`);
494
+ }
495
+ await followTailnet();
496
+ // Only once every listener is bound, so a server that cannot bind leaves
497
+ // the runs alone. The data-dir lock makes what is left running ours to
498
+ // recover; first stop agent CLIs a crashed server left at that work.
499
+ await reapOrphanGroups(cfg.agentsDir);
500
+ engine.pushInterrupted(store.failInterruptedRuns());
501
+ engine.noteCutOffTurns(store.endInterruptedTurns());
502
+ engine.giveUpCutOffChats();
503
+ engine.resume();
504
+ callbacks.resumePending();
505
+ stopScheduler = startScheduler(store, engine, cfg.schedulerIntervalMs, () => {
506
+ health.schedulerTicked();
507
+ try {
508
+ approvals.expireDue();
509
+ } catch (err) {
510
+ log.error('could not expire approval requests', { err });
511
+ }
512
+ // Once a day: the trash past its days, and old inbox files to the trash.
513
+ void trash.housekeep();
514
+ try {
515
+ for (const id of oauth.keepAliveDue()) void oauth.keepAlive(id);
516
+ } catch (err) {
517
+ log.error('could not check OAuth keep-alive', { err });
518
+ }
519
+ });
520
+ workFeed.start();
521
+ return servers;
522
+ },
523
+ async close(opts = {}) {
524
+ // First, so a rehearsal cut off now is recorded as interrupted while the database is open.
525
+ await cliInstaller.close();
526
+ await releases.close();
527
+ brainLogin.cancel();
528
+ await rehearsals.close();
529
+ stopTailnet?.();
530
+ stopScheduler?.();
531
+ const { interrupted } = await engine.drain(opts.drainMs ?? 0);
532
+ await browser.closeAll();
533
+ callbacks.stop();
534
+ await workFeed.stop();
535
+ // Answer ?wait=1 callers and end event streams before the connections go, so what was written last arrives.
536
+ engine.endWaits();
537
+ hub.endStreams();
538
+ await settle(open, 2000);
539
+ await closeServers([...servers, ...hookServers]);
540
+ store.db.close();
541
+ recentLogs.stop();
542
+ lock.release();
543
+ return { interrupted };
544
+ },
545
+ };
546
+ }