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,427 @@
1
+ /*
2
+ * Transcript cards: what a Boton may show its owner in the chat, as data. A card is a fixed list of known blocks
3
+ * (text, status, progress, a table, a workspace file, a link) and at most one thing to answer (a choice, a form, or
4
+ * a connector to give it). There is no markup, script, style or callback in it: the web app draws each block itself,
5
+ * every string as plain text, and what an answer does is decided by the server from the card it saved, never from
6
+ * what the client or the Boton sends with it.
7
+ *
8
+ * This file is the protocol and its checks, shared by the server (which refuses anything else) and the web app.
9
+ * It imports nothing.
10
+ */
11
+
12
+ export const CARD_VERSION = 1;
13
+
14
+ export const CARD_LIMITS = {
15
+ /** The card as saved, in characters of JSON. */
16
+ bytes: 16_000,
17
+ title: 120,
18
+ blocks: 20,
19
+ text: 2000,
20
+ status: 300,
21
+ label: 120,
22
+ columns: 8,
23
+ rows: 50,
24
+ cell: 200,
25
+ options: 8,
26
+ optionLabel: 80,
27
+ fields: 8,
28
+ fieldLabel: 80,
29
+ selectOptions: 20,
30
+ placeholder: 80,
31
+ valueMax: 2000,
32
+ url: 2000,
33
+ path: 500,
34
+ submit: 40,
35
+ } as const;
36
+
37
+ export type CardTone = 'info' | 'success' | 'warning' | 'error' | 'working';
38
+ export type CardCell = string | number | null;
39
+ export interface CardOption {
40
+ id: string;
41
+ label: string;
42
+ }
43
+
44
+ export type CardFieldKind = 'text' | 'textarea' | 'number' | 'select' | 'checkbox';
45
+ export interface CardField {
46
+ id: string;
47
+ label: string;
48
+ kind: CardFieldKind;
49
+ required?: boolean;
50
+ /** text and textarea: at most this many characters (default and max CARD_LIMITS.valueMax). */
51
+ maxLength?: number;
52
+ /** number only. */
53
+ min?: number;
54
+ max?: number;
55
+ /** select only. */
56
+ options?: CardOption[];
57
+ placeholder?: string;
58
+ }
59
+
60
+ export type CardBlock =
61
+ | { type: 'text'; text: string }
62
+ | { type: 'status'; tone: CardTone; text: string }
63
+ | { type: 'progress'; label: string; value: number; max: number }
64
+ | { type: 'table'; columns: string[]; rows: CardCell[][] }
65
+ /** A file in the Boton's workspace, opened in the app's own file viewer. `path` is checked by the server when posted. */
66
+ | { type: 'file'; path: string; title?: string }
67
+ /** An https link, shown with its host; never a script, data or local address. */
68
+ | { type: 'link'; url: string; label: string }
69
+ | { type: 'choice'; id: string; prompt: string; options: CardOption[] }
70
+ | { type: 'form'; id: string; prompt?: string; fields: CardField[]; submit?: string }
71
+ /** Asks the owner to give the Boton this connector: what that does, and the button, are the server's. */
72
+ | { type: 'connector'; connectorId: string };
73
+
74
+ export type InteractiveBlock = Extract<CardBlock, { type: 'choice' | 'form' | 'connector' }>;
75
+
76
+ /** What a Boton posts (`botons card post`). */
77
+ export interface CardDescriptor {
78
+ v: typeof CARD_VERSION;
79
+ title: string;
80
+ blocks: CardBlock[];
81
+ }
82
+
83
+ /** shown: nothing to answer. refused: the server could not do what the answer asked (see `reason`), so nothing was done. */
84
+ export type CardStatus = 'shown' | 'pending' | 'answered' | 'declined' | 'expired' | 'refused';
85
+
86
+ /** What the owner sends to answer a card. */
87
+ export type CardAnswerInput =
88
+ | { action: 'choose'; option: string }
89
+ | { action: 'submit'; values: Record<string, string | number | boolean> }
90
+ | { action: 'grant' }
91
+ | { action: 'decline' };
92
+
93
+ /** How a card was answered, as the server recorded it. */
94
+ export interface CardAnswer {
95
+ action: CardAnswerInput['action'];
96
+ at: string;
97
+ /** The device that answered. */
98
+ by: string | null;
99
+ /** choose: the option chosen. */
100
+ option?: CardOption;
101
+ /** submit: the values, by field id, as checked. */
102
+ values?: Record<string, string | number | boolean>;
103
+ /** grant: the connector was added, or the Boton had it already. */
104
+ result?: 'granted' | 'already';
105
+ }
106
+
107
+ /** A posted card, as the server keeps it (Message.meta.card of a card.posted event). */
108
+ export interface TranscriptCard {
109
+ descriptor: CardDescriptor;
110
+ status: CardStatus;
111
+ expiresAt: string;
112
+ /** Where it was posted from, as the server knew it (never what the Boton said). */
113
+ from: { trigger: string; routineId: string | null; routineName: string | null; runId: string | null; sandboxed: boolean };
114
+ /** A connector block: its name and the request policy generation the card was posted under. */
115
+ connector?: { id: string; name: string; generation: number };
116
+ answer?: CardAnswer;
117
+ /** refused: why the server refused the answer and closed the card. */
118
+ reason?: string;
119
+ }
120
+
121
+ export class CardError extends Error {}
122
+
123
+ const ID = /^[a-z][a-z0-9_-]{0,31}$/;
124
+ export const CONNECTOR_ID = /^[a-z]+:[a-z0-9][a-z0-9_-]{0,63}$/;
125
+ const BANNED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
126
+ /**
127
+ * Field wording that asks for a secret. Cards never take passwords, keys, tokens or codes: an answer goes into the
128
+ * transcript and to the Boton. Signing in to a service goes through Settings, and typing into a site through the
129
+ * owner's own browser handover.
130
+ */
131
+ const SECRET_WORDS =
132
+ /pass\s*(word|phrase|code)|\bpwd\b|secret|token|api[\s_-]*key|access[\s_-]*key|private[\s_-]*key|credential|\bpin\b|\botp\b|\b2fa\b|\bmfa\b|one[\s_-]*time|verification[\s_-]*code|security[\s_-]*code|recovery[\s_-]*code|\bssn\b|social[\s_-]*security|card[\s_-]*number|\bcvv\b|\bcvc\b|\biban\b|seed[\s_-]*phrase|mnemonic|cookie|session[\s_-]*id|bearer/i;
133
+
134
+ const fail = (msg: string): never => {
135
+ throw new CardError(msg);
136
+ };
137
+
138
+ /** A plain JSON object with exactly the allowed keys (no prototype tricks), `required` ones present. */
139
+ function object(v: unknown, where: string, allowed: readonly string[], required: readonly string[] = []): Record<string, unknown> {
140
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) fail(`${where} must be an object`);
141
+ const proto = Object.getPrototypeOf(v);
142
+ if (proto !== Object.prototype && proto !== null) fail(`${where} must be a plain object`);
143
+ const o = v as Record<string, unknown>;
144
+ for (const k of Object.keys(o)) {
145
+ if (BANNED_KEYS.has(k)) fail(`${where} has a key that is not allowed: ${k}`);
146
+ if (!allowed.includes(k)) fail(`${where} has an unknown field "${k}" (allowed: ${allowed.join(', ')})`);
147
+ }
148
+ for (const k of required) if (o[k] === undefined) fail(`${where}.${k} is required`);
149
+ return o;
150
+ }
151
+
152
+ /** Control characters out (newlines kept only where `multiline`), trimmed, bounded. */
153
+ export function cleanText(v: unknown, where: string, max: number, opts: { multiline?: boolean; required?: boolean } = {}): string {
154
+ if (typeof v !== 'string') return fail(`${where} must be a string`);
155
+ let t = v.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g, '');
156
+ t = opts.multiline ? t.replace(/\t/g, ' ').replace(/\n{3,}/g, '\n\n').trim() : t.replace(/\s+/g, ' ').trim();
157
+ if (opts.required !== false && !t) fail(`${where} must not be empty`);
158
+ if (t.length > max) fail(`${where} is too long (max ${max} characters)`);
159
+ return t;
160
+ }
161
+
162
+ function int(v: unknown, where: string, min: number, max: number): number {
163
+ if (typeof v !== 'number' || !Number.isFinite(v)) return fail(`${where} must be a number`);
164
+ if (v < min || v > max) fail(`${where} must be between ${min} and ${max}`);
165
+ return v;
166
+ }
167
+
168
+ function list(v: unknown, where: string, min: number, max: number): unknown[] {
169
+ if (!Array.isArray(v)) return fail(`${where} must be a list`);
170
+ if (v.length < min || v.length > max) fail(min === max ? `${where} must have ${min} items` : `${where} must have ${min} to ${max} items`);
171
+ return v;
172
+ }
173
+
174
+ function id(v: unknown, where: string): string {
175
+ if (typeof v !== 'string' || !ID.test(v) || BANNED_KEYS.has(v)) fail(`${where} must be a short id: lowercase letters, digits, - and _, starting with a letter`);
176
+ return v as string;
177
+ }
178
+
179
+ function options(v: unknown, where: string, min: number, max: number): CardOption[] {
180
+ const seen = new Set<string>();
181
+ return list(v, where, min, max).map((x, i) => {
182
+ const o = object(x, `${where}[${i}]`, ['id', 'label'], ['id', 'label']);
183
+ const oid = id(o.id, `${where}[${i}].id`);
184
+ if (seen.has(oid)) fail(`${where} has the id "${oid}" twice`);
185
+ seen.add(oid);
186
+ return { id: oid, label: cleanText(o.label, `${where}[${i}].label`, CARD_LIMITS.optionLabel) };
187
+ });
188
+ }
189
+
190
+ /** An absolute https URL with a host, no user name or password in it, or a reason it isn't one. */
191
+ export function safeLinkProblem(raw: string): string | null {
192
+ let u: URL;
193
+ try {
194
+ u = new URL(raw);
195
+ } catch {
196
+ return 'is not a valid URL';
197
+ }
198
+ if (u.protocol !== 'https:') return 'must be an https:// link';
199
+ if (u.username || u.password) return 'must not have a user name or password in it';
200
+ if (!u.hostname || !u.hostname.includes('.')) return 'must name a site';
201
+ return null;
202
+ }
203
+
204
+ function secretWording(...texts: (string | undefined)[]): boolean {
205
+ return texts.some((t) => !!t && SECRET_WORDS.test(t));
206
+ }
207
+
208
+ function field(v: unknown, where: string): CardField {
209
+ const o = object(v, where, ['id', 'label', 'kind', 'required', 'maxLength', 'min', 'max', 'options', 'placeholder'], ['id', 'label', 'kind']);
210
+ const f: CardField = { id: id(o.id, `${where}.id`), label: cleanText(o.label, `${where}.label`, CARD_LIMITS.fieldLabel), kind: 'text' };
211
+ const kinds: CardFieldKind[] = ['text', 'textarea', 'number', 'select', 'checkbox'];
212
+ if (!kinds.includes(o.kind as CardFieldKind)) fail(`${where}.kind must be one of ${kinds.join(', ')}`);
213
+ f.kind = o.kind as CardFieldKind;
214
+ if (o.required !== undefined) {
215
+ if (typeof o.required !== 'boolean') fail(`${where}.required must be true or false`);
216
+ if (o.required) f.required = true;
217
+ }
218
+ if (o.placeholder !== undefined) f.placeholder = cleanText(o.placeholder, `${where}.placeholder`, CARD_LIMITS.placeholder);
219
+ const only = (keys: string[], kinds: CardFieldKind[]) => {
220
+ for (const k of keys) if (o[k] !== undefined && !kinds.includes(f.kind)) fail(`${where}.${k} goes only with kind ${kinds.join(' or ')}`);
221
+ };
222
+ only(['maxLength'], ['text', 'textarea']);
223
+ only(['min', 'max'], ['number']);
224
+ only(['options'], ['select']);
225
+ only(['placeholder'], ['text', 'textarea', 'number']);
226
+ if (o.maxLength !== undefined) f.maxLength = Math.floor(int(o.maxLength, `${where}.maxLength`, 1, CARD_LIMITS.valueMax));
227
+ if (o.min !== undefined) f.min = int(o.min, `${where}.min`, -1e12, 1e12);
228
+ if (o.max !== undefined) f.max = int(o.max, `${where}.max`, -1e12, 1e12);
229
+ if (f.min !== undefined && f.max !== undefined && f.min > f.max) fail(`${where}.min is more than its max`);
230
+ if (f.kind === 'select') {
231
+ if (o.options === undefined) fail(`${where}.options is required for a select`);
232
+ f.options = options(o.options, `${where}.options`, 1, CARD_LIMITS.selectOptions);
233
+ }
234
+ if (secretWording(f.id, f.label, f.placeholder, ...(f.options ?? []).map((x) => x.label))) {
235
+ fail(`${where} asks for what looks like a password, key, code or other secret. Cards never take secrets: their answers go into the chat. Ask your owner to sign in or enter it in Settings instead.`);
236
+ }
237
+ return f;
238
+ }
239
+
240
+ function block(v: unknown, where: string): CardBlock {
241
+ const type = (typeof v === 'object' && v !== null ? (v as Record<string, unknown>).type : undefined) as CardBlock['type'] | undefined;
242
+ switch (type) {
243
+ case 'text': {
244
+ const o = object(v, where, ['type', 'text'], ['text']);
245
+ return { type, text: cleanText(o.text, `${where}.text`, CARD_LIMITS.text, { multiline: true }) };
246
+ }
247
+ case 'status': {
248
+ const o = object(v, where, ['type', 'tone', 'text'], ['tone', 'text']);
249
+ const tones: CardTone[] = ['info', 'success', 'warning', 'error', 'working'];
250
+ if (!tones.includes(o.tone as CardTone)) fail(`${where}.tone must be one of ${tones.join(', ')}`);
251
+ return { type, tone: o.tone as CardTone, text: cleanText(o.text, `${where}.text`, CARD_LIMITS.status) };
252
+ }
253
+ case 'progress': {
254
+ const o = object(v, where, ['type', 'label', 'value', 'max'], ['label', 'value', 'max']);
255
+ const max = int(o.max, `${where}.max`, 1, 1e12);
256
+ return { type, label: cleanText(o.label, `${where}.label`, CARD_LIMITS.label), value: int(o.value, `${where}.value`, 0, max), max };
257
+ }
258
+ case 'table': {
259
+ const o = object(v, where, ['type', 'columns', 'rows'], ['columns', 'rows']);
260
+ const columns = list(o.columns, `${where}.columns`, 1, CARD_LIMITS.columns).map((c, i) => cleanText(c, `${where}.columns[${i}]`, CARD_LIMITS.optionLabel));
261
+ const rows = list(o.rows, `${where}.rows`, 0, CARD_LIMITS.rows).map((r, i) =>
262
+ list(r, `${where}.rows[${i}]`, columns.length, columns.length).map((c, j): CardCell => {
263
+ const at = `${where}.rows[${i}][${j}]`;
264
+ if (c === null) return null;
265
+ if (typeof c === 'number') return int(c, at, -1e15, 1e15);
266
+ return cleanText(c, at, CARD_LIMITS.cell, { required: false });
267
+ }),
268
+ );
269
+ return { type, columns, rows };
270
+ }
271
+ case 'file': {
272
+ const o = object(v, where, ['type', 'path', 'title'], ['path']);
273
+ const path = cleanText(o.path, `${where}.path`, CARD_LIMITS.path);
274
+ return { type, path, ...(o.title !== undefined ? { title: cleanText(o.title, `${where}.title`, CARD_LIMITS.label) } : {}) };
275
+ }
276
+ case 'link': {
277
+ const o = object(v, where, ['type', 'url', 'label'], ['url', 'label']);
278
+ const url = cleanText(o.url, `${where}.url`, CARD_LIMITS.url);
279
+ const problem = safeLinkProblem(url);
280
+ if (problem) fail(`${where}.url ${problem}`);
281
+ return { type, url: new URL(url).href, label: cleanText(o.label, `${where}.label`, CARD_LIMITS.label) };
282
+ }
283
+ case 'choice': {
284
+ const o = object(v, where, ['type', 'id', 'prompt', 'options'], ['id', 'prompt', 'options']);
285
+ return { type, id: id(o.id, `${where}.id`), prompt: cleanText(o.prompt, `${where}.prompt`, CARD_LIMITS.status), options: options(o.options, `${where}.options`, 2, CARD_LIMITS.options) };
286
+ }
287
+ case 'form': {
288
+ const o = object(v, where, ['type', 'id', 'prompt', 'fields', 'submit'], ['id', 'fields']);
289
+ const seen = new Set<string>();
290
+ const fields = list(o.fields, `${where}.fields`, 1, CARD_LIMITS.fields).map((f, i) => {
291
+ const out = field(f, `${where}.fields[${i}]`);
292
+ if (seen.has(out.id)) fail(`${where}.fields has the id "${out.id}" twice`);
293
+ seen.add(out.id);
294
+ return out;
295
+ });
296
+ const prompt = o.prompt === undefined ? undefined : cleanText(o.prompt, `${where}.prompt`, CARD_LIMITS.status);
297
+ if (secretWording(prompt)) fail(`${where}.prompt asks for what looks like a secret. Cards never take secrets: their answers go into the chat.`);
298
+ return {
299
+ type,
300
+ id: id(o.id, `${where}.id`),
301
+ ...(prompt ? { prompt } : {}),
302
+ fields,
303
+ ...(o.submit !== undefined ? { submit: cleanText(o.submit, `${where}.submit`, CARD_LIMITS.submit) } : {}),
304
+ };
305
+ }
306
+ case 'connector': {
307
+ const o = object(v, where, ['type', 'connectorId'], ['connectorId']);
308
+ if (typeof o.connectorId !== 'string' || !CONNECTOR_ID.test(o.connectorId)) fail(`${where}.connectorId must be a connector id from \`botons connector list\`, such as lib:github`);
309
+ return { type, connectorId: o.connectorId as string };
310
+ }
311
+ default:
312
+ return fail(`${where}.type must be one of text, status, progress, table, file, link, choice, form, connector`);
313
+ }
314
+ }
315
+
316
+ /** The card a Boton posted, checked and normalized; throws CardError with what is wrong. */
317
+ export function parseCard(input: unknown): CardDescriptor {
318
+ const o = object(input, 'card', ['v', 'title', 'blocks'], ['v', 'title', 'blocks']);
319
+ if (o.v !== CARD_VERSION) fail(`card.v must be ${CARD_VERSION}`);
320
+ const blocks = list(o.blocks, 'card.blocks', 1, CARD_LIMITS.blocks).map((b, i) => block(b, `card.blocks[${i}]`));
321
+ if (blocks.filter(isInteractive).length > 1) fail('A card has at most one choice, form or connector block: post another card for another question');
322
+ const card: CardDescriptor = { v: CARD_VERSION, title: cleanText(o.title, 'card.title', CARD_LIMITS.title), blocks };
323
+ if (JSON.stringify(card).length > CARD_LIMITS.bytes) fail(`The card is too big (max ${CARD_LIMITS.bytes} characters of JSON)`);
324
+ return card;
325
+ }
326
+
327
+ export function isInteractive(b: CardBlock): b is InteractiveBlock {
328
+ return b.type === 'choice' || b.type === 'form' || b.type === 'connector';
329
+ }
330
+
331
+ export function interactiveBlock(card: CardDescriptor): InteractiveBlock | null {
332
+ return card.blocks.find(isInteractive) ?? null;
333
+ }
334
+
335
+ /** The owner's answer, checked against the card as the server saved it (never one the client sends). */
336
+ export function parseAnswer(card: CardDescriptor, input: unknown): CardAnswerInput {
337
+ const action = typeof input === 'object' && input !== null ? (input as Record<string, unknown>).action : undefined;
338
+ const ask = interactiveBlock(card);
339
+ if (!ask) fail('This card has nothing to answer');
340
+ switch (action) {
341
+ case 'decline':
342
+ object(input, 'answer', ['action']);
343
+ return { action };
344
+ case 'choose': {
345
+ const o = object(input, 'answer', ['action', 'option'], ['option']);
346
+ if (ask!.type !== 'choice') fail('This card has no choice to make');
347
+ const choice = ask as Extract<CardBlock, { type: 'choice' }>;
348
+ if (typeof o.option !== 'string' || !choice.options.some((x) => x.id === o.option)) fail('option must be one of the card’s options');
349
+ return { action, option: o.option as string };
350
+ }
351
+ case 'grant':
352
+ object(input, 'answer', ['action']);
353
+ if (ask!.type !== 'connector') fail('This card asks for no connector');
354
+ return { action };
355
+ case 'submit': {
356
+ const o = object(input, 'answer', ['action', 'values'], ['values']);
357
+ if (ask!.type !== 'form') fail('This card has no form');
358
+ return { action, values: formValues((ask as Extract<CardBlock, { type: 'form' }>).fields, o.values) };
359
+ }
360
+ default:
361
+ return fail('action must be one of choose, submit, grant, decline');
362
+ }
363
+ }
364
+
365
+ /** Every value checked against its field: known fields only, of their kind and bounds, text cleaned. */
366
+ export function formValues(fields: CardField[], input: unknown): Record<string, string | number | boolean> {
367
+ const o = object(
368
+ input,
369
+ 'values',
370
+ fields.map((f) => f.id),
371
+ );
372
+ const out: Record<string, string | number | boolean> = {};
373
+ for (const f of fields) {
374
+ const v = o[f.id];
375
+ const where = `values.${f.id}`;
376
+ const empty = v === undefined || v === null || v === '';
377
+ if (f.kind === 'checkbox') {
378
+ if (!empty && typeof v !== 'boolean') fail(`${where} must be true or false`);
379
+ if (f.required && v !== true) fail(`${f.label} must be checked`);
380
+ out[f.id] = v === true;
381
+ continue;
382
+ }
383
+ if (empty) {
384
+ if (f.required) fail(`${f.label} is required`);
385
+ continue;
386
+ }
387
+ if (f.kind === 'number') {
388
+ const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
389
+ if (typeof n !== 'number' || !Number.isFinite(n)) fail(`${f.label} must be a number`);
390
+ if (f.min !== undefined && (n as number) < f.min) fail(`${f.label} must be at least ${f.min}`);
391
+ if (f.max !== undefined && (n as number) > f.max) fail(`${f.label} must be at most ${f.max}`);
392
+ out[f.id] = n as number;
393
+ } else if (f.kind === 'select') {
394
+ if (typeof v !== 'string' || !f.options!.some((x) => x.id === v)) fail(`${f.label} must be one of its options`);
395
+ out[f.id] = v as string;
396
+ } else {
397
+ const t = cleanText(v, f.label, f.maxLength ?? CARD_LIMITS.valueMax, { multiline: f.kind === 'textarea', required: false });
398
+ if (!t && f.required) fail(`${f.label} is required`);
399
+ if (t) out[f.id] = t;
400
+ }
401
+ }
402
+ return out;
403
+ }
404
+
405
+ /** The answer in words, for the transcript and the Boton's turn. */
406
+ export function answerText(card: CardDescriptor, answer: CardAnswer): string {
407
+ const ask = interactiveBlock(card);
408
+ const title = `“${card.title}”`;
409
+ switch (answer.action) {
410
+ case 'decline':
411
+ return `Declined ${title}`;
412
+ case 'grant':
413
+ return answer.result === 'already' ? `Answered ${title}: the connector was already there` : `Answered ${title}: gave the connector`;
414
+ case 'choose':
415
+ return `Answered ${title}: ${answer.option?.label ?? ''}`;
416
+ case 'submit': {
417
+ const fields = ask?.type === 'form' ? ask.fields : [];
418
+ const lines = fields.map((f) => {
419
+ const v = answer.values?.[f.id];
420
+ const shown =
421
+ v === undefined ? '(left empty)' : typeof v === 'boolean' ? (v ? 'yes' : 'no') : f.kind === 'select' ? (f.options?.find((x) => x.id === v)?.label ?? String(v)) : String(v);
422
+ return `- ${f.label}: ${shown.includes('\n') ? `\n ${shown.split('\n').join('\n ')}` : shown}`;
423
+ });
424
+ return `Answered ${title}:\n${lines.join('\n')}`;
425
+ }
426
+ }
427
+ }
@@ -0,0 +1,91 @@
1
+ // How hard a Boton's agent CLI thinks: the levels each provider takes, checked where they are set. Node builtins
2
+ // only (none at all), so doctor and the `botons` CLI can load it without node_modules.
3
+
4
+ export interface EffortLevel {
5
+ id: string;
6
+ label: string;
7
+ }
8
+
9
+ export interface ProviderEffort {
10
+ /** The CLI's name, as refusals say it. */
11
+ name: string;
12
+ /** Every level the provider takes, weakest first. */
13
+ levels: readonly EffortLevel[];
14
+ /** How it reaches the CLI, for the owner. */
15
+ via: string;
16
+ /** What runs when no level is set. */
17
+ defaultText: string;
18
+ }
19
+
20
+ const level = (id: string, label = id[0].toUpperCase() + id.slice(1)): EffortLevel => ({ id, label });
21
+
22
+ /**
23
+ * Claude Code: `--effort`. It runs a level the model doesn't have as the highest one it has below it, and its
24
+ * CLAUDE_CODE_EFFORT_LEVEL would win over the flag, which is why the agent environment never passes it on.
25
+ * Codex: `model_reasoning_effort`, whose levels are its ReasoningEffort names; which of them a model takes is in
26
+ * its model list (ProviderModel.efforts).
27
+ */
28
+ export const PROVIDER_EFFORT: Readonly<Record<string, ProviderEffort>> = {
29
+ 'claude-cli': {
30
+ name: 'Claude Code',
31
+ levels: [level('low'), level('medium'), level('high'), level('xhigh', 'Extra high'), level('max')],
32
+ via: 'Claude Code’s --effort',
33
+ defaultText: 'Claude Code’s default for the model',
34
+ },
35
+ 'codex-cli': {
36
+ name: 'Codex',
37
+ levels: [level('none'), level('minimal'), level('low'), level('medium'), level('high'), level('xhigh', 'Extra high'), level('max'), level('ultra')],
38
+ via: 'Codex’s model_reasoning_effort',
39
+ defaultText: 'Codex’s default for the model',
40
+ },
41
+ // The Messages API's output_config.effort; which levels a model takes is in the key's model list (its effort capability).
42
+ 'anthropic-api': {
43
+ name: 'The Anthropic API',
44
+ levels: [level('low'), level('medium'), level('high'), level('xhigh', 'Extra high'), level('max')],
45
+ via: 'the Anthropic API’s output_config.effort',
46
+ defaultText: 'the model’s default',
47
+ },
48
+ // The Responses API's reasoning.effort; not every model takes every level, and its model list doesn't say which.
49
+ 'openai-api': {
50
+ name: 'The OpenAI API',
51
+ levels: [level('none'), level('minimal'), level('low'), level('medium'), level('high'), level('xhigh', 'Extra high'), level('max')],
52
+ via: 'the OpenAI API’s reasoning.effort',
53
+ defaultText: 'the model’s default',
54
+ },
55
+ mock: { name: 'The mock provider', levels: [level('low'), level('medium'), level('high')], via: 'the mock provider', defaultText: 'the mock’s default' },
56
+ };
57
+
58
+ /** Every level any provider takes. */
59
+ export const EFFORT_IDS: readonly string[] = [...new Set(Object.values(PROVIDER_EFFORT).flatMap((p) => p.levels.map((l) => l.id)))];
60
+
61
+ export const providerEffort = (provider: string): ProviderEffort | null => PROVIDER_EFFORT[provider] ?? null;
62
+
63
+ /**
64
+ * What the provider's own model list says one model takes: its levels and, when it says, the one it runs without a
65
+ * setting. Only a list the provider gives counts; null (no list, or a model it doesn't list) means nothing is known
66
+ * about the model, and only the provider's levels are checked.
67
+ */
68
+ export interface ModelEfforts {
69
+ model: string;
70
+ label: string;
71
+ levels: readonly string[];
72
+ defaultEffort?: string;
73
+ }
74
+
75
+ /**
76
+ * Why `effort` can't be asked of `provider` ("" is always fine: its default), or null. With `known`, the model's own
77
+ * list is checked too; without it, a provider's level may still be one the model lacks, which only the CLI can tell.
78
+ */
79
+ export function effortProblem(provider: string, effort: string, known?: ModelEfforts | null): string | null {
80
+ if (effort === '') return null;
81
+ const p = providerEffort(provider);
82
+ if (!p) return `${provider} has no effort setting: leave it at the provider's default ("")`;
83
+ if (!p.levels.some((l) => l.id === effort)) return `${p.name} has no effort level "${effort}": use one of ${p.levels.map((l) => l.id).join(', ')}, or "" for its default`;
84
+ if (known && !known.levels.includes(effort)) {
85
+ const usable = p.levels.filter((l) => known.levels.includes(l.id)).map((l) => l.id);
86
+ const name = known.label && known.label !== known.model ? `${known.label} (${known.model})` : known.model;
87
+ const fallback = known.defaultEffort ? `, or "" for its default (${known.defaultEffort}, as the list says)` : ', or "" for its default';
88
+ return `${p.name}'s model list has no effort level "${effort}" for ${name}: use one of ${usable.join(', ')}${fallback}`;
89
+ }
90
+ return null;
91
+ }
@@ -0,0 +1,104 @@
1
+ export const UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
2
+ export const ATTACHMENTS_MAX = 10;
3
+ /** Past this size a file is only downloaded, never shown in the app. */
4
+ export const PREVIEW_MAX_BYTES = 25 * 1024 * 1024;
5
+
6
+ /**
7
+ * How the app shows a file. `svg` and `html` can carry script, so neither is an `image` or text:
8
+ * an SVG is shown only as an <img> (or a sandboxed document with no script), and HTML only in a
9
+ * sandboxed page with an opaque origin (see `servingOf` in server/src/files.ts).
10
+ */
11
+ export type FileKind = 'image' | 'svg' | 'html' | 'pdf' | 'markdown' | 'text' | 'other';
12
+
13
+ const IMAGES: Record<string, string> = {
14
+ png: 'image/png',
15
+ jpg: 'image/jpeg',
16
+ jpeg: 'image/jpeg',
17
+ gif: 'image/gif',
18
+ webp: 'image/webp',
19
+ avif: 'image/avif',
20
+ bmp: 'image/bmp',
21
+ ico: 'image/x-icon',
22
+ };
23
+
24
+ const MARKDOWN = new Set(['md', 'markdown', 'mdown', 'mkd']);
25
+
26
+ const TEXT = new Set([
27
+ 'txt', 'text', 'log', 'out', 'err', 'csv', 'tsv', 'json', 'jsonl', 'ndjson', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf',
28
+ 'env', 'properties', 'rst', 'adoc', 'org', 'tex', 'bib', 'srt', 'vtt', 'diff', 'patch', 'sql', 'graphql', 'proto',
29
+ 'ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'swift', 'c', 'h',
30
+ 'cc', 'cpp', 'hpp', 'cs', 'php', 'pl', 'lua', 'r', 'scala', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'css', 'scss', 'less',
31
+ 'vue', 'svelte', 'dockerfile', 'gitignore', 'lock',
32
+ ]);
33
+
34
+ const OTHER_TYPES: Record<string, string> = {
35
+ html: 'text/html',
36
+ htm: 'text/html',
37
+ svg: 'image/svg+xml',
38
+ xml: 'application/xml',
39
+ zip: 'application/zip',
40
+ gz: 'application/gzip',
41
+ tar: 'application/x-tar',
42
+ mp3: 'audio/mpeg',
43
+ m4a: 'audio/mp4',
44
+ wav: 'audio/wav',
45
+ mp4: 'video/mp4',
46
+ mov: 'video/quicktime',
47
+ webm: 'video/webm',
48
+ heic: 'image/heic',
49
+ heif: 'image/heif',
50
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
51
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
52
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
53
+ };
54
+
55
+ function extensionOf(name: string): string {
56
+ const base = name.slice(name.lastIndexOf('/') + 1);
57
+ const dot = base.lastIndexOf('.');
58
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
59
+ }
60
+
61
+ export function fileKind(name: string): FileKind {
62
+ const ext = extensionOf(name);
63
+ if (ext in IMAGES) return 'image';
64
+ if (ext === 'svg') return 'svg';
65
+ if (ext === 'html' || ext === 'htm') return 'html';
66
+ if (ext === 'pdf') return 'pdf';
67
+ if (MARKDOWN.has(ext)) return 'markdown';
68
+ if (TEXT.has(ext)) return 'text';
69
+ return 'other';
70
+ }
71
+
72
+ export function mimeType(name: string): string {
73
+ const ext = extensionOf(name);
74
+ if (ext in IMAGES) return IMAGES[ext];
75
+ if (ext === 'pdf') return 'application/pdf';
76
+ if (MARKDOWN.has(ext)) return 'text/markdown';
77
+ if (ext === 'json') return 'application/json';
78
+ if (ext === 'csv') return 'text/csv';
79
+ if (TEXT.has(ext)) return 'text/plain';
80
+ return OTHER_TYPES[ext] ?? 'application/octet-stream';
81
+ }
82
+
83
+ export function formatSize(bytes: number): string {
84
+ if (bytes < 1024) return `${bytes} B`;
85
+ const units = ['KB', 'MB', 'GB', 'TB'];
86
+ let n = bytes / 1024;
87
+ let i = 0;
88
+ while (n >= 1024 && i < units.length - 1) {
89
+ n /= 1024;
90
+ i++;
91
+ }
92
+ return `${n < 10 ? n.toFixed(1).replace(/\.0$/, '') : Math.round(n)} ${units[i]}`;
93
+ }
94
+
95
+ /** Days something stays in the trash before it is deleted for good. */
96
+ export const TRASH_KEEP_DAYS = 30;
97
+ /** The daily inbox cleanup's choices in Settings → Storage (days untouched), and its default; null turns it off. */
98
+ export const INBOX_CLEANUP_CHOICES: readonly number[] = [7, 14, 30, 60, 90];
99
+ export const INBOX_CLEANUP_DEFAULT_DAYS = 30;
100
+ /** Files kept (Keep) in one Boton's inbox: every one is honoured, and one more past this is refused. */
101
+ export const INBOX_PINS_MAX = 1000;
102
+ /** Past these, doctor and Settings → Storage suggest Clear inbox or emptying the trash. */
103
+ export const INBOX_WARN_BYTES = 1024 ** 3;
104
+ export const FILES_WARN_BYTES = 5 * 1024 ** 3;