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,544 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type {
3
+ ApiKeyProvider,
4
+ ApiKeySource,
5
+ ApiKeyState,
6
+ ApiKeysView,
7
+ ApiKeyTest,
8
+ ApiKeyTestOutcome,
9
+ ApiModel,
10
+ ApiModelCapabilities,
11
+ ApiModelObservation,
12
+ CapabilityEvidence,
13
+ RemoveApiKeyInput,
14
+ SaveApiKeyInput,
15
+ } from '../../shared/types.ts';
16
+ import type { EventHub } from './events.ts';
17
+ import { log } from './log.ts';
18
+ import { HttpError } from './store.ts';
19
+ import { nowIso, parseJson } from './util.ts';
20
+
21
+ /*
22
+ * The owner's own Anthropic and OpenAI API keys. A saved key is kept in the database (the settings table, as the
23
+ * ntfy token is, beside the OAuth tokens), so it is in every backup and Workspace Botons can't read it; the server
24
+ * environment's ANTHROPIC_API_KEY or OPENAI_API_KEY is used only when none is saved. No key ever leaves here: the API
25
+ * answers with whether there is one, where it comes from and fixed test results; logs, events and the audit log get
26
+ * the provider and fixed words. Agent CLIs never see either (providers/process.ts passes only its allowlist).
27
+ *
28
+ * Each save or removal bumps the provider's generation, which a removal keeps (the row stays, keyless). A change
29
+ * must name the generation the page saw, so a stale page can't overwrite a newer key, and a test is saved only if
30
+ * the key is still the one it started with: a late answer about an old key never reads as a result for the new one.
31
+ *
32
+ * A test lists the key's models, read-only, from the provider's fixed HTTPS address: no prompt, no generation. Only
33
+ * the models' ids, plain display names, lifecycle and the capability fields the list has are kept, each in its
34
+ * expected shape; a model whose id could hold the key is left out. Nothing else the provider says is kept.
35
+ *
36
+ * Botons runs Botons on these keys (providers/api-brain.ts) with its own tool loop, sandbox and spending caps: the
37
+ * key goes only into the request headers of the providers' fixed addresses, never into a tool's environment, a tool's
38
+ * output, argv, the log or an event. What real turns showed a model doing (a reply, a tool call, an image read) is kept
39
+ * per key as evidence; nothing is ever sent just to find out.
40
+ */
41
+
42
+ export const API_KEY_PROVIDERS: readonly ApiKeyProvider[] = ['anthropic', 'openai'];
43
+
44
+ const INFO: Record<ApiKeyProvider, { label: string; envVar: string; origin: string; test: string }> = {
45
+ anthropic: {
46
+ label: 'Anthropic',
47
+ envVar: 'ANTHROPIC_API_KEY',
48
+ origin: 'https://api.anthropic.com',
49
+ test: 'Test key asks api.anthropic.com for the list of models this key can use (GET /v1/models). It sends no prompt and generates no text, so it uses no tokens.',
50
+ },
51
+ openai: {
52
+ label: 'OpenAI',
53
+ envVar: 'OPENAI_API_KEY',
54
+ origin: 'https://api.openai.com',
55
+ test: 'Test key asks api.openai.com for the list of models this key can use (GET /v1/models). It sends no prompt and generates no text, so it uses no tokens.',
56
+ },
57
+ };
58
+
59
+ export const API_BRAIN: Record<ApiKeyProvider, string> = { anthropic: 'anthropic-api', openai: 'openai-api' };
60
+
61
+ /** Tests per provider per minute. */
62
+ export const API_KEY_TEST_LIMIT = 6;
63
+ const TEST_WINDOW_MS = 60_000;
64
+ const TEST_TIMEOUT_MS = 15_000;
65
+ /** The most of a model list's answer read, per page. */
66
+ const MAX_BODY = 4 * 1024 * 1024;
67
+ const MAX_MODELS = 500;
68
+ const ANTHROPIC_PAGES = 3;
69
+ const ANTHROPIC_VERSION = '2023-06-01';
70
+
71
+ const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
72
+ const DISPLAY_NAME = /^[A-Za-z0-9][A-Za-z0-9 .()_+-]{0,79}$/;
73
+ const KEY_TEXT = /^[\x21-\x7e]{20,512}$/;
74
+ const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
75
+
76
+ const UNKNOWN: ApiModelCapabilities = { chat: 'unknown', tools: 'unknown', vision: 'unknown', realtimeVoice: 'unknown', speechToText: 'unknown', textToSpeech: 'unknown' };
77
+
78
+ /** What one provider's row in the settings table holds. The key is the only secret. */
79
+ interface Stored {
80
+ key: string | null;
81
+ generation: number;
82
+ savedAt: string | null;
83
+ /** A test of the saved key at `generation`. */
84
+ test: ApiKeyTest | null;
85
+ /** What this key's turns showed, by model (cleared with the key). */
86
+ observed?: Record<string, Omit<ApiModelObservation, 'model'>>;
87
+ }
88
+
89
+ const rowKey = (p: ApiKeyProvider) => `apiKey:${p}`;
90
+
91
+ function isProvider(v: string): v is ApiKeyProvider {
92
+ return (API_KEY_PROVIDERS as readonly string[]).includes(v);
93
+ }
94
+
95
+ /** The provider named in a path, or 404. */
96
+ export function apiKeyProvider(v: string): ApiKeyProvider {
97
+ if (!isProvider(v)) throw new HttpError(404, 'No such API key provider: use anthropic or openai');
98
+ return v;
99
+ }
100
+
101
+ function generationOf(input: unknown): number {
102
+ const g = (input as { generation?: unknown } | null)?.generation;
103
+ if (typeof g !== 'number' || !Number.isSafeInteger(g) || g < 0) throw new HttpError(400, 'generation must be the whole number the page last saw');
104
+ return g;
105
+ }
106
+
107
+ /** A pasted key, checked for shape only; errors never repeat it. */
108
+ function keyOf(p: ApiKeyProvider, input: unknown): string {
109
+ const raw = (input as { key?: unknown } | null)?.key;
110
+ if (typeof raw !== 'string') throw new HttpError(400, 'key must be the API key, as text');
111
+ const key = raw.trim();
112
+ if (!KEY_TEXT.test(key)) throw new HttpError(400, 'That is not an API key: one is 20 to 512 characters with no spaces');
113
+ if (p === 'anthropic') {
114
+ if (key.startsWith('sk-ant-admin')) throw new HttpError(400, "That is an Anthropic Admin API key, which can't use models. Create a regular API key in the Claude Console.");
115
+ if (!key.startsWith('sk-ant-')) throw new HttpError(400, 'An Anthropic API key starts with sk-ant-');
116
+ } else {
117
+ if (key.startsWith('sk-ant-')) throw new HttpError(400, 'That is an Anthropic key: save it as the Anthropic key');
118
+ if (key.startsWith('sk-admin-')) throw new HttpError(400, "That is an OpenAI admin key, which can't use models. Create a project API key.");
119
+ if (!key.startsWith('sk-')) throw new HttpError(400, 'An OpenAI API key starts with sk-');
120
+ }
121
+ return key;
122
+ }
123
+
124
+ function outcomeOf(status: number): ApiKeyTestOutcome {
125
+ if (status === 401) return 'invalid';
126
+ if (status === 403) return 'forbidden';
127
+ if (status === 429) return 'rate_limited';
128
+ if (status >= 500 && status <= 599) return 'unavailable';
129
+ return 'unexpected';
130
+ }
131
+
132
+ const evidence = (v: unknown): CapabilityEvidence => {
133
+ const s = v && typeof v === 'object' ? (v as { supported?: unknown }).supported : undefined;
134
+ return s === true ? 'yes' : s === false ? 'no' : 'unknown';
135
+ };
136
+
137
+ /**
138
+ * Reject raw, case-folded, hex, base64 and URL-encoded key echoes, including prefixed fragments. Display text is
139
+ * never used to authorize a request. This catches common reflections, not every transformation an adversary could
140
+ * invent; unrecognized provider text is omitted independently by the model-list schema.
141
+ */
142
+ function secretFilter(key: string): (text: string) => boolean {
143
+ const variants = [key, Buffer.from(key).toString('hex'), Buffer.from(key).toString('base64'), Buffer.from(key).toString('base64url'), encodeURIComponent(key)];
144
+ const fragments = new Set<string>();
145
+ const short: string[] = [];
146
+ for (const variant of variants) {
147
+ const v = variant.toLowerCase();
148
+ if (v.length < 8) {
149
+ if (v) short.push(v);
150
+ continue;
151
+ }
152
+ for (let i = 0; i <= v.length - 8; i++) fragments.add(v.slice(i, i + 8));
153
+ }
154
+ return (text) => {
155
+ const lower = text.toLowerCase();
156
+ if (short.some((v) => lower.includes(v))) return true;
157
+ for (let i = 0; i <= lower.length - 8; i++) if (fragments.has(lower.slice(i, i + 8))) return true;
158
+ return false;
159
+ };
160
+ }
161
+
162
+ const mayHold = (text: string, key: string) => secretFilter(key)(text);
163
+
164
+ /** Whether `text` may hold `key` or a fragment of it (what redactKey() takes out). */
165
+ export function keyFilter(key: string): (text: string) => boolean {
166
+ return secretFilter(key);
167
+ }
168
+
169
+ /**
170
+ * `text` with the key, and the common encodings of it secretFilter() knows, replaced: for anything a provider or a tool
171
+ * says that goes on to the model, the chat, the log or an error.
172
+ */
173
+ export function redactKey(text: string, key: string): string {
174
+ if (!text) return text;
175
+ let out = text;
176
+ for (const v of [key, Buffer.from(key).toString('hex'), Buffer.from(key).toString('base64'), Buffer.from(key).toString('base64url'), encodeURIComponent(key)]) {
177
+ if (v.length >= 8) out = out.split(v).join('[redacted]');
178
+ }
179
+ // A fragment of it (16 characters or more) that survived, case-folded or not.
180
+ const holds = secretFilter(key);
181
+ if (holds(out)) out = out.replace(/[\x21-\x7e]{8,}/g, (word) => (holds(word) ? '[redacted]' : word));
182
+ return out;
183
+ }
184
+
185
+ interface Listing {
186
+ outcome: ApiKeyTestOutcome;
187
+ httpStatus: number | null;
188
+ models: ApiModel[];
189
+ omitted: number;
190
+ truncated: boolean;
191
+ }
192
+
193
+ class Unexpected extends Error {}
194
+
195
+ /** The answer's JSON, at most MAX_BODY bytes of it; anything else is Unexpected. */
196
+ async function readJson(res: Response): Promise<unknown> {
197
+ if (!res.body) throw new Unexpected();
198
+ const reader = res.body.getReader();
199
+ const chunks: Uint8Array[] = [];
200
+ let size = 0;
201
+ for (;;) {
202
+ const { done, value } = await reader.read();
203
+ if (done) break;
204
+ size += value.byteLength;
205
+ if (size > MAX_BODY) {
206
+ await reader.cancel().catch(() => {});
207
+ throw new Unexpected();
208
+ }
209
+ chunks.push(value);
210
+ }
211
+ try {
212
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
213
+ } catch {
214
+ throw new Unexpected();
215
+ }
216
+ }
217
+
218
+ /** Models from one page of a list, in their expected shape; the rest are counted, not kept. */
219
+ function modelsFrom(p: ApiKeyProvider, data: unknown[], key: string, into: Map<string, ApiModel>): number {
220
+ let omitted = 0;
221
+ const holdsSecret = secretFilter(key);
222
+ for (const item of data) {
223
+ const m = item && typeof item === 'object' ? (item as Record<string, unknown>) : null;
224
+ const id = m?.id;
225
+ if (typeof id !== 'string' || !MODEL_ID.test(id) || holdsSecret(id) || into.size >= MAX_MODELS) {
226
+ omitted++;
227
+ continue;
228
+ }
229
+ if (into.has(id)) continue;
230
+ if (p === 'openai') {
231
+ into.set(id, { id, displayName: null, lifecycle: null, capabilities: { ...UNKNOWN }, effort: null });
232
+ continue;
233
+ }
234
+ const name = m?.display_name;
235
+ const caps = m?.capabilities && typeof m.capabilities === 'object' ? (m.capabilities as Record<string, unknown>) : null;
236
+ const effortCap = caps?.effort && typeof caps.effort === 'object' ? (caps.effort as Record<string, unknown>) : null;
237
+ const effortSupported = effortCap ? evidence(effortCap) : 'unknown';
238
+ into.set(id, {
239
+ id,
240
+ displayName: typeof name === 'string' && DISPLAY_NAME.test(name) && !holdsSecret(name) ? name : null,
241
+ lifecycle: m?.lifecycle === 'active' || m?.lifecycle === 'deprecated' || m?.lifecycle === 'retired' ? m.lifecycle : null,
242
+ // The list has a field for images; chat, tools, voice and speech have none there, so they stay unknown.
243
+ capabilities: { ...UNKNOWN, vision: caps ? evidence(caps.image_input) : 'unknown' },
244
+ effort: effortSupported === 'yes' ? EFFORT_LEVELS.filter((l) => evidence(effortCap?.[l]) === 'yes') : effortSupported === 'no' ? [] : null,
245
+ });
246
+ }
247
+ return omitted;
248
+ }
249
+
250
+ export interface ApiKeysOptions {
251
+ db: DatabaseSync;
252
+ hub: EventHub;
253
+ /** Where ANTHROPIC_API_KEY and OPENAI_API_KEY are read from, once (the server's environment). */
254
+ env: Record<string, string | undefined>;
255
+ /**
256
+ * Tests only: plain-HTTP loopback servers to send tests to in place of the providers' HTTPS addresses. Set in
257
+ * code (createApp's options), never from a setting, the environment or the API; anything else is refused.
258
+ */
259
+ testEndpoints?: Partial<Record<ApiKeyProvider, string>>;
260
+ timeoutMs?: number;
261
+ }
262
+
263
+ export class ApiKeys {
264
+ #db: DatabaseSync;
265
+ #hub: EventHub;
266
+ #env: Record<ApiKeyProvider, string | null>;
267
+ #origins: Record<ApiKeyProvider, string>;
268
+ #timeoutMs: number;
269
+ /** Tests of the environment's key, which isn't saved: they are about this process's environment only. */
270
+ #envTests = new Map<ApiKeyProvider, ApiKeyTest>();
271
+ /** What the environment's key's turns showed, since this process started. */
272
+ #envObserved = new Map<ApiKeyProvider, Record<string, Omit<ApiModelObservation, 'model'>>>();
273
+ #running = new Map<ApiKeyProvider, AbortController>();
274
+ #recent = new Map<ApiKeyProvider, number[]>();
275
+
276
+ constructor(opts: ApiKeysOptions) {
277
+ this.#db = opts.db;
278
+ this.#hub = opts.hub;
279
+ this.#timeoutMs = opts.timeoutMs ?? TEST_TIMEOUT_MS;
280
+ const fromEnv = (name: string) => {
281
+ const v = opts.env[name]?.trim();
282
+ return v ? v : null;
283
+ };
284
+ this.#env = { anthropic: fromEnv(INFO.anthropic.envVar), openai: fromEnv(INFO.openai.envVar) };
285
+ this.#origins = { anthropic: INFO.anthropic.origin, openai: INFO.openai.origin };
286
+ for (const [p, url] of Object.entries(opts.testEndpoints ?? {})) {
287
+ const u = new URL(url as string);
288
+ if (!isProvider(p) || u.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(u.hostname) || u.pathname !== '/' || u.search || u.username) {
289
+ throw new Error('API key test endpoints are for tests only: a plain http:// loopback origin');
290
+ }
291
+ this.#origins[p] = u.origin;
292
+ }
293
+ }
294
+
295
+ #stored(p: ApiKeyProvider): { raw: string | null; s: Stored } {
296
+ const raw = (this.#db.prepare('select value from settings where key = ?').get(rowKey(p)) as { value: string } | undefined)?.value ?? null;
297
+ const v = parseJson<Partial<Stored>>(raw ?? undefined, {});
298
+ const generation = typeof v.generation === 'number' && Number.isSafeInteger(v.generation) && v.generation >= 0 ? v.generation : 0;
299
+ const key = typeof v.key === 'string' && v.key ? v.key : null;
300
+ const test = key && v.test && typeof v.test === 'object' && v.test.generation === generation ? v.test : null;
301
+ const observed = key && v.observed && typeof v.observed === 'object' && !Array.isArray(v.observed) ? v.observed : undefined;
302
+ return { raw, s: { key, generation, savedAt: key && typeof v.savedAt === 'string' ? v.savedAt : null, test, ...(observed ? { observed } : {}) } };
303
+ }
304
+
305
+ #write(p: ApiKeyProvider, s: Stored): void {
306
+ this.#db.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run(rowKey(p), JSON.stringify(s));
307
+ }
308
+
309
+ /** The key Botons would use now, and which one it is. Never leaves this module. */
310
+ #effective(p: ApiKeyProvider): { key: string; source: Exclude<ApiKeySource, 'none'>; generation: number } | null {
311
+ const { s } = this.#stored(p);
312
+ if (s.key) return { key: s.key, source: 'saved', generation: s.generation };
313
+ const env = this.#env[p];
314
+ return env ? { key: env, source: 'environment', generation: s.generation } : null;
315
+ }
316
+
317
+ state(p: ApiKeyProvider): ApiKeyState {
318
+ const { s } = this.#stored(p);
319
+ const environment = !!this.#env[p];
320
+ const source: ApiKeySource = s.key ? 'saved' : environment ? 'environment' : 'none';
321
+ const test = source === 'saved' ? s.test : source === 'environment' ? (this.#envTests.get(p) ?? null) : null;
322
+ return {
323
+ provider: p,
324
+ label: INFO[p].label,
325
+ envVar: INFO[p].envVar,
326
+ source,
327
+ saved: !!s.key,
328
+ savedAt: s.savedAt,
329
+ environment,
330
+ generation: s.generation,
331
+ test: test ? { ...test, current: true } : null,
332
+ testing: this.#running.has(p),
333
+ testDescription: INFO[p].test,
334
+ brain: { available: source !== 'none', provider: API_BRAIN[p], reason: source === 'none' ? `Save a key (or set ${INFO[p].envVar}) to run Botons on it.` : null },
335
+ observed: Object.entries((source === 'saved' ? s.observed : source === 'environment' ? this.#envObserved.get(p) : undefined) ?? {})
336
+ .filter(([model]) => MODEL_ID.test(model))
337
+ .map(([model, o]) => ({ model, ...o })),
338
+ };
339
+ }
340
+
341
+ /** The key a turn uses now, and which one it is. For providers/api-brain.ts only: never sent anywhere but the provider. */
342
+ keyFor(p: ApiKeyProvider): { key: string; source: Exclude<ApiKeySource, 'none'>; generation: number } | null {
343
+ return this.#effective(p);
344
+ }
345
+
346
+ /**
347
+ * Every key Botons holds now, of both providers, saved and from the environment: what an API brain takes out of
348
+ * everything that comes back, so a key saved while a turn runs is redacted from it too.
349
+ */
350
+ knownKeys(): string[] {
351
+ const out = new Set<string>();
352
+ for (const p of API_KEY_PROVIDERS) {
353
+ const { s } = this.#stored(p);
354
+ if (s.key) out.add(s.key);
355
+ if (this.#env[p]) out.add(this.#env[p]!);
356
+ }
357
+ return [...out];
358
+ }
359
+
360
+ /** Where requests for `p` go: its fixed HTTPS origin (a loopback one only in tests, set in code). */
361
+ origin(p: ApiKeyProvider): string {
362
+ return this.#origins[p];
363
+ }
364
+
365
+ /** The models the newest test of the key in use listed, in their expected shape; empty before a test. */
366
+ models(p: ApiKeyProvider): ApiModel[] {
367
+ const t = this.state(p).test;
368
+ return t && t.outcome === 'ok' ? t.models : [];
369
+ }
370
+
371
+ /** The newest test of the key in use, if it listed the models. */
372
+ testedAt(p: ApiKeyProvider): string | null {
373
+ const t = this.state(p).test;
374
+ return t && t.outcome === 'ok' ? t.checkedAt : null;
375
+ }
376
+
377
+ /**
378
+ * A turn on `used` showed `model` doing `what`: kept only if that key is still the one in use (a removed or replaced
379
+ * key's evidence says nothing about the new one).
380
+ */
381
+ observe(p: ApiKeyProvider, used: { key: string; source: ApiKeySource; generation: number }, model: string, what: ('chat' | 'tools' | 'vision')[]): void {
382
+ if (!what.length || !MODEL_ID.test(model) || mayHold(model, used.key)) return;
383
+ const now = this.#effective(p);
384
+ if (!now || now.source !== used.source || now.generation !== used.generation || now.key !== used.key) return;
385
+ const at = nowIso();
386
+ // The first time each was seen: written only when something is new.
387
+ const add = (all: Record<string, Omit<ApiModelObservation, 'model'>>): boolean => {
388
+ const was = all[model] ?? {};
389
+ const fresh = what.filter((w) => !was[w]);
390
+ if (!fresh.length) return false;
391
+ const keys = Object.keys(all);
392
+ if (!all[model] && keys.length >= MAX_MODELS) delete all[keys[0]];
393
+ all[model] = { ...was, ...Object.fromEntries(fresh.map((w) => [w, at])) };
394
+ return true;
395
+ };
396
+ if (used.source === 'environment') {
397
+ const all = this.#envObserved.get(p) ?? {};
398
+ if (!add(all)) return;
399
+ this.#envObserved.set(p, all);
400
+ } else {
401
+ const { raw, s } = this.#stored(p);
402
+ if (raw === null) return;
403
+ const all = { ...(s.observed ?? {}) };
404
+ if (!add(all)) return;
405
+ this.#db.prepare('update settings set value = ? where key = ? and value = ?').run(JSON.stringify({ ...s, observed: all }), rowKey(p), raw);
406
+ }
407
+ this.#changed();
408
+ }
409
+
410
+ view(): ApiKeysView {
411
+ return { providers: API_KEY_PROVIDERS.map((p) => this.state(p)) };
412
+ }
413
+
414
+ #changed(): void {
415
+ this.#hub.publish({ type: 'api_keys.updated', view: this.view() });
416
+ }
417
+
418
+ #checkGeneration(p: ApiKeyProvider, given: number, current: number): void {
419
+ if (given !== current) throw new HttpError(409, `The ${INFO[p].label} key changed since this page loaded it. Look again, then try again.`);
420
+ }
421
+
422
+ /** A running test is about the key being replaced: stop it (its answer would be discarded anyway). */
423
+ #stopTest(p: ApiKeyProvider): void {
424
+ this.#running.get(p)?.abort();
425
+ }
426
+
427
+ save(p: ApiKeyProvider, input: SaveApiKeyInput): { state: ApiKeyState; replaced: boolean } {
428
+ const key = keyOf(p, input);
429
+ const generation = generationOf(input);
430
+ const { s } = this.#stored(p);
431
+ this.#checkGeneration(p, generation, s.generation);
432
+ this.#stopTest(p);
433
+ this.#write(p, { key, generation: s.generation + 1, savedAt: nowIso(), test: null });
434
+ log.info('API key saved', { api: p });
435
+ this.#changed();
436
+ return { state: this.state(p), replaced: !!s.key };
437
+ }
438
+
439
+ /** Removes the saved key only; the environment's, if set, is used from then on (and the answer says so). */
440
+ remove(p: ApiKeyProvider, input: RemoveApiKeyInput): ApiKeyState {
441
+ const generation = generationOf(input);
442
+ const { s } = this.#stored(p);
443
+ if (!s.key) {
444
+ const env = this.#env[p] ? ` The key in use comes from ${INFO[p].envVar} in the server's environment: remove it there and restart the server.` : '';
445
+ throw new HttpError(404, `No ${INFO[p].label} key is saved.${env}`);
446
+ }
447
+ this.#checkGeneration(p, generation, s.generation);
448
+ this.#stopTest(p);
449
+ this.#write(p, { key: null, generation: s.generation + 1, savedAt: null, test: null });
450
+ log.info('API key removed', { api: p });
451
+ this.#changed();
452
+ return this.state(p);
453
+ }
454
+
455
+ /** Lists the models of the key in use now. One at a time per provider, API_KEY_TEST_LIMIT a minute. */
456
+ async test(p: ApiKeyProvider): Promise<ApiKeyTest> {
457
+ const using = this.#effective(p);
458
+ if (!using) throw new HttpError(400, `There is no ${INFO[p].label} key to test: save one, or set ${INFO[p].envVar} in the server's environment.`);
459
+ if (this.#running.has(p)) throw new HttpError(409, `A test of the ${INFO[p].label} key is already running`);
460
+ const now = Date.now();
461
+ const recent = (this.#recent.get(p) ?? []).filter((t) => now - t < TEST_WINDOW_MS);
462
+ if (recent.length >= API_KEY_TEST_LIMIT) {
463
+ const wait = Math.max(1, Math.ceil((TEST_WINDOW_MS - (now - recent[0])) / 1000));
464
+ throw new HttpError(429, `At most ${API_KEY_TEST_LIMIT} tests of a key a minute: try again in ${wait} s`, { 'retry-after': String(wait) });
465
+ }
466
+ recent.push(now);
467
+ this.#recent.set(p, recent);
468
+ const controller = new AbortController();
469
+ this.#running.set(p, controller);
470
+ this.#changed();
471
+ const started = Date.now();
472
+ let listing: Listing;
473
+ try {
474
+ listing = await this.#list(p, using.key, controller.signal);
475
+ } finally {
476
+ this.#running.delete(p);
477
+ }
478
+ const test: ApiKeyTest = {
479
+ provider: p,
480
+ source: using.source,
481
+ generation: using.generation,
482
+ ...listing,
483
+ checkedAt: nowIso(),
484
+ durationMs: Date.now() - started,
485
+ current: false,
486
+ };
487
+ test.current = this.#keep(p, using, test);
488
+ log.info('API key tested', { api: p, ok: test.outcome === 'ok', status: test.httpStatus ?? undefined, durationMs: test.durationMs, current: test.current });
489
+ this.#changed();
490
+ return test;
491
+ }
492
+
493
+ /** Saves a test only if the key in use is still the one it tested; says whether it did. */
494
+ #keep(p: ApiKeyProvider, tested: { key: string; source: ApiKeySource; generation: number }, test: ApiKeyTest): boolean {
495
+ const now = this.#effective(p);
496
+ if (!now || now.source !== tested.source || now.generation !== tested.generation || now.key !== tested.key) return false;
497
+ if (tested.source === 'environment') {
498
+ this.#envTests.set(p, test);
499
+ return true;
500
+ }
501
+ const { raw, s } = this.#stored(p);
502
+ if (raw === null) return false;
503
+ // Only over the very row this test checked as current, in one statement.
504
+ const r = this.#db.prepare('update settings set value = ? where key = ? and value = ?').run(JSON.stringify({ ...s, test }), rowKey(p), raw);
505
+ return Number(r.changes) === 1;
506
+ }
507
+
508
+ async #list(p: ApiKeyProvider, key: string, cancel: AbortSignal): Promise<Listing> {
509
+ const signal = AbortSignal.any([cancel, AbortSignal.timeout(this.#timeoutMs)]);
510
+ const headers: Record<string, string> =
511
+ p === 'anthropic' ? { 'x-api-key': key, 'anthropic-version': ANTHROPIC_VERSION, accept: 'application/json' } : { authorization: `Bearer ${key}`, accept: 'application/json' };
512
+ const models = new Map<string, ApiModel>();
513
+ let omitted = 0;
514
+ let after: string | null = null;
515
+ let httpStatus: number | null = null;
516
+ try {
517
+ for (let page = 0; page < (p === 'anthropic' ? ANTHROPIC_PAGES : 1); page++) {
518
+ const url = p === 'anthropic' ? `${this.#origins[p]}/v1/models?limit=1000${after ? `&after_id=${encodeURIComponent(after)}` : ''}` : `${this.#origins[p]}/v1/models`;
519
+ // Never follows a redirect: the key header would go with it.
520
+ const res = await fetch(url, { method: 'GET', headers, redirect: 'manual', signal });
521
+ httpStatus = res.status;
522
+ if (res.status !== 200) {
523
+ await res.body?.cancel().catch(() => {});
524
+ return { outcome: outcomeOf(res.status), httpStatus, models: [], omitted: 0, truncated: false };
525
+ }
526
+ const body = (await readJson(res)) as Record<string, unknown> | null;
527
+ if (!body || typeof body !== 'object' || !Array.isArray(body.data)) throw new Unexpected();
528
+ omitted += modelsFrom(p, body.data, key, models);
529
+ if (p === 'openai') break;
530
+ const last = body.last_id;
531
+ if (body.has_more !== true) {
532
+ after = null;
533
+ break;
534
+ }
535
+ if (typeof last !== 'string' || !MODEL_ID.test(last) || mayHold(last, key)) throw new Unexpected();
536
+ after = last;
537
+ }
538
+ return { outcome: 'ok', httpStatus, models: [...models.values()], omitted, truncated: after !== null };
539
+ } catch (err) {
540
+ if (err instanceof Unexpected) return { outcome: 'unexpected', httpStatus, models: [], omitted: 0, truncated: false };
541
+ return { outcome: 'unreachable', httpStatus: null, models: [], omitted: 0, truncated: false };
542
+ }
543
+ }
544
+ }