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,255 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { DatabaseSync } from 'node:sqlite';
3
+ import type { SpendingInput, SpendingView } from '../../shared/types.ts';
4
+ import { log } from './log.ts';
5
+ import { type ModelRate, mostOf, outputFor, round } from './rates.ts';
6
+ import { zonedToUtc } from './schedule.ts';
7
+ import { HttpError } from './store.ts';
8
+
9
+ /*
10
+ * Spending caps on API-key turns, kept on Botons' own estimate (rates.ts), never the provider's bill: a Boton may
11
+ * have a daily cap (from midnight in its time zone) and each of its routines a cap per run (all the run's attempts).
12
+ *
13
+ * The account is one row per request to a model, in api_requests, never per turn:
14
+ * - A request's period is the moment it was reserved, just before it was sent (the server's clock, as a UTC instant):
15
+ * it counts on the day that moment falls in, in the Boton's time zone as it is when the day is read. A turn that
16
+ * runs past midnight counts its later requests on the new day, and a hold made before midnight stays on its own day.
17
+ * A day counts every request from its midnight on, with no end: after the clock is set back, requests stamped later
18
+ * still count on today, never dropped.
19
+ * - Before each request, in one transaction: the caps that apply are read as they are now (a cap added, lowered or
20
+ * removed since the turn began counts from its next request), and what is left under them; the most the request
21
+ * can cost (every input token at the dearest input rate, its output cap at the output rate) is written as its row.
22
+ * If that doesn't fit, its output cap is lowered to fit; if not even a small answer fits, it isn't sent. A request
23
+ * already sent stays counted however the caps change after.
24
+ * - When its answer comes, the row becomes what its reported tokens cost. A request whose tokens are unknown (the
25
+ * answer broke off, was stopped, or reported no valid usage) stays held at its most: the provider may have billed
26
+ * it. One refused before anything was generated is released. A row still reserved when the server starts was the
27
+ * process's that is gone: it is held, never released.
28
+ * - A model with no known rate can't be kept to a cap: with a cap set, its requests are refused, and so are a capped
29
+ * Boton's requests on a day (or in a run) that already has one with no known price.
30
+ * The provider's bill can still differ (its rounding, rates changed since rates.ts was checked, tokens Botons can't see);
31
+ * a cap bounds Botons' estimate, not the bill.
32
+ */
33
+
34
+ export const API_BRAINS = ['anthropic-api', 'openai-api'] as const;
35
+ export const MAX_CAP_USD = 100_000;
36
+ /** Settled rows older than this go (no cap looks back further than a run). */
37
+ const KEEP_DAYS = 400;
38
+
39
+ export const SPEND_RULES =
40
+ 'Caps count Botons’ own estimate of API-key requests (the tokens each answer reports times the published rates), not your provider’s bill. Each request counts on the day it was sent. Before each one the most it could cost is set aside, under the caps as they are then; it is sent only if that fits in what is left. A request whose answer was cut off or stopped stays counted at that most. A change to a cap applies from the next request; one already sent stays counted. Requests on a model with no known price don’t run while a cap is set.';
41
+
42
+ const capKey = (kind: 'bot' | 'routine', id: string) => `spendCap:${kind}:${id}`;
43
+
44
+ /** What a row counts: its cost once settled, its most while reserved or held, nothing once released. */
45
+ const COUNTED = `case state when 'settled' then coalesce(cost_usd, 0) when 'released' then 0 else coalesce(reserved_usd, 0) end`;
46
+ /** A row with no known price that may have been billed. */
47
+ const UNPRICED = `case when state != 'released' and reserved_usd is null and cost_usd is null then 1 else 0 end`;
48
+
49
+ export class CapError extends Error {}
50
+
51
+ export interface SpendScope {
52
+ botId: string;
53
+ turnId: string;
54
+ runId: string | null;
55
+ routineId: string | null;
56
+ }
57
+
58
+ export interface ReserveAsk {
59
+ provider: string;
60
+ model: string;
61
+ /** null: no known price (then nothing can be set aside, and no cap may apply). */
62
+ rate: ModelRate | null;
63
+ /** At most this many input tokens. */
64
+ inputTokens: number;
65
+ maxOutput: number;
66
+ /** Below this output cap a request isn't worth sending. */
67
+ minOutput: number;
68
+ }
69
+
70
+ /** One request's row. Each of settle, hold and release acts once, and only on a row still reserved. */
71
+ export interface Reservation {
72
+ /** The output cap it was reserved at (lowered to fit a cap). */
73
+ maxOutput: number;
74
+ /** What was set aside; null with no known price. */
75
+ usd: number | null;
76
+ settle(usd: number | null): void;
77
+ hold(): void;
78
+ release(): void;
79
+ }
80
+
81
+ export class Spending {
82
+ #db: DatabaseSync;
83
+ #now: () => Date;
84
+
85
+ constructor(db: DatabaseSync, opts: { now?: () => Date } = {}) {
86
+ this.#db = db;
87
+ this.#now = opts.now ?? (() => new Date());
88
+ // Reserved by a process that is gone: it may have been sent and billed.
89
+ const n = Number(this.#db.prepare("update api_requests set state = 'held' where state = 'reserved'").run().changes);
90
+ if (n) log.warn('API requests a stopped server left reserved are held at their most', { requests: n });
91
+ }
92
+
93
+ #cap(kind: 'bot' | 'routine', id: string | null): number | null {
94
+ if (!id) return null;
95
+ const row = this.#db.prepare('select value from settings where key = ?').get(capKey(kind, id)) as { value: string } | undefined;
96
+ const v = row ? Number(row.value) : Number.NaN;
97
+ return Number.isFinite(v) && v >= 0 ? v : null;
98
+ }
99
+
100
+ #setCap(kind: 'bot' | 'routine', id: string, usd: number | null) {
101
+ if (usd === null) this.#db.prepare('delete from settings where key = ?').run(capKey(kind, id));
102
+ else this.#db.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run(capKey(kind, id), String(usd));
103
+ }
104
+
105
+ /** Midnight of `now`'s day in `tz`, as an ISO time (rows' `at` compare as text). */
106
+ dayStart(tz: string, now = this.#now()): string {
107
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(now);
108
+ const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
109
+ return zonedToUtc(get('year'), get('month'), get('day'), 0, 0, tz).toISOString();
110
+ }
111
+
112
+ #today(botId: string): { spent: number; unpriced: number; since: string } {
113
+ const since = this.dayStart(this.#timezone(botId));
114
+ const r = this.#db.prepare(`select coalesce(sum(${COUNTED}), 0) as spent, coalesce(sum(${UNPRICED}), 0) as unpriced from api_requests where bot_id = ? and at >= ?`).get(botId, since) as {
115
+ spent: number;
116
+ unpriced: number;
117
+ };
118
+ return { spent: round(Number(r.spent)), unpriced: Number(r.unpriced), since };
119
+ }
120
+
121
+ #run(runId: string): { spent: number; unpriced: number } {
122
+ const r = this.#db.prepare(`select coalesce(sum(${COUNTED}), 0) as spent, coalesce(sum(${UNPRICED}), 0) as unpriced from api_requests where run_id = ?`).get(runId) as { spent: number; unpriced: number };
123
+ return { spent: round(Number(r.spent)), unpriced: Number(r.unpriced) };
124
+ }
125
+
126
+ #timezone(botId: string): string {
127
+ const r = this.#db.prepare('select timezone from bots where id = ?').get(botId) as { timezone: string } | undefined;
128
+ return r?.timezone || 'UTC';
129
+ }
130
+
131
+ view(botId: string): SpendingView {
132
+ const today = this.#today(botId);
133
+ const routines = this.#db.prepare('select id, name from routines where bot_id = ? order by created_at').all(botId) as { id: string; name: string }[];
134
+ return {
135
+ botId,
136
+ dailyCapUsd: this.#cap('bot', botId),
137
+ spentTodayUsd: today.spent,
138
+ unpricedToday: today.unpriced,
139
+ dayStartedAt: today.since,
140
+ routines: routines.map((x) => ({ id: String(x.id), name: String(x.name), perRunCapUsd: this.#cap('routine', String(x.id)) })),
141
+ rules: SPEND_RULES,
142
+ };
143
+ }
144
+
145
+ update(botId: string, input: SpendingInput): SpendingView {
146
+ const usd = (v: unknown, field: string): number | null => {
147
+ if (v === null) return null;
148
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > MAX_CAP_USD) throw new HttpError(400, `${field} must be a number of US dollars from 0 to ${MAX_CAP_USD}, or null for no cap`);
149
+ return round(v);
150
+ };
151
+ const changes: [kind: 'bot' | 'routine', id: string, usd: number | null][] = [];
152
+ if (input?.dailyCapUsd !== undefined) changes.push(['bot', botId, usd(input.dailyCapUsd, 'dailyCapUsd')]);
153
+ if (input?.routines !== undefined) {
154
+ if (!input.routines || typeof input.routines !== 'object' || Array.isArray(input.routines)) throw new HttpError(400, 'routines must map routine ids to a cap or null');
155
+ for (const [id, v] of Object.entries(input.routines)) {
156
+ const own = this.#db.prepare('select 1 from routines where id = ? and bot_id = ?').get(id, botId);
157
+ if (!own) throw new HttpError(404, `No routine ${id} on this Boton`);
158
+ changes.push(['routine', id, usd(v, `routines.${id}`)]);
159
+ }
160
+ }
161
+ for (const [kind, id, v] of changes) this.#setCap(kind, id, v);
162
+ log.info('spending caps changed', { botId, caps: changes.map(([kind, id, v]) => ({ kind, id, usd: v })) });
163
+ return this.view(botId);
164
+ }
165
+
166
+ /**
167
+ * The caps a configuration import gives a Boton it has just made, inside the import's transaction. Only ever on a
168
+ * Boton with no cap and no account here yet: an import never raises, clears or restarts caps or spending a Boton
169
+ * already has (a Boton made anew starts at $0, as one the owner adds by hand does). Anything else throws, and the
170
+ * import rolls back.
171
+ */
172
+ importCaps(botId: string, daily: number | null, routines: [routineId: string, usd: number][]): void {
173
+ const db = this.#db;
174
+ if (!db.isTransaction) throw new Error('importCaps runs inside the import transaction');
175
+ const ok = (v: number) => Number.isFinite(v) && v >= 0 && v <= MAX_CAP_USD;
176
+ if ((daily !== null && !ok(daily)) || routines.some(([, v]) => !ok(v))) throw new Error('an imported spending cap is out of range');
177
+ const routineIds = this.#db.prepare('select id from routines where bot_id = ?').all(botId).map((r) => String((r as { id: string }).id));
178
+ const keys = [capKey('bot', botId), ...routineIds.map((id) => capKey('routine', id))];
179
+ const had = keys.some((k) => db.prepare('select 1 from settings where key = ?').get(k));
180
+ const counted = db.prepare('select 1 from api_requests where bot_id = ? limit 1').get(botId);
181
+ if (had || counted) throw new Error('an import only gives caps to a Boton it has just made');
182
+ for (const [id] of routines) if (!routineIds.includes(id)) throw new Error('an imported routine cap names a routine of another Boton');
183
+ if (daily !== null) this.#setCap('bot', botId, round(daily));
184
+ for (const [id, v] of routines) this.#setCap('routine', id, round(v));
185
+ }
186
+
187
+ /** Caps of Botons and routines that no longer exist go, and settled rows past any cap's reach. */
188
+ prune(): void {
189
+ this.#db.exec(
190
+ `delete from settings where (key like 'spendCap:bot:%' and substr(key, 14) not in (select id from bots))
191
+ or (key like 'spendCap:routine:%' and substr(key, 18) not in (select id from routines))`,
192
+ );
193
+ const before = new Date(this.#now().getTime() - KEEP_DAYS * 86_400_000).toISOString();
194
+ this.#db.prepare("delete from api_requests where at < ? and state in ('settled', 'released')").run(before);
195
+ }
196
+
197
+ /**
198
+ * Sets aside the most the next request can cost under the caps as they are now, as its own row, or throws CapError
199
+ * (nothing written). One transaction: no other request is counted between the read and the write.
200
+ */
201
+ reserve(scope: SpendScope, ask: ReserveAsk): Reservation {
202
+ const db = this.#db;
203
+ db.exec('begin immediate');
204
+ let row: { id: string; maxOutput: number; usd: number | null };
205
+ try {
206
+ row = this.#reserve(scope, ask);
207
+ db.exec('commit');
208
+ } catch (err) {
209
+ if (db.isTransaction) db.exec('rollback');
210
+ throw err;
211
+ }
212
+ const end = (state: 'settled' | 'held' | 'released', cost: number | null) =>
213
+ db.prepare("update api_requests set state = ?, cost_usd = ?, ended_at = ? where id = ? and state = 'reserved'").run(state, cost === null ? null : round(cost), this.#now().toISOString(), row.id);
214
+ return {
215
+ maxOutput: row.maxOutput,
216
+ usd: row.usd,
217
+ // Tokens unknown: held at its most (or, with no known price, an unpriced request that may have been billed).
218
+ settle: (usd) => (usd === null ? end('held', null) : end('settled', usd)),
219
+ hold: () => end('held', null),
220
+ release: () => end('released', null),
221
+ };
222
+ }
223
+
224
+ #reserve(scope: SpendScope, ask: ReserveAsk): { id: string; maxOutput: number; usd: number | null } {
225
+ const daily = this.#cap('bot', scope.botId);
226
+ const perRun = scope.runId ? this.#cap('routine', scope.routineId) : null;
227
+ const capped = daily !== null || perRun !== null;
228
+ if (capped && !ask.rate) throw new CapError('its model has no known price in Botons, so its spending cap can’t be kept. Pick a model with a known price, or remove the cap.');
229
+ let left = Number.POSITIVE_INFINITY;
230
+ let which = '';
231
+ if (daily !== null) {
232
+ const today = this.#today(scope.botId);
233
+ if (today.unpriced > 0) throw new CapError(`today already has ${today.unpriced} API-key request${today.unpriced > 1 ? 's' : ''} with no known price, so the daily cap can’t be kept until tomorrow.`);
234
+ if (daily - today.spent < left) [left, which] = [daily - today.spent, `the Boton’s daily cap of $${daily}`];
235
+ }
236
+ if (perRun !== null && scope.runId) {
237
+ const run = this.#run(scope.runId);
238
+ if (run.unpriced > 0) throw new CapError('this run already has a request with no known price, so the routine’s cap can’t be kept.');
239
+ if (perRun - run.spent < left) [left, which] = [perRun - run.spent, `the routine’s cap of $${perRun} a run`];
240
+ }
241
+ let maxOutput = ask.maxOutput;
242
+ if (ask.rate && capped) {
243
+ const fits = outputFor(ask.rate, ask.inputTokens, round(left));
244
+ if (fits < ask.minOutput) throw new CapError(`the next request could cost more than the $${Math.max(0, left).toFixed(4)} left under ${which}.`);
245
+ maxOutput = Math.min(maxOutput, fits);
246
+ }
247
+ const usd = ask.rate ? mostOf(ask.rate, ask.inputTokens, maxOutput) : null;
248
+ if (usd !== null && usd > left + 1e-9) throw new CapError(`the next request could cost up to $${usd.toFixed(4)}, more than the $${Math.max(0, left).toFixed(4)} left under ${which}.`);
249
+ const id = `req_${randomUUID()}`;
250
+ this.#db
251
+ .prepare("insert into api_requests (id, bot_id, turn_id, run_id, routine_id, provider, model, at, reserved_usd, cost_usd, state) values (?, ?, ?, ?, ?, ?, ?, ?, ?, null, 'reserved')")
252
+ .run(id, scope.botId, scope.turnId, scope.runId, scope.routineId, ask.provider, ask.model, this.#now().toISOString(), usd);
253
+ return { id, maxOutput, usd };
254
+ }
255
+ }
@@ -0,0 +1,126 @@
1
+ import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
2
+ import { BlockList, isIP, type LookupFunction } from 'node:net';
3
+ import { networkInterfaces } from 'node:os';
4
+
5
+ const DENY = new BlockList();
6
+ for (const [net, bits] of [
7
+ ['0.0.0.0', 8], // "this network"
8
+ ['10.0.0.0', 8],
9
+ ['100.64.0.0', 10], // carrier-grade NAT, and Tailscale's addresses
10
+ ['127.0.0.0', 8],
11
+ ['169.254.0.0', 16], // link-local, and cloud metadata services
12
+ ['172.16.0.0', 12],
13
+ ['192.0.0.0', 24],
14
+ ['192.0.2.0', 24],
15
+ ['192.168.0.0', 16],
16
+ ['198.18.0.0', 15],
17
+ ['198.51.100.0', 24],
18
+ ['203.0.113.0', 24],
19
+ ['224.0.0.0', 4], // multicast
20
+ ['240.0.0.0', 4], // reserved, and broadcast
21
+ ] as const) {
22
+ DENY.addSubnet(net, bits, 'ipv4');
23
+ }
24
+ for (const [net, bits] of [
25
+ ['::', 128],
26
+ ['::1', 128],
27
+ ['64:ff9b::', 96], // NAT64: could lead to any IPv4 address
28
+ ['100::', 64],
29
+ ['2001:db8::', 32],
30
+ ['fc00::', 7], // unique local, and Tailscale's IPv6 addresses
31
+ ['fe80::', 10],
32
+ ['ff00::', 8],
33
+ ] as const) {
34
+ DENY.addSubnet(net, bits, 'ipv6');
35
+ }
36
+
37
+ /** BOTONS_CALLBACK_ALLOW, parsed. */
38
+ interface Allowance {
39
+ addresses: BlockList;
40
+ names: Set<string>;
41
+ }
42
+
43
+ const family = (ip: string) => (isIP(ip) === 6 ? 'ipv6' : 'ipv4');
44
+
45
+ export function allowance(entries: string[]): Allowance {
46
+ const addresses = new BlockList();
47
+ const names = new Set<string>();
48
+ for (const entry of entries) {
49
+ const [ip, bits] = entry.split('/');
50
+ if (!isIP(ip)) names.add(entry.toLowerCase());
51
+ else if (bits === undefined) addresses.addAddress(ip, family(ip));
52
+ else addresses.addSubnet(ip, Number(bits), family(ip));
53
+ }
54
+ return { addresses, names };
55
+ }
56
+
57
+ /** Why the server must not connect to `ip`, or null if it may. */
58
+ export function refusedAddress(ip: string, allow: Allowance): string | null {
59
+ const plain = ip.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '');
60
+ if (!isIP(plain)) return 'not an IP address';
61
+ if (allow.addresses.check(plain, family(plain))) return null;
62
+ // An IPv4 address written as IPv6 (::ffff:127.0.0.1) is judged as the IPv4 one.
63
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(plain)?.[1];
64
+ if (mapped) return refusedAddress(mapped, allow);
65
+ if (DENY.check(plain, family(plain))) return 'a loopback, private, link-local or reserved address';
66
+ const own = Object.values(networkInterfaces()).flat().some((i) => i?.address.toLowerCase().replace(/%.*$/, '') === plain);
67
+ if (own) return "this machine's own address";
68
+ return null;
69
+ }
70
+
71
+ const ALLOW_HINT = 'BOTONS_CALLBACK_ALLOW can allow it';
72
+
73
+ const LOCAL_NAME = /(^|\.)localhost\.?$/i;
74
+
75
+ /** Why a callback URL is refused as written, or null. Names are checked on what they resolve to when connecting. */
76
+ export function refusedUrl(raw: string, allow: Allowance): string | null {
77
+ let u: URL;
78
+ try {
79
+ u = new URL(raw);
80
+ } catch {
81
+ return 'callbackUrl must be an http:// or https:// URL';
82
+ }
83
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return 'callbackUrl must be an http:// or https:// URL';
84
+ if (u.username || u.password) return 'callbackUrl must not contain a user name or password';
85
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
86
+ if (allow.names.has(host)) return null;
87
+ if (isIP(host)) {
88
+ const why = refusedAddress(host, allow);
89
+ return why ? `callbackUrl can't point at ${host}: ${why} (${ALLOW_HINT})` : null;
90
+ }
91
+ if (LOCAL_NAME.test(host)) return `callbackUrl can't point at ${host}: this machine (${ALLOW_HINT})`;
92
+ return null;
93
+ }
94
+
95
+ /** A `lookup` for http.request that refuses a name if any of its addresses is refused, so a later DNS answer can't slip past an earlier check. */
96
+ export function guardedLookup(allow: Allowance): LookupFunction {
97
+ return (hostname, options, callback) => {
98
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses: LookupAddress[]) => {
99
+ if (err) return callback(err, '', 0);
100
+ const allowedName = allow.names.has(hostname.toLowerCase());
101
+ for (const a of allowedName ? [] : addresses) {
102
+ const why = refusedAddress(a.address, allow);
103
+ if (why) return callback(Object.assign(new Error(`${hostname} resolves to ${a.address}, ${why} (${ALLOW_HINT})`), { code: 'EREFUSEDADDRESS' }), '', 0);
104
+ }
105
+ if (options.all) return (callback as unknown as (err: null, addresses: LookupAddress[]) => void)(null, addresses);
106
+ callback(null, addresses[0].address, addresses[0].family);
107
+ });
108
+ };
109
+ }
110
+
111
+ /** Check a callback URL before saving it. A name that doesn't resolve now passes: every send checks it again. */
112
+ export async function checkCallbackUrl(raw: string, allowEntries: string[]): Promise<string | null> {
113
+ const allow = allowance(allowEntries);
114
+ const refused = refusedUrl(raw, allow);
115
+ if (refused) return refused;
116
+ const host = new URL(raw).hostname.replace(/^\[|\]$/g, '');
117
+ if (isIP(host) || allow.names.has(host.toLowerCase())) return null;
118
+ const lookup = guardedLookup(allow);
119
+ return new Promise((resolve) => {
120
+ const timer = setTimeout(() => resolve(null), 3000);
121
+ lookup(host, { all: true }, (err) => {
122
+ clearTimeout(timer);
123
+ resolve((err as NodeJS.ErrnoException | null)?.code === 'EREFUSEDADDRESS' ? `callbackUrl can't point at ${host}: ${err!.message}` : null);
124
+ });
125
+ });
126
+ }
@@ -0,0 +1,168 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import type { Routine, RoutineRun, StarterRoutineGate } from '../../shared/types.ts';
3
+ import type { Connectors } from './connectors.ts';
4
+ import type { Skills } from './skills.ts';
5
+ import { type BotRecord, HttpError, type StarterRecord, type Store } from './store.ts';
6
+ import { nowIso } from './util.ts';
7
+
8
+ /*
9
+ * When a starter's routine may be turned on. Installing a starter saves a record (Store.starterRecord) listing its
10
+ * routines as pending; while one is, Store.updateRoutine refuses to turn it on for anyone, so a PATCH, the `botons`
11
+ * CLI, the Routines panel or a copy of the Boton can't. Only activate() turns it on: for the owner (its route is
12
+ * theirs alone), when the latest test run finished without an error on exactly the settings it has now, or when the
13
+ * owner overrides that. Either way it is then an ordinary routine.
14
+ *
15
+ * "Exactly the settings": a test run's turn notes what it ran on (noteTest) as keyed hashes of each part below, taken
16
+ * from the very records the turn used. Turning on compares them with the same parts computed now, in one synchronous
17
+ * step with the enabling write, so nothing can change between the comparison and the grant. A finished test run is no
18
+ * proof its output was right, only that the brain's process ended without an error on these settings: the owner reads
19
+ * its reply. A test run is a real turn told to act as a dry run; nothing here enforces that.
20
+ */
21
+
22
+ /** What a test run ran on, part by part, for saying which changed since. */
23
+ export const PARTS = {
24
+ boton: "the Boton's settings (brain, model, effort, access, instructions, name, timezone, or what it may do without asking)",
25
+ routine: 'the routine (its instruction, schedule, webhook, skill, model, effort, session or time limit)',
26
+ connectors: 'its connectors (which ones it has, or their setup, sign-in or on and off)',
27
+ skills: 'its skills',
28
+ browser: 'its browser or the sites it may read',
29
+ } as const;
30
+ export type Part = keyof typeof PARTS;
31
+ export type Parts = Record<Part, string>;
32
+
33
+ const KEEP_TESTS = 5;
34
+
35
+ export interface GateDeps {
36
+ store: Store;
37
+ connectors: Pick<Connectors, 'revision'>;
38
+ skills: Pick<Skills, 'list'>;
39
+ browser: { enabled(botId: string): boolean; sites(botId: string): string[] } | null;
40
+ }
41
+
42
+ export class StarterGate {
43
+ #d: GateDeps;
44
+ constructor(deps: GateDeps) {
45
+ this.#d = deps;
46
+ }
47
+
48
+ /** A keyed hash: opaque to anyone without the server's key, so it may reach a client. */
49
+ mac(v: unknown): string {
50
+ return createHmac('sha256', this.#d.store.starterSecret()).update(JSON.stringify(v)).digest('hex').slice(0, 32);
51
+ }
52
+
53
+ /** What connector `id` is now (Connectors.revision), as a keyed hash; one that is gone has its own. */
54
+ connectorRevision(id: string): string {
55
+ return this.mac({ id, revision: this.#d.connectors.revision(id) });
56
+ }
57
+
58
+ /** What a turn of `routine` on `bot` runs on, from these very records (not read again). */
59
+ parts(bot: BotRecord, routine: Routine): Parts {
60
+ const { store, skills, browser } = this.#d;
61
+ const boton = {
62
+ id: bot.id,
63
+ name: bot.name,
64
+ title: bot.title,
65
+ instructions: bot.instructions,
66
+ provider: bot.provider,
67
+ model: bot.model,
68
+ effort: bot.effort,
69
+ access: bot.access,
70
+ timezone: bot.timezone,
71
+ skills: bot.skills,
72
+ alwaysAllow: bot.alwaysAllow,
73
+ askFirst: bot.askFirst,
74
+ owner: store.getSettings().ownerName,
75
+ };
76
+ const hook = routine.webhook;
77
+ const r = {
78
+ id: routine.id,
79
+ name: routine.name,
80
+ instruction: routine.instruction,
81
+ schedule: routine.schedule,
82
+ timezone: routine.timezone,
83
+ webhook: hook ? { source: hook.source, filter: hook.filter, secret: hook.hasSecret, callback: hook.callbackUrl } : null,
84
+ webhookWorkspace: routine.webhookWorkspace,
85
+ notify: routine.notify,
86
+ skill: routine.skill,
87
+ model: routine.model,
88
+ effort: routine.effort,
89
+ session: routine.session,
90
+ timeLimitMs: routine.timeLimitMs,
91
+ };
92
+ const wanted = new Set([...(bot.skills ?? skills.list().map((s) => s.name)), ...(routine.skill ? [routine.skill] : [])]);
93
+ const skillParts = skills
94
+ .list()
95
+ .filter((s) => wanted.has(s.name))
96
+ .map((s) => [s.name, s.updatedAt, s.version, s.disabled, s.pending, s.updatedBy]);
97
+ return {
98
+ boton: this.mac({ boton }),
99
+ routine: this.mac({ r }),
100
+ connectors: this.mac({ connectors: bot.connections.map((id) => [id, this.connectorRevision(id)]) }),
101
+ skills: this.mac({ skills: skillParts, missing: [...wanted].filter((n) => !skillParts.some(([x]) => x === n)).sort() }),
102
+ browser: this.mac({ browser: browser ? { on: browser.enabled(bot.id), sites: browser.sites(bot.id) } : null }),
103
+ };
104
+ }
105
+
106
+ /** The parts routine `id` would run on now. */
107
+ current(id: string): Parts {
108
+ const routine = this.#d.store.getRoutine(id);
109
+ return this.parts(this.#d.store.getBot(routine.botId), routine);
110
+ }
111
+
112
+ /**
113
+ * The engine, as a test run's turn starts: what it runs on. `before` was taken before the turn waited on its
114
+ * connectors and `after` when it stopped waiting; if they differ, something changed while it was set up, and the run
115
+ * can't count for turning the routine on.
116
+ */
117
+ noteTest(run: Pick<RoutineRun, 'id' | 'routineId' | 'botId' | 'trigger'>, before: Parts, after: Parts): void {
118
+ if (run.trigger !== 'test') return;
119
+ const { store } = this.#d;
120
+ const rec = store.starterRecord(run.botId);
121
+ if (!rec?.pending.includes(run.routineId)) return;
122
+ const same = (Object.keys(PARTS) as Part[]).every((k) => before[k] === after[k]);
123
+ const tests = [{ runId: run.id, routineId: run.routineId, at: nowIso(), parts: same ? after : null }, ...rec.tests.filter((t) => t.runId !== run.id)];
124
+ const kept = tests.filter((t, i) => tests.slice(0, i).filter((x) => x.routineId === t.routineId).length < KEEP_TESTS);
125
+ store.saveStarterRecord(run.botId, { ...rec, tests: kept });
126
+ }
127
+
128
+ /** Whether routine `id` of a starter can be turned on without an override now, and why. */
129
+ verdict(id: string, rec: StarterRecord | null = null): StarterRoutineGate {
130
+ const { store } = this.#d;
131
+ const routine = store.getRoutine(id);
132
+ const record = rec ?? store.starterRecord(routine.botId);
133
+ const pending = !!record?.pending.includes(id);
134
+ const out = (tested: boolean, testing: boolean, runId: string | null, text: string): StarterRoutineGate => ({ routineId: id, pending, tested, testing, runId, text });
135
+ if (!pending) return out(false, false, null, routine.enabled ? 'Turned on by you: an ordinary routine now.' : 'Turned on by you before: an ordinary routine now.');
136
+ const test = store.listRuns(id).find((x) => x.trigger === 'test');
137
+ if (!test) return out(false, false, null, 'Not tested yet: run a test, read its reply, then turn it on.');
138
+ if (test.status === 'queued' || test.status === 'running') return out(false, true, test.id, `A test run is ${test.status}: wait for it to end.`);
139
+ const when = (test.finishedAt ?? test.startedAt).replace('T', ' ').slice(0, 16);
140
+ if (test.status !== 'succeeded') return out(false, false, test.id, `The latest test run ${test.status === 'failed' ? 'failed' : 'was skipped'} at ${when} UTC: fix what it says, then test again.`);
141
+ const noted = record!.tests.find((t) => t.runId === test.id);
142
+ if (!noted) return out(false, false, test.id, `The latest test run (${when} UTC) ran before this server noted what it ran on: test again.`);
143
+ if (!noted.parts) return out(false, false, test.id, `Its settings changed while the latest test run (${when} UTC) was being set up: test again.`);
144
+ const now = this.current(id);
145
+ const changed = (Object.keys(PARTS) as Part[]).filter((k) => noted.parts![k] !== now[k]);
146
+ if (changed.length) return out(false, false, test.id, `Changed since the latest test run (${when} UTC): ${changed.map((k) => PARTS[k]).join('; ')}. Test again.`);
147
+ return out(true, false, test.id, `The latest test run (${when} UTC) finished without an error on its current settings. That doesn't show its output was right: read its reply.`);
148
+ }
149
+
150
+ /**
151
+ * The owner turns a starter's routine on (the route is theirs alone). Refused while a test run is under way, and
152
+ * without `override` unless verdict() says tested; all in one synchronous step with the write.
153
+ */
154
+ activate(id: string, override: boolean): { routine: Routine; gate: StarterRoutineGate } {
155
+ const { store } = this.#d;
156
+ return store.atomically(() => {
157
+ const routine = store.getRoutine(id);
158
+ const rec = store.starterRecord(routine.botId);
159
+ if (!rec?.pending.includes(id)) throw new HttpError(400, `“${routine.name}” is not a starter's routine waiting to be turned on: turn it on as any other routine`);
160
+ const gate = this.verdict(id, rec);
161
+ if (gate.testing) throw new HttpError(409, gate.text);
162
+ if (!gate.tested && !override) throw new HttpError(409, `${gate.text} Or turn it on without a passing test run (override).`);
163
+ const on = store.updateRoutine(id, { enabled: true }, { starterActivation: true });
164
+ store.saveStarterRecord(routine.botId, { ...rec, pending: rec.pending.filter((x) => x !== id), tests: rec.tests.filter((t) => t.routineId !== id) });
165
+ return { routine: on, gate };
166
+ });
167
+ }
168
+ }