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,947 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isLegacySetting } from '../../shared/legacy-names.mjs';
3
+ import { chmodSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import type { DatabaseSync } from 'node:sqlite';
6
+ import type { Connector, ConnectorCatalog, ConnectorGrant, ConnectorNotice, ConnectorOff, ConnectorPolicyInput, ConnectorTest, McpServer, McpServerInput, BotonConnector } from '../../shared/types.ts';
7
+ import { connectorJail, type Jail, type LaunchPolicy, release, releaseAll } from './connector-launch.ts';
8
+ import { log } from './log.ts';
9
+ import { McpError, McpSession, secretsOf } from './mcp-client.ts';
10
+ import { sanitize } from './secrets.ts';
11
+ import { OAuthError } from './oauth.ts';
12
+ import type { OAuthConnections } from './oauth-connections.ts';
13
+ import { refusedOutbound } from './outbound.ts';
14
+ import type { ProviderRegistry } from './providers/index.ts';
15
+ import type { McpStartup, Provider, TurnMcpServer } from './providers/types.ts';
16
+ import { HttpError, type Store } from './store.ts';
17
+ import { BROWSER_CONNECTOR, BROWSER_SERVER } from '../../shared/util.ts';
18
+ import { presetFor, TOKEN_AS_NAME, tokenShapedName } from '../../shared/services.ts';
19
+ import { authorizationOf, GITHUB_PUBLIC_ONLY, GITHUB_REJECTED, githubAccess } from './github-access.ts';
20
+ import { drivesBrowser } from './browser-policy.ts';
21
+ import { nowIso, parseJson, type Row, strings } from './util.ts';
22
+
23
+ /*
24
+ * The MCP servers a Boton can opt in to, all managed here: the library ("lib:<name>") and OAuth connectors
25
+ * ("oauth:<name>"). Nothing reads a provider CLI's own MCP setup: each turn's CLI gets only the servers passed to
26
+ * it, so a connector works the same whichever brain a Boton has. Secrets stay in the database and are only read
27
+ * back to start a turn; the API returns names and targets only. A Boton's own list (bots.connections) is what it
28
+ * has; a connector the owner makes available on request can be added to it by `botons connector request`.
29
+ *
30
+ * The owner can turn a connector off without deleting anything (setEnabled): its setup, secrets, OAuth sign-in and
31
+ * grants stay, but resolve() leaves it out of every turn, no Boton may ask for it, cards asked before can't be
32
+ * allowed, and the work feed isn't read. Turning it on again restores exactly the grants it had, and nothing more.
33
+ * Off is a row in `settings` (offKey), so it needs no table of its own.
34
+ *
35
+ * One library connector is built in rather than added: the managed browser (BROWSER_CONNECTOR, browser.ts). Its
36
+ * server is fixed by Botons (no command, args or env to edit), it can't be asked for, and the library's own entries
37
+ * can't take its name.
38
+ */
39
+
40
+ /** The built-in browser connector, as browser.ts provides it. */
41
+ export interface ManagedBrowser {
42
+ server(): TurnMcpServer;
43
+ /** Why turns can't use it now (no Chromium, say), or null; null too before the server has looked. */
44
+ unavailable(): string | null;
45
+ }
46
+
47
+ const NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/;
48
+ const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
49
+ const HEADER_KEY = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
50
+ const MAX_ENTRIES = 20;
51
+ const MAX_VALUE = 4000;
52
+ export const MAX_CONNECTIONS = 50;
53
+ /** The connectors that count toward MAX_CONNECTIONS: every new Boton has the built-in browser, so it never takes a place. */
54
+ export const countedConnections = (ids: readonly string[]): number => ids.filter((id) => id !== BROWSER_CONNECTOR).length;
55
+ const TEST_TIMEOUT_MS = 30_000;
56
+ const TEST_TOOLS = 100;
57
+
58
+ /*
59
+ * What each kind of connector's status rests on. Only the library's http servers and OAuth connectors can be tested
60
+ * from the server: a stdio command starts only in a Boton's turn (as the server's user, not sandboxed).
61
+ */
62
+ const BASIS = {
63
+ stdio:
64
+ "Saved here. Its command starts only inside a Boton's turn, so Botons doesn't run it to test it. Whichever Boton has it, it starts in the Workspace sandbox's view of this computer (its programs, read-only), with this computer's network, the folders its command and arguments name, and no workspace, shared folder or home folder. Which Botons have it and the environment you give it are what it is handed, not what confines it.",
65
+ http: 'Saved here, not checked until you test it: Botons connects to it and lists its tools.',
66
+ oauth: 'Signed in from this computer. Test it to list its tools with the current token.',
67
+ oauthOff: 'Not usable until it is signed in.',
68
+ };
69
+
70
+ const TRIED =
71
+ "Botons' server tried to connect to it from this computer and list its tools. A Boton's turn connects separately, through its own Claude Code or Codex, so this usually means the Boton can't use it either.";
72
+
73
+ const REACHED =
74
+ "Botons' server connected to it from this computer and listed its tools. A Boton's turn connects again through its own Claude Code or Codex, which can still leave it out (see the server log); that part is checked only in a turn.";
75
+
76
+ const GITHUB_CHECKED = `${REACHED} It also asked GitHub whether the token can see a private repository.`;
77
+ const GITHUB_PRIVATE = `${GITHUB_CHECKED} The token sees a private repository; this does not prove it may read its contents.`;
78
+
79
+ /** Secrets included: never sent to clients. */
80
+ interface LibraryRow {
81
+ name: string;
82
+ transport: 'stdio' | 'http';
83
+ command: string;
84
+ args: string[];
85
+ url: string;
86
+ env: Record<string, string>;
87
+ headers: Record<string, string>;
88
+ createdAt: string;
89
+ updatedAt: string;
90
+ }
91
+
92
+ function stringMap(v: unknown): Record<string, string> {
93
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
94
+ return Object.fromEntries(Object.entries(v).filter((e): e is [string, string] => typeof e[1] === 'string'));
95
+ }
96
+
97
+ function commandLine(command: string, args: string[]): string {
98
+ return [command, ...args].map((a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : JSON.stringify(a))).join(' ');
99
+ }
100
+
101
+ /** For display, without a query or credentials, which might be secrets. */
102
+ function displayUrl(url: string): string {
103
+ try {
104
+ const u = new URL(url);
105
+ return `${u.protocol}//${u.host}${u.pathname}${u.search ? '?…' : ''}`;
106
+ } catch {
107
+ return url ? '(invalid URL)' : '(no URL)';
108
+ }
109
+ }
110
+
111
+ export interface ResolvedConnectors {
112
+ servers: TurnMcpServer[];
113
+ skipped: { id: string; reason: string }[];
114
+ /** Each server's connector id, by the name the CLI sees it under. */
115
+ ids: Record<string, string>;
116
+ /** The local connectors' launch files (connector-launch.ts), removed with release() when the turn ends. */
117
+ launchFiles?: string[];
118
+ }
119
+
120
+ export interface ConnectorPolicy {
121
+ onRequest: boolean;
122
+ grant: ConnectorGrant;
123
+ generation: number;
124
+ }
125
+
126
+ /** The settings row that says a connector is off (`ConnectorOff` as JSON); none while it is on. */
127
+ export const offKey = (id: string) => `connector_off:${id}`;
128
+
129
+ /** Why a turn goes without a connector the owner turned off: in the owner's notices and the Boton's own turn. */
130
+ export const OFF_REASON = 'turned off by the owner in Settings → Connectors';
131
+
132
+ const NO_POLICY: ConnectorPolicy = { onRequest: false, grant: 'approval', generation: 0 };
133
+
134
+ /*
135
+ * A request policy's generation is its authority: a card or a connector request records the one it was asked under,
136
+ * and is allowed only while the connector's policy still has it. Every generation is issued here, negative and below
137
+ * every one issued before on this install, so none is ever issued twice: rows are never deleted (a deleted
138
+ * connector's stays, off request, see retirePolicy), so the lowest only falls, whatever is deleted, added again or
139
+ * switched. Nothing issues 0 or a positive generation, and one of those can't be told from the generation another
140
+ * setup under the same name had: a record holding one (or none) is never allowed, and the Boton asks again.
141
+ */
142
+ const NEXT_GENERATION = '(select min(0, coalesce(min(generation), 0)) - 1 from connector_policies)';
143
+
144
+ /** A recorded generation that can still be allowed: one issued as above. */
145
+ export const issuedGeneration = (g: unknown): g is number => typeof g === 'number' && Number.isSafeInteger(g) && g < 0;
146
+
147
+ /** Inside the caller's transaction: connector `id`'s policy gets a new generation, and `set` if given. */
148
+ function issuePolicy(db: DatabaseSync, id: string, set: { onRequest: boolean; grant: ConnectorGrant } | null): void {
149
+ db.prepare(
150
+ `insert into connector_policies (connector_id, on_request, grant_mode, generation, updated_at) values (?, ?, ?, ${NEXT_GENERATION}, ?)
151
+ on conflict(connector_id) do update set ${set ? 'on_request = excluded.on_request, grant_mode = excluded.grant_mode, ' : ''}generation = excluded.generation, updated_at = excluded.updated_at`,
152
+ ).run(id, set?.onRequest ? 1 : 0, set?.grant ?? 'approval', nowIso());
153
+ }
154
+
155
+ /**
156
+ * A connector is deleted: its request policy goes back to off at a new generation, in the same transaction. The row
157
+ * stays, so a connector added again under the same id starts off request, and no card or request asked for the
158
+ * deleted one matches it, even with the very same setup.
159
+ */
160
+ export function retirePolicy(db: DatabaseSync, id: string): void {
161
+ issuePolicy(db, id, { onRequest: false, grant: 'approval' });
162
+ }
163
+
164
+ /** At start: a policy whose generation was not issued as above gets one, so what is asked from now on can be allowed. */
165
+ export function issueGenerations(db: DatabaseSync): void {
166
+ for (const r of db.prepare('select connector_id from connector_policies where generation >= 0').all() as Row[]) issuePolicy(db, String(r.connector_id), null);
167
+ }
168
+
169
+ export class Connectors {
170
+ #db: DatabaseSync;
171
+ #providers: ProviderRegistry;
172
+ #store: Pick<Store, 'dropFromBots' | 'atomically'>;
173
+ /** For turn files with secrets. Files a crashed turn left are deleted at start. */
174
+ readonly privateDir: string;
175
+ /** The local connectors' launch files and socket filter (connector-launch.ts): private, no Boton can write it. */
176
+ readonly launchDir: string;
177
+ /** BOTONS_OAUTH_ALLOW: private hosts the server's own MCP requests may go to. */
178
+ #allow: string[];
179
+ /** Tests since the server started, by connector id; one at a time per connector. */
180
+ #tests = new Map<string, ConnectorTest>();
181
+ #testing = new Map<string, Promise<ConnectorTest>>();
182
+ /** Bumped by forgetTest(): a test that started under an earlier one is about something that changed. */
183
+ #testVersions = new Map<string, number>();
184
+ oauth!: OAuthConnections;
185
+ browser: ManagedBrowser | null = null;
186
+
187
+ constructor(db: DatabaseSync, opts: { home: string; providers: ProviderRegistry; store: Pick<Store, 'dropFromBots' | 'atomically'>; allow?: string[] }) {
188
+ this.#db = db;
189
+ this.#allow = opts.allow ?? [];
190
+ this.#providers = opts.providers;
191
+ this.#store = opts.store;
192
+ this.privateDir = join(opts.home, 'run');
193
+ mkdirSync(this.privateDir, { recursive: true, mode: 0o700 });
194
+ chmodSync(this.privateDir, 0o700);
195
+ for (const f of readdirSync(this.privateDir)) if (/^mcp-.*\.json$/.test(f)) rmSync(join(this.privateDir, f), { force: true });
196
+ this.launchDir = join(this.privateDir, 'connectors');
197
+ mkdirSync(this.launchDir, { recursive: true, mode: 0o700 });
198
+ chmodSync(this.launchDir, 0o700);
199
+ releaseAll(this.launchDir);
200
+ this.#store.atomically(() => issueGenerations(db));
201
+ }
202
+
203
+ #toLibrary(r: Row): LibraryRow {
204
+ return {
205
+ name: r.name,
206
+ transport: r.transport,
207
+ command: r.command,
208
+ args: strings(parseJson(r.args, [])),
209
+ url: r.url,
210
+ env: stringMap(parseJson(r.env, {})),
211
+ headers: stringMap(parseJson(r.headers, {})),
212
+ createdAt: r.created_at,
213
+ updatedAt: r.updated_at,
214
+ };
215
+ }
216
+
217
+ #publicServer(s: LibraryRow): McpServer {
218
+ return {
219
+ name: s.name,
220
+ transport: s.transport,
221
+ command: s.command,
222
+ args: s.args,
223
+ url: s.url,
224
+ envKeys: Object.keys(s.env).sort(),
225
+ headerKeys: Object.keys(s.headers).sort(),
226
+ createdAt: s.createdAt,
227
+ updatedAt: s.updatedAt,
228
+ };
229
+ }
230
+
231
+ #library(): LibraryRow[] {
232
+ return this.#db
233
+ .prepare('select * from mcp_servers where name != ? order by name')
234
+ .all(BROWSER_SERVER)
235
+ .map((r) => this.#toLibrary(r as Row));
236
+ }
237
+
238
+ #findServer(name: string): LibraryRow | null {
239
+ if (name === BROWSER_SERVER) return null;
240
+ const r = this.#db.prepare('select * from mcp_servers where name = ?').get(name) as Row | undefined;
241
+ return r ? this.#toLibrary(r) : null;
242
+ }
243
+
244
+ listServers(): McpServer[] {
245
+ return this.#library().map((s) => this.#publicServer(s));
246
+ }
247
+
248
+ getServer(name: string): McpServer {
249
+ const s = this.#findServer(name);
250
+ if (!s) throw new HttpError(404, `No MCP server named ${name}`);
251
+ return this.#publicServer(s);
252
+ }
253
+
254
+ createServer(input: McpServerInput): McpServer {
255
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
256
+ if (!NAME.test(name)) throw new HttpError(400, 'name must be 1-40 lowercase letters, digits, - or _, starting with a letter or digit');
257
+ if (name === BROWSER_SERVER) throw new HttpError(400, `${BROWSER_SERVER} is the name of the browser Botons runs itself`);
258
+ if (this.#findServer(name)) throw new HttpError(409, `There is already an MCP server named ${name}`);
259
+ if (input.transport !== 'stdio' && input.transport !== 'http') throw new HttpError(400, 'transport must be "stdio" or "http"');
260
+ const now = nowIso();
261
+ const next = this.#apply({ name, transport: input.transport, command: '', args: [], url: '', env: {}, headers: {}, createdAt: now, updatedAt: now }, input);
262
+ this.#db
263
+ .prepare('insert into mcp_servers (name, transport, command, args, url, env, headers, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)')
264
+ .run(name, next.transport, next.command, JSON.stringify(next.args), next.url, JSON.stringify(next.env), JSON.stringify(next.headers), now, now);
265
+ log.info('MCP server added to the library', { server: name, transport: next.transport });
266
+ return this.getServer(name);
267
+ }
268
+
269
+ updateServer(name: string, input: McpServerInput): McpServer {
270
+ const before = this.#findServer(name);
271
+ if (!before) throw new HttpError(404, `No MCP server named ${name}`);
272
+ if (input.name !== undefined && input.name !== name) throw new HttpError(400, "An MCP server's name can't change; add a new one instead");
273
+ if (input.transport !== undefined && input.transport !== 'stdio' && input.transport !== 'http') throw new HttpError(400, 'transport must be "stdio" or "http"');
274
+ const next = this.#apply({ ...before, transport: input.transport ?? before.transport }, input);
275
+ this.#db
276
+ .prepare('update mcp_servers set transport = ?, command = ?, args = ?, url = ?, env = ?, headers = ?, updated_at = ? where name = ?')
277
+ .run(next.transport, next.command, JSON.stringify(next.args), next.url, JSON.stringify(next.env), JSON.stringify(next.headers), nowIso(), name);
278
+ this.forgetTest(`lib:${name}`);
279
+ log.info('MCP server in the library changed', { server: name, transport: next.transport });
280
+ return this.getServer(name);
281
+ }
282
+
283
+ /** Also takes it off every Boton that had it. */
284
+ deleteServer(name: string): void {
285
+ if (!this.#findServer(name)) throw new HttpError(404, `No MCP server named ${name}`);
286
+ this.#store.atomically(() => {
287
+ this.#db.prepare('delete from mcp_servers where name = ?').run(name);
288
+ retirePolicy(this.#db, `lib:${name}`);
289
+ this.#db.prepare('delete from settings where key = ?').run(offKey(`lib:${name}`));
290
+ this.#store.dropFromBots('connections', `lib:${name}`);
291
+ });
292
+ this.forgetTest(`lib:${name}`);
293
+ log.info('MCP server removed from the library', { server: name });
294
+ }
295
+
296
+ #apply(s: LibraryRow, input: McpServerInput): LibraryRow {
297
+ const next: LibraryRow = { ...s, args: [...s.args], env: { ...s.env }, headers: { ...s.headers } };
298
+ if (input.command !== undefined) {
299
+ if (typeof input.command !== 'string' || input.command.length > 500 || /[\r\n\0]/.test(input.command)) throw new HttpError(400, 'command must be one line, at most 500 characters');
300
+ next.command = input.command.trim();
301
+ }
302
+ if (input.args !== undefined) {
303
+ if (!Array.isArray(input.args) || input.args.length > 50 || !input.args.every((a) => typeof a === 'string' && a.length <= 2000 && !a.includes('\0'))) {
304
+ throw new HttpError(400, 'args must be a list of at most 50 strings');
305
+ }
306
+ next.args = [...input.args];
307
+ }
308
+ if (input.url !== undefined) {
309
+ if (typeof input.url !== 'string') throw new HttpError(400, 'url must be a string');
310
+ next.url = input.url.trim();
311
+ }
312
+ next.env = patchSecrets(next.env, input.env, 'env', ENV_KEY);
313
+ next.headers = patchSecrets(next.headers, input.headers, 'headers', HEADER_KEY);
314
+ if (next.transport === 'stdio') {
315
+ if (!next.command) throw new HttpError(400, 'A stdio server needs a command');
316
+ next.url = '';
317
+ next.headers = {};
318
+ } else {
319
+ let u: URL;
320
+ try {
321
+ u = new URL(next.url);
322
+ } catch {
323
+ throw new HttpError(400, 'An http server needs a URL such as https://example.com/mcp');
324
+ }
325
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new HttpError(400, 'url must be http:// or https://');
326
+ if (u.username || u.password) throw new HttpError(400, 'Put credentials in a header (headers), not in the URL');
327
+ next.command = '';
328
+ next.args = [];
329
+ next.env = {};
330
+ }
331
+ return next;
332
+ }
333
+
334
+ #connectorProviders(): Provider[] {
335
+ return [...this.#providers.ids()].flatMap((id) => {
336
+ const p = this.#providers.get(id);
337
+ return p && (p.cli || p.serverTools) ? [p] : [];
338
+ });
339
+ }
340
+
341
+ /** Whether this brain receives Botons-managed connectors, through its CLI or server-side tool loop. */
342
+ takesConnectors(providerId: string): boolean {
343
+ return this.#connectorProviders().some((p) => p.id === providerId);
344
+ }
345
+
346
+ /**
347
+ * What connector `id` is now, for telling whether it is still the one something was checked against: its setup
348
+ * (secrets only as their SHA-256, never the values), when it was made and changed, its request-policy generation
349
+ * (new on every off, on, policy change and delete) and whether it is off. Null when there is no such connector. Never
350
+ * sent to a client as it is: callers fold it into a keyed hash (starter-gate.ts).
351
+ */
352
+ revision(id: string): Record<string, unknown> | null {
353
+ const sha = (v: string) => createHash('sha256').update(v).digest('hex');
354
+ const secrets = (m: Record<string, string>) => Object.fromEntries(Object.keys(m).sort().map((k) => [k, sha(m[k])]));
355
+ const common = { generation: this.policyOf(id).generation, off: this.isOff(id) };
356
+ if (id === BROWSER_CONNECTOR) return this.browser ? { kind: 'browser' } : null;
357
+ if (id.startsWith('lib:')) {
358
+ const s = this.#findServer(id.slice(4));
359
+ if (!s) return null;
360
+ return { kind: 'lib', name: s.name, transport: s.transport, command: s.command, args: s.args, url: sha(s.url), env: secrets(s.env), headers: secrets(s.headers), createdAt: s.createdAt, updatedAt: s.updatedAt, ...common };
361
+ }
362
+ if (id.startsWith('oauth:')) {
363
+ const c = this.oauth?.byName(id.slice(6));
364
+ if (!c) return null;
365
+ // Not the tokens: a refresh renews them without changing what the connector is. A new sign-in bumps the generation.
366
+ return { kind: 'oauth', row: c.id, url: sha(c.url), status: c.status, signIn: c.generation, issuer: c.issuer, resource: c.resource, client: c.clientId, ...common };
367
+ }
368
+ return null;
369
+ }
370
+
371
+ /** Every connector a Boton could be given, without secrets. */
372
+ catalog(): ConnectorCatalog {
373
+ const providers = this.#connectorProviders();
374
+ const policies = this.#policies();
375
+ const policy = (id: string) => {
376
+ const p = policies.get(id) ?? NO_POLICY;
377
+ return { onRequest: p.onRequest, grant: p.grant };
378
+ };
379
+ const connectors: Connector[] = this.#library().map((s) => ({
380
+ id: `lib:${s.name}`,
381
+ name: s.name,
382
+ source: 'library',
383
+ providers: providers.map((p) => p.id),
384
+ transport: s.transport,
385
+ target: s.transport === 'stdio' ? commandLine(s.command, s.args) : displayUrl(s.url),
386
+ status: 'ok',
387
+ note: null,
388
+ ...policy(`lib:${s.name}`),
389
+ basis: s.transport === 'stdio' ? BASIS.stdio : BASIS.http,
390
+ testable: s.transport === 'http',
391
+ lastTest: this.#tests.get(`lib:${s.name}`) ?? null,
392
+ ...this.#onOff(`lib:${s.name}`),
393
+ }));
394
+ if (this.browser) {
395
+ const why = this.browser.unavailable();
396
+ connectors.unshift({
397
+ id: BROWSER_CONNECTOR,
398
+ name: BROWSER_SERVER,
399
+ source: 'library',
400
+ providers: providers.filter(drivesBrowser).map((p) => p.id),
401
+ transport: 'stdio',
402
+ target: "Built into Botons: the Boton's own managed browser, which only reads the sites you list in its settings",
403
+ status: 'ok',
404
+ note: why,
405
+ onRequest: false,
406
+ grant: 'approval',
407
+ basis: 'Run by the Botons server itself in each turn that has it; its sites are in the Boton\'s settings, under Browser.',
408
+ testable: false,
409
+ lastTest: null,
410
+ managed: true,
411
+ enabled: true,
412
+ off: null,
413
+ });
414
+ }
415
+ for (const c of this.oauth?.list() ?? []) {
416
+ connectors.push({
417
+ id: `oauth:${c.name}`,
418
+ name: c.name,
419
+ source: 'oauth',
420
+ providers: providers.map((p) => p.id),
421
+ transport: 'http',
422
+ target: c.url,
423
+ status: c.status === 'connected' ? 'ok' : 'needs-auth',
424
+ note: c.status === 'connected' ? c.refreshError : c.status === 'new' ? 'Not signed in yet: Connect it in Settings → Connectors.' : 'Needs signing in again: Reconnect it in Settings → Connectors.',
425
+ ...policy(`oauth:${c.name}`),
426
+ basis: c.status === 'connected' ? BASIS.oauth : BASIS.oauthOff,
427
+ testable: c.status === 'connected',
428
+ lastTest: this.#tests.get(`oauth:${c.name}`) ?? null,
429
+ ...this.#onOff(`oauth:${c.name}`),
430
+ });
431
+ }
432
+ return { connectors };
433
+ }
434
+
435
+ // ------------------------------------------------- on and off
436
+
437
+ /** When and why the owner turned connector `id` off, or null while it is on. */
438
+ offState(id: string): ConnectorOff | null {
439
+ if (id === BROWSER_CONNECTOR) return null;
440
+ const raw = (this.#db.prepare('select value from settings where key = ?').get(offKey(id)) as Row | undefined)?.value;
441
+ if (raw === undefined) return null;
442
+ const v = parseJson<Partial<ConnectorOff>>(raw, {});
443
+ // A row it can't read still means off: never on by accident.
444
+ return { at: typeof v.at === 'string' ? v.at : '', reason: typeof v.reason === 'string' ? v.reason : '' };
445
+ }
446
+
447
+ isOff(id: string): boolean {
448
+ return this.offState(id) !== null;
449
+ }
450
+
451
+ /** Switches made since the server started, by connector: each off or on adds one (see authority). */
452
+ #switches = new Map<string, number>();
453
+
454
+ /**
455
+ * What work done for connector `id` stands on: null while it is off, else a number that changes whenever it is turned
456
+ * off or on. Work that began under one value and finds another (even after an off and on again) must not carry on.
457
+ * Counted in memory: nothing that began before a restart is still under way after it.
458
+ */
459
+ authority(id: string): number | null {
460
+ return this.isOff(id) ? null : (this.#switches.get(id) ?? 0);
461
+ }
462
+
463
+ #onOff(id: string): { enabled: boolean; off: ConnectorOff | null } {
464
+ const off = this.offState(id);
465
+ return { enabled: !off, off };
466
+ }
467
+
468
+ /**
469
+ * Turns connector `id` off or on, inside the caller's transaction. Nothing it has is deleted. Each change starts a new
470
+ * request-policy generation, so a card a Boton asked for before can't be allowed after (even once it is on again).
471
+ * Returns whether anything changed. The managed browser is not switched here: it is given and stopped per Boton.
472
+ */
473
+ setEnabled(id: string, enabled: boolean, reason = ''): boolean {
474
+ const c = this.catalog().connectors.find((x) => x.id === id);
475
+ if (!c) throw new HttpError(404, `No connector ${id}`);
476
+ if (c.managed) throw new HttpError(400, `${c.name} is turned on and off in each Boton's settings, under Browser`);
477
+ if (c.enabled === enabled) return false;
478
+ // Counted first: should the caller's transaction roll back, a poll under way is only dropped and read again.
479
+ this.#switches.set(id, (this.#switches.get(id) ?? 0) + 1);
480
+ if (enabled) this.#db.prepare('delete from settings where key = ?').run(offKey(id));
481
+ else {
482
+ const off: ConnectorOff = { at: nowIso(), reason: reason.trim().slice(0, 200) };
483
+ this.#db.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run(offKey(id), JSON.stringify(off));
484
+ }
485
+ issuePolicy(this.#db, id, null);
486
+ // A test that started before is about a connector in the other state.
487
+ this.forgetTest(id);
488
+ log.info(enabled ? 'connector turned on' : 'connector turned off', { connector: id });
489
+ return true;
490
+ }
491
+
492
+ /** Drops a connector's last test, and makes one still running not count: what it tested changed or is gone. */
493
+ forgetTest(id: string): void {
494
+ this.#tests.delete(id);
495
+ this.#testing.delete(id);
496
+ this.#testVersions.set(id, (this.#testVersions.get(id) ?? 0) + 1);
497
+ }
498
+
499
+ /**
500
+ * The owner's test of one connector: for the library's http servers and signed-in OAuth connectors, an MCP session
501
+ * from this server (through outbound(), so only HTTPS or allowed loopback addresses, no redirects) that lists its
502
+ * tools and calls none. Anything else is answered as untestable, saying what its status rests on.
503
+ */
504
+ test(id: string): Promise<ConnectorTest> {
505
+ const c = this.catalog().connectors.find((x) => x.id === id);
506
+ if (!c) throw new HttpError(404, `No connector ${id}`);
507
+ const running = this.#testing.get(id);
508
+ if (running) return running;
509
+ const version = this.#testVersions.get(id) ?? 0;
510
+ const p = this.#runTest(c).then((t) => {
511
+ // Edited, signed in again or removed meanwhile: the result is about what it was, so it isn't kept or shown as current.
512
+ if ((this.#testVersions.get(id) ?? 0) !== version) return this.#changedMeanwhile(c, t);
513
+ if (this.#testing.get(id) === p) {
514
+ this.#tests.set(id, t);
515
+ this.#testing.delete(id);
516
+ }
517
+ return t;
518
+ });
519
+ this.#testing.set(id, p);
520
+ return p;
521
+ }
522
+
523
+ #changedMeanwhile(c: Connector, t: ConnectorTest): ConnectorTest {
524
+ return {
525
+ ...t,
526
+ outcome: 'untestable',
527
+ scope: c.basis,
528
+ problem: 'It was changed, signed in again or removed while this test ran, so the result is not about it now. Test it again.',
529
+ toolCount: 0,
530
+ tools: [],
531
+ };
532
+ }
533
+
534
+ async #runTest(c: Connector): Promise<ConnectorTest> {
535
+ const started = Date.now();
536
+ /*
537
+ * The credentials this test sends (saved headers, OAuth tokens). A server may echo them back in an error or a tool
538
+ * name, and what it says goes to the owner, the log and this cache, so each is replaced first, before any cut.
539
+ */
540
+ const secrets = new Set<string>();
541
+ const clean = (text: string, max: number) => sanitize(text, secrets, max);
542
+ const result = (outcome: ConnectorTest['outcome'], scope: string, problem: string | null = null, tools: string[] = []): ConnectorTest => ({
543
+ connectorId: c.id,
544
+ at: nowIso(),
545
+ outcome,
546
+ scope,
547
+ problem: problem === null ? null : clean(problem, 300),
548
+ toolCount: tools.length,
549
+ tools: tools.slice(0, TEST_TOOLS).map((t) => clean(t, 100)),
550
+ durationMs: Date.now() - started,
551
+ });
552
+ if (!c.testable) return result('untestable', c.basis);
553
+ const name = c.id.slice(c.id.indexOf(':') + 1);
554
+ const signal = AbortSignal.timeout(TEST_TIMEOUT_MS);
555
+ const list = async (url: string, auth: string | Record<string, string>) => {
556
+ const s = new McpSession(url, auth, this.#allow, signal);
557
+ for (const v of s.secrets) secrets.add(v);
558
+ await s.initialize();
559
+ return (await s.listTools()).map((t) => t.name);
560
+ };
561
+ try {
562
+ let tools: string[];
563
+ if (c.source === 'library') {
564
+ const s = this.#findServer(name);
565
+ if (!s) return result('failed', TRIED, 'It is not in the library any more.');
566
+ const refused = refusedOutbound(s.url, this.#allow);
567
+ if (refused) {
568
+ return result(
569
+ 'untestable',
570
+ "Botons' server connects only to HTTPS addresses, or to private and loopback ones listed in BOTONS_OAUTH_ALLOW, so it didn't test this one. A Boton's brain connects to it itself in a turn.",
571
+ `Won't connect: ${refused}.`,
572
+ );
573
+ }
574
+ for (const v of secretsOf(s.headers)) secrets.add(v);
575
+ tools = await list(s.url, s.headers);
576
+ const auth = presetFor(s.url)?.id === 'github' ? authorizationOf(s.headers) : null;
577
+ if (auth) {
578
+ const access = await githubAccess(auth, signal);
579
+ if (access === 'rejected') return result('failed', GITHUB_CHECKED, GITHUB_REJECTED, tools);
580
+ if (access === 'public-only') return result('reached', GITHUB_CHECKED, GITHUB_PUBLIC_ONLY, tools);
581
+ if (access === 'private') return result('reached', GITHUB_PRIVATE, null, tools);
582
+ }
583
+ } else {
584
+ const rec = this.oauth?.byName(name);
585
+ if (!rec) return result('failed', TRIED, 'It is not a connector any more.');
586
+ const rememberCredentials = () => {
587
+ const current = this.oauth?.record(rec.id);
588
+ for (const v of [current?.accessToken, current?.refreshToken]) if (v) secrets.add(v);
589
+ };
590
+ rememberCredentials();
591
+ let token = await this.oauth.accessToken(rec.id, 60_000);
592
+ rememberCredentials();
593
+ try {
594
+ tools = await list(rec.url, token);
595
+ } catch (err) {
596
+ if (!(err instanceof McpError && err.kind === 'auth')) throw err;
597
+ rememberCredentials();
598
+ token = await this.oauth.replaceToken(rec.id, token);
599
+ rememberCredentials();
600
+ tools = await list(rec.url, token);
601
+ }
602
+ // Signed in again while it ran: the test used the earlier grant, so it says nothing about the current one.
603
+ if (this.oauth.record(rec.id)?.generation !== rec.generation) return this.#changedMeanwhile(c, result('untestable', c.basis));
604
+ }
605
+ return result('reached', REACHED, null, tools);
606
+ } catch (err) {
607
+ const why =
608
+ err instanceof OAuthError && err.kind === 'grant'
609
+ ? 'It needs signing in again.'
610
+ : signal.aborted
611
+ ? `It didn't finish within ${TEST_TIMEOUT_MS / 1000} seconds.`
612
+ : clean((err as Error).message, 300);
613
+ log.warn('a connector test failed', { connector: c.id, error: why });
614
+ return result('failed', TRIED, why);
615
+ }
616
+ }
617
+
618
+ /** Known connector ids, without repeats; an id the Boton already has may stay even if its connector is gone. */
619
+ check(ids: unknown, current: string[] = []): string[] {
620
+ if (!Array.isArray(ids) || !ids.every((c) => typeof c === 'string')) throw new HttpError(400, 'connections must be a list of connector ids');
621
+ const list = [...new Set(ids as string[])];
622
+ if (countedConnections(list) > MAX_CONNECTIONS) throw new HttpError(400, `A Boton can have at most ${MAX_CONNECTIONS} connectors`);
623
+ const known = new Set(this.catalog().connectors.map((c) => c.id));
624
+ const unknown = list.filter((id) => !known.has(id) && !current.includes(id));
625
+ if (unknown.length) throw new HttpError(400, `Unknown connector ${unknown[0]}. See GET /api/connectors.`);
626
+ return list;
627
+ }
628
+
629
+ /**
630
+ * The servers a turn on `provider` gets, secrets included (for the agent CLI only), and the ids it skips with
631
+ * why. An OAuth connector's access token is fetched (refreshed first if it would expire within `forMs`). Each library
632
+ * command starts in the Workspace sandbox under `launch` (connector-launch.ts); one that can't is left out with why,
633
+ * and without `launch` every one is. The caller removes the launch files with release() when the turn ends.
634
+ */
635
+ async resolve(ids: string[], provider: Pick<Provider, 'cli' | 'fitMcpServers' | 'serverTools'> | undefined, opts: { forMs?: number; launch?: LaunchPolicy } = {}): Promise<ResolvedConnectors> {
636
+ const out: ResolvedConnectors = { servers: [], skipped: [], ids: {} };
637
+ if (!ids.length) return out;
638
+ if (!provider?.cli && !provider?.serverTools) {
639
+ for (const id of ids) out.skipped.push({ id, reason: "this Boton's brain can't use connectors" });
640
+ return out;
641
+ }
642
+ // Whatever throws once a launch file is written (another connector's, a token store, fitting them to the CLI):
643
+ // those written so far are removed before the error goes on, since no caller gets them to release.
644
+ try {
645
+ return await this.#resolve(ids, provider, opts, out);
646
+ } catch (err) {
647
+ this.release(out);
648
+ throw err;
649
+ }
650
+ }
651
+
652
+ async #resolve(ids: string[], provider: Pick<Provider, 'cli' | 'fitMcpServers' | 'serverTools'>, opts: { forMs?: number; launch?: LaunchPolicy }, out: ResolvedConnectors): Promise<ResolvedConnectors> {
653
+ const names = new Map<string, string>();
654
+ let jail: Jail | undefined;
655
+ for (const id of ids) {
656
+ const colon = id.indexOf(':');
657
+ const source = id.slice(0, colon);
658
+ const name = id.slice(colon + 1);
659
+ let server: TurnMcpServer | null = null;
660
+ // Off: left out before anything is read for it (no secret, no token fetched or refreshed).
661
+ if (this.isOff(id)) {
662
+ out.skipped.push({ id, reason: OFF_REASON });
663
+ continue;
664
+ }
665
+ if (id === BROWSER_CONNECTOR) {
666
+ // Only an agent CLI's turns drive it (drivesBrowser); an API key brain is never handed tools it can't use. Not a
667
+ // turn that went without it either: a Boton moved to such a brain keeps it for a move back, and its Browser
668
+ // settings say why it is off.
669
+ if (!drivesBrowser(provider)) continue;
670
+ const why = this.browser ? this.browser.unavailable() : 'this server has no managed browser';
671
+ if (!this.browser || why) {
672
+ out.skipped.push({ id, reason: why ?? 'this server has no managed browser' });
673
+ continue;
674
+ }
675
+ server = this.browser.server();
676
+ } else if (source === 'lib') {
677
+ const s = this.#findServer(name);
678
+ if (!s) {
679
+ out.skipped.push({ id, reason: 'not in the library' });
680
+ continue;
681
+ }
682
+ if (s.transport === 'stdio') {
683
+ jail ??= opts.launch ? connectorJail(opts.launch) : { problem: 'a local connector starts only in the Workspace sandbox, and this turn has none for it' };
684
+ const launch = 'problem' in jail ? jail : jail.wrap(s);
685
+ if ('problem' in launch) {
686
+ out.skipped.push({ id, reason: `not started: ${launch.problem}` });
687
+ continue;
688
+ }
689
+ (out.launchFiles ??= []).push(launch.file);
690
+ server = { name: s.name, kind: 'stdio', command: launch.command, args: launch.args, env: launch.env, secrets: launch.secrets };
691
+ } else server = { name: s.name, kind: 'http', url: s.url, headers: { ...s.headers } };
692
+ } else if (source === 'oauth') {
693
+ const c = this.oauth?.byName(name);
694
+ if (!c) {
695
+ out.skipped.push({ id, reason: 'no such OAuth connector' });
696
+ continue;
697
+ }
698
+ if (names.has(c.name)) {
699
+ out.skipped.push({ id, reason: `another connector is already named ${c.name}` });
700
+ continue;
701
+ }
702
+ try {
703
+ const token = await this.oauth.accessToken(c.id, opts.forMs ?? 0);
704
+ server = { name: c.name, kind: 'http', url: c.url, headers: { Authorization: `Bearer ${token}` } };
705
+ } catch (err) {
706
+ // Shown to the owner and in the Boton's own turn: no credential of this connector, before the cut.
707
+ const rec = this.oauth.record(c.id);
708
+ const why = sanitize((err as Error).message, [rec?.accessToken, rec?.refreshToken], 120);
709
+ out.skipped.push({ id, reason: err instanceof OAuthError && err.kind === 'grant' ? 'needs signing in again' : `its token could not be refreshed (${why})` });
710
+ continue;
711
+ }
712
+ } else {
713
+ // Never looked up in the CLI's own setup: an id that isn't a Botons connector is left out, and said so.
714
+ out.skipped.push({ id, reason: 'not a Botons connector (add it in Settings → Connectors)' });
715
+ continue;
716
+ }
717
+ if (names.has(server.name)) {
718
+ out.skipped.push({ id, reason: `another connector is already named ${server.name}` });
719
+ continue;
720
+ }
721
+ names.set(server.name, id);
722
+ out.servers.push(server);
723
+ }
724
+ const fit = out.servers.length ? provider?.fitMcpServers?.(out.servers) : undefined;
725
+ if (fit) {
726
+ for (const s of fit.skipped) out.skipped.push({ id: names.get(s.name) ?? s.name, reason: s.reason });
727
+ out.servers = fit.servers;
728
+ }
729
+ for (const s of out.servers) out.ids[s.name] = names.get(s.name)!;
730
+ return out;
731
+ }
732
+
733
+ /**
734
+ * Removes a turn's launch files (resolve()); the files are gone from then on, so the CLI can't start them again.
735
+ * Never throws, so an error it runs after goes on as it was: one that can't be removed is logged, and the next start
736
+ * removes it (releaseAll).
737
+ */
738
+ release(r: Pick<ResolvedConnectors, 'launchFiles'>): void {
739
+ const files = r.launchFiles ?? [];
740
+ r.launchFiles = [];
741
+ try {
742
+ release(files);
743
+ } catch (err) {
744
+ log.warn('could not remove a local connector’s launch file', { err });
745
+ }
746
+ }
747
+
748
+ // ------------------------------------------------- asking for connectors
749
+
750
+ #policies(): Map<string, ConnectorPolicy> {
751
+ const rows = this.#db.prepare('select * from connector_policies').all() as Row[];
752
+ return new Map(rows.map((r) => [String(r.connector_id), { onRequest: !!r.on_request, grant: r.grant_mode, generation: Number(r.generation) }]));
753
+ }
754
+
755
+ policyOf(id: string): ConnectorPolicy {
756
+ return this.#policies().get(id) ?? NO_POLICY;
757
+ }
758
+
759
+ /** The owner's choice for one connector; every change starts a new generation, so a card asked under the old one can't be allowed. */
760
+ setPolicy(id: string, input: ConnectorPolicyInput): Connector {
761
+ const c = this.catalog().connectors.find((x) => x.id === id);
762
+ if (!c) throw new HttpError(404, `No connector ${id}`);
763
+ if (c.managed) throw new HttpError(400, `${c.name} can't be asked for: only you give a Boton its browser, in its settings`);
764
+ if (input.onRequest !== undefined && typeof input.onRequest !== 'boolean') throw new HttpError(400, 'onRequest must be true or false');
765
+ if (input.grant !== undefined && input.grant !== 'approval' && input.grant !== 'immediate') throw new HttpError(400, 'grant must be "approval" or "immediate"');
766
+ const before = this.policyOf(id);
767
+ const next = { onRequest: input.onRequest ?? before.onRequest, grant: input.grant ?? before.grant };
768
+ issuePolicy(this.#db, id, next);
769
+ log.info('connector request policy changed', { connector: id, onRequest: next.onRequest, grant: next.grant });
770
+ return this.catalog().connectors.find((x) => x.id === id)!;
771
+ }
772
+
773
+ /**
774
+ * What `botons connector list` shows a Boton: its own and the ones it may ask for, by id and name only. One of its
775
+ * own its last turn went without (left out, or its CLI couldn't start it) is not usable either.
776
+ */
777
+ forBoton(bot: { id?: string; connections: string[]; provider: string }): BotonConnector[] {
778
+ const all = this.catalog().connectors;
779
+ const out: BotonConnector[] = [];
780
+ const without = new Set(
781
+ bot.id ? (this.#db.prepare("select connector_id from connector_notices where bot_id = ? and kind = 'skipped'").all(bot.id) as Row[]).map((r) => String(r.connector_id)) : [],
782
+ );
783
+ for (const id of bot.connections) {
784
+ const c = all.find((x) => x.id === id);
785
+ const off = !!c && !c.enabled;
786
+ out.push({
787
+ id,
788
+ name: c?.name ?? id.slice(id.indexOf(':') + 1),
789
+ state: 'yours',
790
+ grant: null,
791
+ usable: !!c && !off && c.status === 'ok' && c.providers.includes(bot.provider) && !without.has(id),
792
+ off,
793
+ });
794
+ }
795
+ for (const c of all) {
796
+ if (!c.onRequest || !c.enabled || bot.connections.includes(c.id) || !c.providers.includes(bot.provider)) continue;
797
+ out.push({ id: c.id, name: c.name, state: 'available', grant: c.grant, usable: c.status === 'ok', off: false });
798
+ }
799
+ return out;
800
+ }
801
+
802
+ /**
803
+ * Why `id` can't be given to this Boton now, or null. `asked` is the policy generation a card or request recorded
804
+ * (`'now'`: none, the request is being made): only that one, issued as above, can still be allowed. With `usable`,
805
+ * one known not to work now (an OAuth connector not signed in) can't be either: what `botons connector list` shows
806
+ * as not usable.
807
+ */
808
+ grantProblem(bot: { connections: string[]; provider: string }, id: string, asked: unknown = 'now', opts: { usable?: boolean } = {}): string | null {
809
+ const c = this.catalog().connectors.find((x) => x.id === id);
810
+ if (!c) return `There is no connector ${id} any more`;
811
+ if (c.managed) return `${c.name} can't be asked for`;
812
+ if (!c.enabled) return `${c.name} is turned off`;
813
+ if (!c.providers.includes(bot.provider)) return `${c.name} can't be used by this Boton's brain`;
814
+ const p = this.policyOf(id);
815
+ if (!p.onRequest) return `${c.name} is not available on request any more`;
816
+ if (asked !== 'now') {
817
+ if (!issuedGeneration(asked)) return `This was asked in a way that can't be matched to ${c.name} as it is set up now (it may have been asked for one deleted since): the Boton has to ask for it again`;
818
+ if (p.generation !== asked) return `How ${c.name} is granted changed since the request was made`;
819
+ }
820
+ if (opts.usable && c.status !== 'ok') return `${c.name} is not signed in: it can be given once it is signed in, in Settings → Connectors`;
821
+ if (countedConnections(bot.connections) >= MAX_CONNECTIONS) return `A Boton can have at most ${MAX_CONNECTIONS} connectors`;
822
+ return null;
823
+ }
824
+
825
+ // ------------------------------------------------- notices for the owner
826
+
827
+ /**
828
+ * What the owner should know about this Boton's connectors, newest first: ones taken off it because Botons no
829
+ * longer uses the CLIs' own servers, and ones its last turn went without. Each offers a Botons connector with the
830
+ * same name or address, if there is one it doesn't have. Notices that no longer apply (a skipped connector it
831
+ * doesn't have any more, a removed one whose offer it has now) are cleared as they are read.
832
+ */
833
+ notices(bot: { id: string; connections: string[] }): ConnectorNotice[] {
834
+ const rows = this.#db.prepare('select * from connector_notices where bot_id = ? order by at desc, connector_id').all(bot.id) as Row[];
835
+ const all = this.catalog().connectors;
836
+ const out: ConnectorNotice[] = [];
837
+ for (const r of rows) {
838
+ const offer = r.kind === 'removed' ? matching(all, String(r.name), String(r.target)) : null;
839
+ const stale = r.kind === 'skipped' ? !bot.connections.includes(r.connector_id) : !!offer && bot.connections.includes(offer.id);
840
+ if (stale) {
841
+ this.dismissNotice(bot.id, r.connector_id);
842
+ continue;
843
+ }
844
+ out.push({
845
+ botId: bot.id,
846
+ connectorId: r.connector_id,
847
+ kind: r.kind,
848
+ name: r.name,
849
+ target: r.target,
850
+ reason: r.reason,
851
+ at: r.at,
852
+ offer: offer && !bot.connections.includes(offer.id) ? { id: offer.id, name: offer.name, target: offer.target } : null,
853
+ });
854
+ }
855
+ return out;
856
+ }
857
+
858
+ /** Why this Boton's last turn went without connector `id` (left out, or its CLI couldn't start it), or null. */
859
+ withoutReason(botId: string, id: string): string | null {
860
+ const r = this.#db.prepare("select reason from connector_notices where bot_id = ? and connector_id = ? and kind = 'skipped'").get(botId, id) as Row | undefined;
861
+ return r ? String(r.reason) : null;
862
+ }
863
+
864
+ /** The owner dismissed it (or it no longer applies). Returns whether there was one. */
865
+ dismissNotice(botId: string, connectorId: string): boolean {
866
+ return Number(this.#db.prepare('delete from connector_notices where bot_id = ? and connector_id = ?').run(botId, connectorId).changes) > 0;
867
+ }
868
+
869
+ /**
870
+ * After a turn's connectors were resolved: each it went without is noted with why (the owner sees it in the
871
+ * Boton's settings), and the ones it got clear theirs. Removed notices stay until the owner deals with them.
872
+ */
873
+ recordTurn(botId: string, resolved: Pick<ResolvedConnectors, 'skipped' | 'ids'>): void {
874
+ const clear = this.#db.prepare("delete from connector_notices where bot_id = ? and connector_id = ? and kind = 'skipped'");
875
+ for (const id of Object.values(resolved.ids)) clear.run(botId, id);
876
+ this.#noteSkipped(botId, resolved.skipped);
877
+ }
878
+
879
+ /**
880
+ * After the turn ran: each connector it was given that the CLI showed didn't start is noted like one resolve left
881
+ * out, with the CLI's reason. One the CLI said nothing about stays as it is: given, not known to have started.
882
+ */
883
+ recordStartup(botId: string, ids: Record<string, string>, startup: McpStartup[]): void {
884
+ this.#noteSkipped(
885
+ botId,
886
+ startup.flatMap((s) => (!s.started && ids[s.name] ? [{ id: ids[s.name], reason: s.reason ?? 'it did not start' }] : [])),
887
+ );
888
+ }
889
+
890
+ #noteSkipped(botId: string, skipped: { id: string; reason: string }[]): void {
891
+ if (!skipped.length) return;
892
+ const all = this.catalog().connectors;
893
+ const now = nowIso();
894
+ const note = this.#db.prepare(
895
+ "insert into connector_notices (bot_id, connector_id, kind, name, target, reason, at) values (?, ?, 'skipped', ?, ?, ?, ?) on conflict(bot_id, connector_id) do update set name = excluded.name, target = excluded.target, reason = excluded.reason, at = excluded.at where kind = 'skipped'",
896
+ );
897
+ for (const s of skipped) {
898
+ const c = all.find((x) => x.id === s.id);
899
+ note.run(botId, s.id, c?.name ?? s.id.slice(s.id.indexOf(':') + 1), c?.target ?? '', s.reason, now);
900
+ }
901
+ }
902
+ }
903
+
904
+ /** A Botons connector with this name (OAuth first), else one at this address: what to offer for a removed one. */
905
+ function matching(all: Connector[], name: string, target: string): Connector | null {
906
+ const byName = all.filter((c) => c.name === name);
907
+ if (byName.length) return byName.find((c) => c.source === 'oauth') ?? byName[0];
908
+ if (!target) return null;
909
+ const at = all.filter((c) => c.transport === 'http' && sameAddress(c.target, target));
910
+ return at.find((c) => c.source === 'oauth') ?? at[0] ?? null;
911
+ }
912
+
913
+ /**
914
+ * Whether two MCP addresses name the same server: the scheme and host compare as the URL parser normalizes them
915
+ * (case, default port), the path exactly (including case and trailing slashes), and the query
916
+ * and fragment not at all (a notice keeps the address without them).
917
+ */
918
+ export function sameAddress(a: string, b: string): boolean {
919
+ const key = (t: string) => {
920
+ try {
921
+ const u = new URL(t);
922
+ return `${u.protocol}//${u.host}${u.pathname}`;
923
+ } catch {
924
+ return t.split(/[?#]/)[0];
925
+ }
926
+ };
927
+ return key(a) === key(b);
928
+ }
929
+
930
+ /** A write-only patch of env vars or headers: a string sets, null removes, left out stays. */
931
+ export function patchSecrets(current: Record<string, string>, patch: unknown, field: string, key: RegExp): Record<string, string> {
932
+ if (patch === undefined) return current;
933
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new HttpError(400, `${field} must be an object of names to values`);
934
+ const next = { ...current };
935
+ for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
936
+ // Not echoed: a token pasted where its name goes would come back in the error, and never kept as a name.
937
+ if (tokenShapedName(k)) throw new HttpError(400, field === 'env' ? 'env: that looks like a token, not a variable name. Put the token in its value.' : TOKEN_AS_NAME);
938
+ if (!key.test(k)) throw new HttpError(400, field === 'env' ? 'env: not a valid name (letters, digits and _, not starting with a digit)' : "headers: not a valid name (letters, digits and -, no spaces or ':')");
939
+ if (field === 'env' && (/^BOTONS?_/.test(k) || isLegacySetting(k) || k.startsWith('LC_'))) throw new HttpError(400, `env: ${k} is reserved`);
940
+ if (v === null) delete next[k];
941
+ else if (typeof v !== 'string' || v.length > MAX_VALUE || v.includes('\0') || (field === 'headers' && /[\r\n]/.test(v))) {
942
+ throw new HttpError(400, `${field === 'env' ? 'An environment variable' : 'A header'}'s value must be text of at most ${MAX_VALUE} characters${field === 'headers' ? ' on one line' : ''}`);
943
+ } else next[k] = v;
944
+ }
945
+ if (Object.keys(next).length > MAX_ENTRIES) throw new HttpError(400, `At most ${MAX_ENTRIES} ${field}`);
946
+ return next;
947
+ }