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,1669 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { currentPortable } from '../../shared/legacy-names.mjs';
3
+ import type {
4
+ AccessLevel,
5
+ Bot,
6
+ ConnectorGrant,
7
+ PortableConfig,
8
+ PortableConnector,
9
+ PortableCapsChoice,
10
+ PortableConnectorAction,
11
+ PortableExport,
12
+ PortableImportChoices,
13
+ PortableImportInput,
14
+ PortableImportPreview,
15
+ PortableImportResult,
16
+ PortableBoton,
17
+ PortableBotonAction,
18
+ PortableBotonPlan,
19
+ PortableReconnect,
20
+ PortableRoutine,
21
+ PortableSettings,
22
+ PortableSidebarMode,
23
+ PortableSkill,
24
+ PortableSkillAction,
25
+ PortableTemplate,
26
+ PortableTemplateAction,
27
+ SidebarTransfer,
28
+ BotonLook,
29
+ TemplateRoutine,
30
+ SkillAuthor,
31
+ SkillSource,
32
+ } from '../../shared/types.ts';
33
+ import { effortProblem } from '../../shared/effort.ts';
34
+ import { lookFromTater, parseLook } from '../../shared/look.ts';
35
+ import { parseCritter } from '../../shared/look-critter.ts';
36
+ import { parseTater, taterFromCritter } from '../../shared/look-tater.ts';
37
+ import { tokenShapedName } from '../../shared/services.ts';
38
+ import { BROWSER_CONNECTOR, BROWSER_SERVER, isPastOneOff, isValidTimezone } from '../../shared/util.ts';
39
+ import { askFirstList } from './ask-rules.ts';
40
+ import type { Browser } from './browser.ts';
41
+ import { effectiveSites } from './browser-policy.ts';
42
+ import type { Config } from './config.ts';
43
+ import { type Connectors, countedConnections, MAX_CONNECTIONS } from './connectors.ts';
44
+ import { log } from './log.ts';
45
+ import type { OAuthConnections } from './oauth-connections.ts';
46
+ import { refusedOutbound } from './outbound.ts';
47
+ import type { ProviderRegistry } from './providers/index.ts';
48
+ import { describeSchedule, parseSchedule, toSpec } from './schedule.ts';
49
+ import { redactTokenShaped } from './secrets.ts';
50
+ import { anthropicWire, openaiWire } from './providers/api-wire.ts';
51
+ import { rateFor, round } from './rates.ts';
52
+ import { asksOf } from './skill-packages.ts';
53
+ import { SKILL_HISTORY_KEEP, SKILL_NAME, type Skills, skillVersion } from './skills.ts';
54
+ import { MAX_CAP_USD, type Spending } from './spend.ts';
55
+ import { alwaysAllowList, followUpsInput, HttpError, ntfyOrigin, inheritedEffortProblem, MAX_ROUTINES_PER_BOT, type Store, sidebarTransfer } from './store.ts';
56
+ import { readRoutine, type StoredTemplate, type Templates, templateRoutine } from './templates.ts';
57
+ import { nowIso } from './util.ts';
58
+
59
+ /*
60
+ * The owner's configuration as a file of its own (PortableConfig in shared/types.ts), to move to another server or
61
+ * rebuild one without a backup: Botons with their routines, rules and browser sites, skills with their history,
62
+ * connectors without their secrets, settings and the sidebar. A full backup (npm run backup) is what holds the rest:
63
+ * chats, memories, workspaces, tokens, sign-ins and secrets; this file never does.
64
+ *
65
+ * What is left out is decided by an allowlist: export() builds each part field by field from what is listed below,
66
+ * never by copying a row and deleting secrets from it. Structured places a credential lives (a connector's env and
67
+ * headers, a URL's query, an argument that looks like one) keep only their names, or a placeholder. Text the owner
68
+ * wrote (instructions, skills) is copied as written: it is checked for credential shapes and named in the export's
69
+ * warnings, which is a help, not a guarantee.
70
+ *
71
+ * Importing reads the file as data only: nothing in it is run, fetched or followed, and no file is opened. Every
72
+ * field is checked against what this version of the format has (unknown or missing ones refuse the file), then
73
+ * preview() works out exactly what would be made, and import() makes it only if that is still what preview() showed
74
+ * (its fingerprint), all in one transaction. It widens nothing silently: Full computer access and Always allow
75
+ * rules come only when the owner ticks them, routines arrive off, webhooks get new keys, connectors arrive off (with
76
+ * a fresh request-policy generation) and OAuth ones signed out, and the managed browser stays the server's own.
77
+ * Spending caps go with their Boton and routines (never what was spent): a Boton made by an import is new here, so
78
+ * it gets the file's caps (or none, if the owner chooses) and an account of its own from $0; no Boton already here has
79
+ * its caps or account touched.
80
+ */
81
+
82
+ export const PORTABLE_FORMAT = 'botons-config';
83
+ /** This server writes and reads this version; older ones are upgraded by UPGRADES first. */
84
+ export const PORTABLE_VERSION = 5;
85
+ /** The import routes' body limit: the file and the choices. */
86
+ export const PORTABLE_MAX_BYTES = 16 * 1024 * 1024;
87
+ /**
88
+ * How this server turns a file into a plan, in the preview's fingerprint: a preview made by a server that planned
89
+ * differently (another release, mid-upgrade) is refused at import and shown again, never applied as it now reads.
90
+ */
91
+ const PLAN_REVISION = 'plan-6';
92
+
93
+ /**
94
+ * Each entry turns a file of version n into version n + 1, as data (never code from the file). A version this
95
+ * server doesn't know, older or newer, is refused. Each of server/test/fixtures/portable-config-{1,2,3}.json must keep importing.
96
+ * (A file at the current version is what export writes; the tests make one.)
97
+ */
98
+ const rec = (x: unknown) => (x && typeof x === 'object' && !Array.isArray(x) ? (x as Record<string, unknown>) : null);
99
+
100
+ /** A file's Botons and templates with each look (a template's may be null) turned by `to`. */
101
+ function eachLook(f: Record<string, unknown>, to: (v: unknown, where: string) => unknown): { botons: unknown; templates: unknown } {
102
+ const botons = Array.isArray(f.botons) ? f.botons.map((m, i) => (rec(m) && 'look' in rec(m)! ? { ...rec(m), look: to(rec(m)!.look, `botons[${i}].look`) } : m)) : f.botons;
103
+ const templates = Array.isArray(f.templates)
104
+ ? f.templates.map((t, i) => {
105
+ const bot = rec(rec(t)?.bot);
106
+ return bot && bot.look !== null && bot.look !== undefined ? { ...rec(t), bot: { ...bot, look: to(bot.look, `templates[${i}].bot.look`) } } : t;
107
+ })
108
+ : f.templates;
109
+ return { botons, templates };
110
+ }
111
+
112
+ const UPGRADES: Record<number, (file: Record<string, unknown>) => Record<string, unknown>> = {
113
+ // Version 2 added saved templates and the reactions that start a follow-up. A version-1 file has neither: no
114
+ // templates, and a reaction setting of null, which keeps the importing server's own (never a default in its place).
115
+ 1: (f) => {
116
+ if ('templates' in f) bad('file.templates', 'is not a field of version 1');
117
+ const settings = f.settings && typeof f.settings === 'object' && !Array.isArray(f.settings) ? (f.settings as Record<string, unknown>) : null;
118
+ if (settings && 'reactionFollowUps' in settings) bad('settings.reactionFollowUps', 'is not a field of version 1');
119
+ return { ...f, version: 2, templates: [], settings: settings ? { ...settings, reactionFollowUps: null } : f.settings };
120
+ },
121
+ // Version 3 added spending caps: a Boton's a day and a routine's a run. A version-2 file has none, so its Botons
122
+ // arrive with none, as a Boton the owner adds by hand does (the preview says the file had no caps to carry).
123
+ 2: (f) => {
124
+ if (!Array.isArray(f.botons)) return { ...f, version: 3 };
125
+ const botons = f.botons.map((m, i) => {
126
+ const o = rec(m);
127
+ if (!o) return m;
128
+ if ('dailyCapUsd' in o) bad(`botons[${i}].dailyCapUsd`, 'is not a field of version 2');
129
+ const routines = Array.isArray(o.routines)
130
+ ? o.routines.map((r, j) => {
131
+ const ro = rec(r);
132
+ if (!ro) return r;
133
+ if ('perRunCapUsd' in ro) bad(`botons[${i}].routines[${j}].perRunCapUsd`, 'is not a field of version 2');
134
+ return { ...ro, perRunCapUsd: null };
135
+ })
136
+ : o.routines;
137
+ return { ...o, dailyCapUsd: null, routines };
138
+ });
139
+ return { ...f, version: 3, botons };
140
+ },
141
+ // Version 4 holds tater looks. A version-3 look is a Critter, and it becomes its closest tater look
142
+ // (taterFromCritter); one that isn't a Critter refuses the file, as it did at version 3.
143
+ 3: (f) => {
144
+ const tater = (v: unknown, where: string) => {
145
+ const critter = parseCritter(obj(v, where, CRITTER_KEYS));
146
+ return critter ? taterFromCritter(critter) : bad(where, 'is not a look this server draws');
147
+ };
148
+ return { ...f, version: 4, ...eachLook(f, tater) };
149
+ },
150
+ // Version 5 draws Botons. A version-4 look is a tater look, and it becomes its closest Boton (lookFromTater); one
151
+ // that isn't a tater look refuses the file, as it did at version 4.
152
+ 4: (f) => {
153
+ const Boton = (v: unknown, where: string) => {
154
+ const tater = parseTater(obj(v, where, TATER_KEYS));
155
+ return tater ? lookFromTater(tater) : bad(where, 'is not a look this server draws');
156
+ };
157
+ return { ...f, version: 5, ...eachLook(f, Boton) };
158
+ },
159
+ };
160
+
161
+ const MAX_BOTONS = 500;
162
+ const MAX_SKILLS = 1000;
163
+ const MAX_LIBRARY = 200;
164
+ const MAX_OAUTH = 20;
165
+ const MAX_SITES = 200;
166
+ const MAX_TEMPLATES = 200;
167
+ const MAX_DEPTH = 16;
168
+ const MAX_NODES = 500_000;
169
+ const REF = /^[A-Za-z0-9_-]{1,64}$/;
170
+ const CONNECTOR_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/;
171
+ const PROVIDER = /^[a-z0-9][a-z0-9_-]{0,39}$/;
172
+ const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
173
+ const HEADER_KEY = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
174
+ const NTFY_TOPIC = /^[-_A-Za-z0-9]{1,64}$/;
175
+ const HASH = /^[0-9a-f]{32}$/;
176
+ const FIELD_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
177
+ /** Keys no object in the file may have, at any depth. */
178
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
179
+
180
+ /** Stands in the file where a credential was. */
181
+ export const LEFT_OUT = '<left out by the export>';
182
+
183
+ /** Never in the file, whatever is in the database. */
184
+ const NEVER_EXPORTED = [
185
+ 'chats, memories and workspace files',
186
+ "connectors' env and header values, and URL queries",
187
+ 'OAuth sign-ins, tokens and client registrations',
188
+ 'webhook keys, signing secrets and callback URLs',
189
+ 'the ntfy token and API keys',
190
+ 'paired devices, pairing requests and Boton tokens',
191
+ 'browser profiles, cookies and journals',
192
+ 'provider sessions, approvals and the audit log',
193
+ 'what API-key turns have spent (the account the spending caps count): it stays in full backups',
194
+ ];
195
+
196
+ /** The model an API key brain's turn runs on, and whether Botons knows its price; null for another brain. */
197
+ function apiPriceKnown(provider: string, model: string): boolean | null {
198
+ if (provider === 'anthropic-api') return rateFor('anthropic', model || anthropicWire.defaultModel) !== null;
199
+ if (provider === 'openai-api') return rateFor('openai', model || openaiWire.defaultModel) !== null;
200
+ return null;
201
+ }
202
+ const usd = (v: number | null) => (v === null ? 'none' : `$${v}`);
203
+
204
+ /** Shapes of credentials in text: token-shaped runs (as secrets.ts sees them) and well-known key prefixes. */
205
+ const KEY_PREFIX = /\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|github_pat_\w{8,}|xox[abprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{16}|tk_[A-Za-z0-9]{8,})|-----BEGIN [A-Z ]*PRIVATE KEY-----|\bBearer\s+[A-Za-z0-9._~+/-]{12,}/;
206
+ export const credentialShaped = (text: string): boolean => KEY_PREFIX.test(text) || redactTokenShaped(text) !== text;
207
+
208
+ // ------------------------------------------------------- credentials in structured places
209
+ // A connector's command line is structured enough to read: flags and their values, NAME=value assignments, header
210
+ // lines, URLs. cleanArgs() reads it that way, so a short or plain credential named as one is left out however it is
211
+ // written (`--password -x`, `API_KEY=a`, `--token=a`, `Authorization: Bearer a`, a URL's user, password or query),
212
+ // and token-shaped runs are left out wherever they are. It leaves out more than it must rather than less; what it
213
+ // left out is named, so the owner can put it back. Prose (instructions, skills) can't be read this way: it is only
214
+ // checked, and named in the warnings.
215
+
216
+ /** Words that make a name (a flag, an env var, a header, a query parameter) the name of a credential, in any case. */
217
+ const SECRET_WORDS = new Set(['token', 'secret', 'password', 'passwd', 'passphrase', 'pass', 'pwd', 'auth', 'authorization', 'credential', 'credentials', 'apikey', 'key', 'cookie', 'session', 'bearer', 'pat', 'jwt', 'signature', 'sig']);
218
+ const SECRET_JOINED = /apikey|accesskey|secretkey|privatekey|authtoken|accesstoken|refreshtoken|clientsecret|sessionid/;
219
+
220
+ /** Whether `name` names a credential: by its words (split at -, _, ., spaces and camelCase), in any case. */
221
+ export function secretName(name: string): boolean {
222
+ const words = name
223
+ .replace(/^-+/, '')
224
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
225
+ .toLowerCase()
226
+ .split(/[^a-z0-9]+/)
227
+ .filter(Boolean);
228
+ return words.some((w) => SECRET_WORDS.has(w)) || SECRET_JOINED.test(words.join(''));
229
+ }
230
+
231
+ /** A word that says the next one is a credential: an HTTP auth scheme. */
232
+ const AUTH_SCHEME = /^(?:bearer|basic|token|digest)$/i;
233
+ /** Where LEFT_OUT already stands, as one word while text is split into words. */
234
+ const HELD = '\u0000left-out\u0000';
235
+
236
+ /**
237
+ * A URL without its user name and password, query and #fragment (`strict`), or without its user name, password and
238
+ * the query parameters named as credentials; and without its path when that holds something token-shaped. `why`
239
+ * says what went.
240
+ */
241
+ function cleanUrl(text: string, strict: boolean): { url: string; why: string | null } {
242
+ let u: URL;
243
+ try {
244
+ u = new URL(text);
245
+ } catch {
246
+ return /[@?#]/.test(text) ? { url: LEFT_OUT, why: 'it looked like a URL with a credential in it' } : { url: text, why: null };
247
+ }
248
+ const gone: string[] = [];
249
+ if (u.username || u.password) {
250
+ gone.push('its user name and password');
251
+ u.username = '';
252
+ u.password = '';
253
+ }
254
+ const params = [...new Set([...u.searchParams.keys()])];
255
+ const dropped = strict ? params : params.filter(secretName);
256
+ if (dropped.length || (strict && u.search)) {
257
+ gone.push(`its query${dropped.length ? ` (${dropped.join(', ')})` : ''}`);
258
+ if (strict) u.search = '';
259
+ else for (const k of dropped) u.searchParams.delete(k);
260
+ }
261
+ if (strict && u.hash) {
262
+ gone.push('its #fragment');
263
+ u.hash = '';
264
+ }
265
+ let path = u.pathname;
266
+ try {
267
+ path = decodeURIComponent(path);
268
+ } catch {
269
+ // A stray % stays as written.
270
+ }
271
+ if (credentialShaped(path)) {
272
+ gone.push('its path');
273
+ u.pathname = '/';
274
+ }
275
+ return gone.length ? { url: u.toString(), why: gone.join(', ') } : { url: text, why: null };
276
+ }
277
+
278
+ /** One word of a command line (an argument, or a word of one), with what is a credential replaced by LEFT_OUT. */
279
+ function cleanWord(w: string, prev: string | undefined): { word: string; why: string | null } {
280
+ if (w === HELD) return { word: w, why: null };
281
+ if (prev !== undefined && prev !== HELD) {
282
+ if (/^-/.test(prev) && !prev.includes('=') && secretName(prev)) return { word: HELD, why: `the value of ${prev}` };
283
+ if (AUTH_SCHEME.test(prev)) return { word: HELD, why: `the credential after ${prev}` };
284
+ if (/^[A-Za-z][\w-]*:$/.test(prev) && secretName(prev.slice(0, -1))) return { word: HELD, why: `the value of ${prev.slice(0, -1)}` };
285
+ }
286
+ // NAME=value, --name=value: the value of a credential's name goes; any other value is read as a word itself.
287
+ const eq = /^(-{0,2}[A-Za-z_][\w.-]*)=([\s\S]*)$/.exec(w);
288
+ if (eq) {
289
+ if (secretName(eq[1])) return { word: `${eq[1]}=${HELD}`, why: `the value of ${eq[1]}` };
290
+ const v = cleanWord(eq[2], undefined);
291
+ return { word: `${eq[1]}=${v.word}`, why: v.why };
292
+ }
293
+ // Name:value, as a header can be written.
294
+ const colon = /^([A-Za-z][\w-]*):(?!\/\/)(.+)$/.exec(w);
295
+ if (colon && secretName(colon[1])) return { word: `${colon[1]}:${HELD}`, why: `the value of ${colon[1]}` };
296
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(w)) {
297
+ const u = cleanUrl(w, true);
298
+ return { word: u.url === LEFT_OUT ? HELD : u.url, why: u.why && `a URL's ${u.why.replace(/its /g, '')}` };
299
+ }
300
+ if (credentialShaped(w)) return { word: HELD, why: 'it looked like a credential' };
301
+ return { word: w, why: null };
302
+ }
303
+
304
+ /** Where a word says the next one is a credential (a credential's flag, `Name:`, an auth scheme): how to name it. */
305
+ function namesNext(word: string): string | null {
306
+ if (word === HELD) return null;
307
+ if (/^-/.test(word) && !word.includes('=') && secretName(word)) return `the value of ${word}`;
308
+ if (AUTH_SCHEME.test(word)) return `the credential after ${word}`;
309
+ if (/^[A-Za-z][\w-]*:$/.test(word) && secretName(word.slice(0, -1))) return `the value of ${word.slice(0, -1)}`;
310
+ return null;
311
+ }
312
+
313
+ /** What makes an argument more than one plain word: space, a quote or a backslash. */
314
+ const NOT_BARE = /[\s'"`\\]/;
315
+
316
+ /**
317
+ * `text` split into words as a POSIX shell reads them (single and double quotes, backslashes), or null where that
318
+ * can't be done exactly: an unclosed quote, a trailing backslash, or a substitution (`…`, $(…), ${…}) whose words
319
+ * only running it would tell. Nothing is expanded or run.
320
+ */
321
+ export function shellWords(text: string): string[] | null {
322
+ const words: string[] = [];
323
+ let cur = '';
324
+ let inWord = false;
325
+ const substitution = (i: number) => text[i] === '`' || (text[i] === '$' && (text[i + 1] === '(' || text[i + 1] === '{'));
326
+ for (let i = 0; i < text.length; ) {
327
+ const ch = text[i];
328
+ if (/\s/.test(ch)) {
329
+ if (inWord) words.push(cur);
330
+ cur = '';
331
+ inWord = false;
332
+ i++;
333
+ continue;
334
+ }
335
+ inWord = true;
336
+ if (ch === "'") {
337
+ const end = text.indexOf("'", i + 1);
338
+ if (end < 0) return null;
339
+ cur += text.slice(i + 1, end);
340
+ i = end + 1;
341
+ } else if (ch === '"') {
342
+ for (i++; ; ) {
343
+ if (i >= text.length) return null;
344
+ if (text[i] === '"') {
345
+ i++;
346
+ break;
347
+ }
348
+ if (substitution(i)) return null;
349
+ if (text[i] === '\\' && i + 1 < text.length && '"\\$`\n'.includes(text[i + 1])) {
350
+ cur += text[i + 1];
351
+ i += 2;
352
+ } else cur += text[i++];
353
+ }
354
+ } else if (ch === '\\') {
355
+ if (i + 1 >= text.length) return null;
356
+ cur += text[i + 1];
357
+ i += 2;
358
+ } else if (substitution(i)) return null;
359
+ else {
360
+ cur += ch;
361
+ i++;
362
+ }
363
+ }
364
+ if (inWord) words.push(cur);
365
+ return words;
366
+ }
367
+
368
+ /** A name, then : or = and a value, as JSON, YAML, a header or an assignment writes it (quoted or not). */
369
+ const KEYED = /([A-Za-z_][\w.-]*)["']?\s*[:=]\s*["']?([^\s"',;}\]]+)/g;
370
+
371
+ /** The first name of a credential that `text` gives a value to in any such form, or null. */
372
+ function keyedCredential(text: string): string | null {
373
+ for (const m of text.matchAll(KEYED)) if (secretName(m[1]) && m[2] !== HELD && !m[2].startsWith('//')) return m[1];
374
+ return null;
375
+ }
376
+
377
+ /** Arguments inside arguments (an `sh -c` line quoting another) are read this many levels deep, then left out. */
378
+ const MAX_NESTING = 4;
379
+
380
+ /**
381
+ * One argument (HELD where LEFT_OUT stood) with its credentials replaced. `after` names what it is when the
382
+ * argument before it said it is a credential: then all of it goes. A header line or NAME=value naming a credential
383
+ * loses its whole value, however many words; a plain word is read as cleanWord reads it; anything else is shell text,
384
+ * read word by word with shellWords(), and goes whole if any word of it is a credential or it can't be read exactly
385
+ * (a credential is never cut out of text whose quoting would have to be guessed).
386
+ */
387
+ function cleanArg(a: string, after: string | null, depth = 0): { text: string; why: string[] } {
388
+ if (a === HELD) return { text: a, why: [] };
389
+ if (after) return { text: HELD, why: [after] };
390
+ const header = /^([A-Za-z][\w-]*):(?!\/\/)(\s*)([\s\S]+)$/.exec(a);
391
+ if (header && secretName(header[1])) return header[3] === HELD ? { text: a, why: [] } : { text: `${header[1]}:${header[2]}${HELD}`, why: [`the value of ${header[1]}`] };
392
+ const eq = /^(-{0,2}[A-Za-z_][\w.-]*)=([\s\S]*)$/.exec(a);
393
+ if (eq) {
394
+ if (!eq[2] || eq[2] === HELD) return { text: a, why: [] };
395
+ if (secretName(eq[1])) return { text: `${eq[1]}=${HELD}`, why: [`the value of ${eq[1]}`] };
396
+ const v = cleanArg(eq[2], null, depth + 1);
397
+ return { text: `${eq[1]}=${v.text}`, why: v.why };
398
+ }
399
+ if (/^[{[]/.test(a.trim())) {
400
+ try {
401
+ const inspect = (value: unknown, level = 0): string | null => {
402
+ if (level > 64) throw new Error('JSON nesting exceeds the review bound');
403
+ if (typeof value === 'string') {
404
+ const word = cleanWord(value, undefined);
405
+ return word.why ?? keyedCredential(value);
406
+ }
407
+ if (!value || typeof value !== 'object') return null;
408
+ for (const [key, item] of Object.entries(value)) {
409
+ if (secretName(key) && item !== null && item !== '' && item !== HELD && item !== LEFT_OUT) return 'a credential field';
410
+ const found = inspect(item, level + 1);
411
+ if (found) return found;
412
+ }
413
+ return null;
414
+ };
415
+ const parsed = JSON.parse(a);
416
+ // JSON.parse keeps only the last duplicate. Inspect the decoded names in the source too, so no discarded
417
+ // property can hide a credential in the argument that would be exported unchanged.
418
+ const tokens = a.match(/"(?:\\[\s\S]|[^"\\])*"|[{}\[\]:]/g) ?? [];
419
+ const objects: (Set<string> | null)[] = [];
420
+ for (let i = 0; i < tokens.length; i++) {
421
+ const token = tokens[i];
422
+ if (token === '{') objects.push(new Set());
423
+ else if (token === '[') objects.push(null);
424
+ else if (token === '}' || token === ']') objects.pop();
425
+ else if (token.startsWith('"') && tokens[i + 1] === ':') {
426
+ const names = objects.at(-1);
427
+ const name = JSON.parse(token) as string;
428
+ if (names?.has(name)) return { text: HELD, why: ['duplicate decoded JSON fields, so all of it'] };
429
+ names?.add(name);
430
+ }
431
+ }
432
+ const found = inspect(parsed);
433
+ return found ? { text: HELD, why: ['a credential in JSON, so all of it'] } : { text: a, why: [] };
434
+ } catch {
435
+ return { text: HELD, why: ["JSON this export can't read safely, so all of it"] };
436
+ }
437
+ }
438
+ if (!NOT_BARE.test(a)) {
439
+ const w = cleanWord(a, undefined);
440
+ if (w.why) return { text: w.word, why: [w.why] };
441
+ const named = keyedCredential(w.word);
442
+ return named ? { text: HELD, why: [`the value of ${named}, in structured text`] } : { text: w.word, why: [] };
443
+ }
444
+ if (depth >= MAX_NESTING) return { text: HELD, why: ['arguments nested too deep to read'] };
445
+ const words = shellWords(a);
446
+ if (!words) return { text: HELD, why: ["shell text this export can't read exactly, so all of it"] };
447
+ const why: string[] = [];
448
+ let next: string | null = null;
449
+ for (const w of words) {
450
+ why.push(...cleanArg(w, next, depth + 1).why);
451
+ next = namesNext(w);
452
+ }
453
+ const named = why.length ? null : keyedCredential(a);
454
+ if (named) why.push(`the value of ${named}, in structured text`);
455
+ if (!why.length) return { text: a, why: [] };
456
+ const each = [...new Set(why.map((x) => x.replace(/, in quoted text: all of it$/, '')))];
457
+ return { text: HELD, why: [`${each.join('; ')}, in quoted text: all of it`] };
458
+ }
459
+
460
+ /** The word an argument ends with, as the next argument's `after` reads it. */
461
+ const lastWord = (a: string): string => (NOT_BARE.test(a) ? (shellWords(a)?.at(-1) ?? '') : a);
462
+
463
+ const held = (text: string) => text.split(LEFT_OUT).join(HELD);
464
+ const shown = (text: string) => text.split(HELD).join(LEFT_OUT);
465
+
466
+ /** A command (one argument) with its credentials replaced, as cleanArgs() reads an argument. */
467
+ export function cleanCommand(command: string): { text: string; why: string[] } {
468
+ const c = cleanArg(held(command), null);
469
+ return { text: shown(c.text), why: c.why };
470
+ }
471
+
472
+ /** A connector's arguments with every credential in them replaced by LEFT_OUT, and where each was. Each argument stays one. */
473
+ export function cleanArgs(args: string[]): { args: string[]; leftOut: string[] } {
474
+ const leftOut: string[] = [];
475
+ const out = args.map((a, i) => {
476
+ // An argument after a credential's flag is its value, all of it, whatever it looks like (`--password -x` too).
477
+ const c = cleanArg(held(a), i > 0 ? namesNext(lastWord(held(args[i - 1]))) : null);
478
+ if (c.why.length) leftOut.push(`args[${i}] (${[...new Set(c.why)].join('; ')})`);
479
+ return shown(c.text);
480
+ });
481
+ return { args: out, leftOut };
482
+ }
483
+
484
+ /** Whether prose holds something that is a credential as written: token-shaped, NAME=value, a flag's value, a URL's user or secret query. */
485
+ export function proseHasCredential(text: string): boolean {
486
+ if (!text) return false;
487
+ if (credentialShaped(text)) return true;
488
+ let prev: string | undefined;
489
+ for (const w of text.split(/\s+/)) {
490
+ if (!w) continue;
491
+ const bare = w.replace(/^[("'`<\[]+|[)"'`>\].,;!?]+$/g, '');
492
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(bare)) {
493
+ if (cleanUrl(bare, false).why) return true;
494
+ } else if (/=/.test(bare) || prev !== undefined) {
495
+ const c = cleanWord(bare, prev);
496
+ // In prose, only a NAME=value with a value, or a flag's value, counts.
497
+ if (c.why && c.why !== 'it looked like a credential' && !/=$/.test(bare)) return true;
498
+ }
499
+ prev = /^-{1,2}[A-Za-z]/.test(bare) ? bare : undefined;
500
+ }
501
+ return false;
502
+ }
503
+
504
+ const bad = (where: string, why: string): never => {
505
+ throw new HttpError(400, `Not a configuration this server can import: ${where} ${why}`);
506
+ };
507
+
508
+ const sha = (text: string) => createHash('sha256').update(text).digest('hex');
509
+
510
+ // ------------------------------------------------------------------ reading
511
+
512
+ type J = Record<string, unknown>;
513
+
514
+ /** No forbidden key, no deeper than MAX_DEPTH, no more than MAX_NODES values: before anything else reads it. */
515
+ function scan(v: unknown): void {
516
+ let nodes = 0;
517
+ const visit = (x: unknown, depth: number, where: string) => {
518
+ if (++nodes > MAX_NODES) bad('the file', `has more than ${MAX_NODES} values`);
519
+ if (depth > MAX_DEPTH) bad(where, `is nested more than ${MAX_DEPTH} deep`);
520
+ // Every text in the file is whole Unicode, keys and list entries too: a lone surrogate would be stored as U+FFFD.
521
+ if (typeof x === 'string' && !x.isWellFormed()) bad(where, 'holds a broken character (a lone surrogate)');
522
+ if (Array.isArray(x)) x.forEach((y, i) => visit(y, depth + 1, `${where}[${i}]`));
523
+ else if (x !== null && typeof x === 'object') {
524
+ if (Object.getPrototypeOf(x) !== Object.prototype) bad(where, 'is not a plain object');
525
+ for (const k of Object.keys(x)) {
526
+ if (FORBIDDEN_KEYS.has(k)) bad(where, `has a key named ${k}`);
527
+ if (!k.isWellFormed()) bad(where, 'has a key with a broken character (a lone surrogate)');
528
+ visit((x as J)[k], depth + 1, `${where}.${k}`);
529
+ }
530
+ }
531
+ };
532
+ visit(v, 0, 'file');
533
+ }
534
+
535
+ /** An object with exactly `keys` (the format's fields at this version): unknown and missing fields refuse the file. */
536
+ function obj(v: unknown, where: string, keys: readonly string[]): J {
537
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return bad(where, 'must be an object');
538
+ for (const k of Object.keys(v)) if (!keys.includes(k)) bad(`${where}.${k}`, 'is not a field of this format version (was the file made by a newer Botons?)');
539
+ for (const k of keys) if (!(k in v)) bad(`${where}.${k}`, 'is missing');
540
+ return v as J;
541
+ }
542
+
543
+ function str(v: unknown, where: string, max: number, opts: { min?: number; re?: RegExp; oneLine?: boolean } = {}): string {
544
+ if (typeof v !== 'string') return bad(where, 'must be a string');
545
+ if (v.length > max) bad(where, `is longer than ${max} characters`);
546
+ if (v.length < (opts.min ?? 0)) bad(where, opts.min === 1 ? 'is empty' : `is shorter than ${opts.min} characters`);
547
+ if (v.includes('\0')) bad(where, 'holds a NUL character');
548
+ if (!v.isWellFormed()) bad(where, 'holds a broken character (a lone surrogate)');
549
+ if (opts.oneLine && /[\r\n]/.test(v)) bad(where, 'must be one line');
550
+ if (opts.re && !opts.re.test(v)) bad(where, 'is not of the expected form');
551
+ return v;
552
+ }
553
+
554
+ /** Trimmed as the server keeps it: a value with space around it was not written by an export. */
555
+ function text(v: unknown, where: string, max: number, required = false): string {
556
+ const s = str(v, where, max, { min: required ? 1 : 0 });
557
+ if (s !== s.trim()) bad(where, 'has space at its start or end');
558
+ return s;
559
+ }
560
+
561
+ /** A spending cap in US dollars as spend.ts keeps them (0 to MAX_CAP_USD, to its rounding), or null for none. */
562
+ function capUsd(v: unknown, where: string): number | null {
563
+ if (v === null) return null;
564
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > MAX_CAP_USD || round(v) !== v) return bad(where, `must be a number of US dollars from 0 to ${MAX_CAP_USD}, or null for no cap`);
565
+ return v;
566
+ }
567
+
568
+ function bool(v: unknown, where: string): boolean {
569
+ if (typeof v !== 'boolean') bad(where, 'must be true or false');
570
+ return v as boolean;
571
+ }
572
+
573
+ function list<T>(v: unknown, where: string, max: number, item: (x: unknown, where: string) => T): T[] {
574
+ if (!Array.isArray(v)) return bad(where, 'must be a list');
575
+ if (v.length > max) bad(where, `has more than ${max} entries`);
576
+ return v.map((x, i) => item(x, `${where}[${i}]`));
577
+ }
578
+
579
+ function unique(values: string[], where: string): void {
580
+ if (new Set(values).size !== values.length) bad(where, 'has an entry twice');
581
+ }
582
+
583
+ /** A server's checker, its 400 as the file's. */
584
+ function checked<T>(where: string, fn: () => T): T {
585
+ try {
586
+ return fn();
587
+ } catch (err) {
588
+ return bad(where, (err as Error).message);
589
+ }
590
+ }
591
+
592
+ function plainUrl(v: unknown, where: string, opts: { empty?: boolean } = {}): string {
593
+ const s = str(v, where, 2000, { oneLine: true });
594
+ if (!s && opts.empty) return s;
595
+ let u: URL;
596
+ try {
597
+ u = new URL(s);
598
+ } catch {
599
+ return bad(where, 'is not a URL');
600
+ }
601
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') bad(where, 'must be http:// or https://');
602
+ if (u.username || u.password) bad(where, 'holds credentials');
603
+ if (u.hash) bad(where, 'has a #fragment');
604
+ return s;
605
+ }
606
+
607
+ /** A relative path a package listed: never read, never written, only shown. */
608
+ function listedPath(v: unknown, where: string): string {
609
+ const s = str(v, where, 500, { min: 1 });
610
+ if (/[\u0000-\u001f\u007f\\]/.test(s) || s.startsWith('/') || /^[A-Za-z]:/.test(s) || s.split('/').some((p) => p === '..' || p === '.')) bad(where, 'is not a path inside a package');
611
+ return s;
612
+ }
613
+
614
+ function source(v: unknown, where: string): SkillSource | null {
615
+ if (v === null) return null;
616
+ const o = obj(v, where, ['kind', 'folder', 'label', 'installed', 'installedAt', 'fields', 'files', 'reviewed']);
617
+ if (o.kind !== 'folder') bad(`${where}.kind`, 'must be "folder"');
618
+ const fields = obj(o.fields, `${where}.fields`, Object.keys((o.fields as J) ?? {}));
619
+ if (Object.keys(fields).length > 50) bad(`${where}.fields`, 'has more than 50 fields');
620
+ for (const [k, x] of Object.entries(fields)) {
621
+ if (!FIELD_KEY.test(k)) bad(`${where}.fields`, `has a field named "${k.slice(0, 64)}"`);
622
+ str(x, `${where}.fields.${k}`, 1000);
623
+ }
624
+ return {
625
+ kind: 'folder',
626
+ // A skill package's folder is one name in the packages folder: no path, so it can't point anywhere else.
627
+ folder: str(o.folder, `${where}.folder`, 64, { re: SKILL_NAME }),
628
+ label: o.label === null ? null : str(o.label, `${where}.label`, 1000),
629
+ installed: str(o.installed, `${where}.installed`, 32, { re: HASH }),
630
+ installedAt: str(o.installedAt, `${where}.installedAt`, 40),
631
+ fields: fields as Record<string, string>,
632
+ files: list(o.files, `${where}.files`, 60, listedPath),
633
+ reviewed: str(o.reviewed, `${where}.reviewed`, 32, { re: HASH }),
634
+ };
635
+ }
636
+
637
+ const ROUTINE_KEYS = [
638
+ 'name',
639
+ 'instruction',
640
+ 'schedule',
641
+ 'timezone',
642
+ 'webhook',
643
+ 'notify',
644
+ 'webhookWorkspace',
645
+ 'webhookSource',
646
+ 'webhookFilter',
647
+ 'skill',
648
+ 'model',
649
+ 'effort',
650
+ 'session',
651
+ 'timeLimitMs',
652
+ ] as const;
653
+
654
+ /** Checked by the templates' own reader: a field it would read differently is not one an export wrote. */
655
+ type RoutineFields = Omit<TemplateRoutine, 'scheduleText'>;
656
+
657
+ function routineFields(v: unknown, where: string, zone: string, extra: readonly string[] = []): { r: RoutineFields; o: J } {
658
+ const o = obj(v, where, [...ROUTINE_KEYS, ...extra]);
659
+ const r = readRoutine(o);
660
+ if (!r) return bad(where, 'is not a routine: it needs a name of at most 80 characters and an instruction');
661
+ for (const k of Object.keys(r) as (keyof typeof r)[]) if (JSON.stringify(r[k]) !== JSON.stringify(o[k])) bad(`${where}.${k}`, 'is not a valid value');
662
+ str(r.name, `${where}.name`, 80);
663
+ str(r.instruction, `${where}.instruction`, 20_000);
664
+ if (r.schedule) checked(`${where}.schedule`, () => parseSchedule(r.schedule!, r.timezone ?? zone));
665
+ return { r, o };
666
+ }
667
+
668
+ function routine(v: unknown, where: string, botonZone: string): PortableRoutine {
669
+ const { r, o } = routineFields(v, where, botonZone, ['enabled', 'webhookSecretLeftOut', 'callbackLeftOut', 'perRunCapUsd']);
670
+ return {
671
+ ...r,
672
+ enabled: bool(o.enabled, `${where}.enabled`),
673
+ webhookSecretLeftOut: bool(o.webhookSecretLeftOut, `${where}.webhookSecretLeftOut`),
674
+ callbackLeftOut: bool(o.callbackLeftOut, `${where}.callbackLeftOut`),
675
+ perRunCapUsd: capUsd(o.perRunCapUsd, `${where}.perRunCapUsd`),
676
+ };
677
+ }
678
+
679
+ const LOOK_KEYS = ['shape', 'shell', 'glow', 'eyes', 'brows', 'mouth', 'top', 'ears', 'visor', 'panel', 'arms', 'backdrop', 'cheeks'] as const;
680
+ /** A look's fields at version 4 (a tater look), which the upgrade to 5 maps to its closest Boton. */
681
+ const TATER_KEYS = ['shape', 'toast', 'eyes', 'brows', 'mouth', 'hat', 'glasses', 'extra', 'arms', 'wear', 'backdrop', 'cheeks'] as const;
682
+ /** A look's fields at version 3 and before (a Critter), which the upgrade to 4 maps to its closest tater look. */
683
+ const CRITTER_KEYS = ['body', 'ears', 'eyes', 'mouth', 'marks', 'cheeks', 'color', 'backdrop'] as const;
684
+
685
+ /** A look with exactly its fields, each one this server draws (an unknown field refuses the file, never dropped). */
686
+ function look(v: unknown, where: string): BotonLook {
687
+ const l = parseLook(obj(v, where, LOOK_KEYS));
688
+ if (!l) return bad(where, 'is not a look this server draws');
689
+ return l;
690
+ }
691
+
692
+ export function connectionIds(v: unknown, where: string): string[] {
693
+ // The built-in browser is one more beside MAX_CONNECTIONS others (countedConnections).
694
+ const ids = list(v, where, MAX_CONNECTIONS + 1, (x, w) => {
695
+ const id = str(x, w, 50);
696
+ if (id !== BROWSER_CONNECTOR && !/^(lib|oauth):[a-z0-9][a-z0-9_-]{0,39}$/.test(id)) bad(w, 'is not a connector id');
697
+ return id;
698
+ });
699
+ unique(ids, where);
700
+ if (countedConnections(ids) > MAX_CONNECTIONS) bad(where, `has more than ${MAX_CONNECTIONS} connectors`);
701
+ return ids;
702
+ }
703
+
704
+ function skillNames(v: unknown, where: string): string[] | null {
705
+ if (v === null) return null;
706
+ const names = list(v, where, MAX_SKILLS, (x, w) => str(x, w, 64, { re: SKILL_NAME }));
707
+ unique(names, where);
708
+ return names;
709
+ }
710
+
711
+ const TEMPLATE_BOT_KEYS = ['name', 'title', 'instructions', 'look', 'provider', 'model', 'effort', 'access', 'group', 'timezone', 'connections', 'skills'] as const;
712
+
713
+ function template(v: unknown, where: string): PortableTemplate {
714
+ const o = obj(v, where, ['ref', 'name', 'description', 'bot', 'routines']);
715
+ const b = obj(o.bot, `${where}.bot`, TEMPLATE_BOT_KEYS);
716
+ if (b.access !== 'workspace' && b.access !== 'host') bad(`${where}.bot.access`, 'must be "workspace" or "host"');
717
+ const timezone = b.timezone === null ? null : str(b.timezone, `${where}.bot.timezone`, 80);
718
+ if (timezone !== null && !isValidTimezone(timezone)) bad(`${where}.bot.timezone`, 'is not a timezone this server knows');
719
+ return {
720
+ ref: str(o.ref, `${where}.ref`, 64, { re: REF }),
721
+ name: text(o.name, `${where}.name`, 60, true),
722
+ description: text(o.description, `${where}.description`, 300),
723
+ bot: {
724
+ name: text(b.name, `${where}.bot.name`, 60),
725
+ title: text(b.title, `${where}.bot.title`, 80),
726
+ instructions: text(b.instructions, `${where}.bot.instructions`, 20_000),
727
+ look: b.look === null ? null : look(b.look, `${where}.bot.look`),
728
+ provider: b.provider === null ? null : str(b.provider, `${where}.bot.provider`, 40, { re: PROVIDER }),
729
+ model: text(b.model, `${where}.bot.model`, 120),
730
+ effort: text(b.effort, `${where}.bot.effort`, 24),
731
+ access: b.access as AccessLevel,
732
+ group: text(b.group, `${where}.bot.group`, 40),
733
+ timezone,
734
+ connections: connectionIds(b.connections, `${where}.bot.connections`),
735
+ skills: skillNames(b.skills, `${where}.bot.skills`),
736
+ },
737
+ routines: list(o.routines, `${where}.routines`, MAX_ROUTINES_PER_BOT, (x, w) => routineFields(x, w, timezone ?? 'UTC').r),
738
+ };
739
+ }
740
+
741
+ const BOTON_KEYS = ['ref', 'name', 'title', 'instructions', 'look', 'provider', 'model', 'effort', 'access', 'timezone', 'paused', 'connections', 'skills', 'askFirst', 'alwaysAllow', 'browserSites', 'dailyCapUsd', 'routines'] as const;
742
+
743
+ function boton(v: unknown, where: string): PortableBoton {
744
+ const o = obj(v, where, BOTON_KEYS);
745
+ const botonLook = look(o.look, `${where}.look`);
746
+ if (o.access !== 'workspace' && o.access !== 'host') bad(`${where}.access`, 'must be "workspace" or "host"');
747
+ const timezone = str(o.timezone, `${where}.timezone`, 80);
748
+ if (!isValidTimezone(timezone)) bad(`${where}.timezone`, 'is not a timezone this server knows');
749
+ const connections = connectionIds(o.connections, `${where}.connections`);
750
+ const skills = skillNames(o.skills, `${where}.skills`);
751
+ return {
752
+ ref: str(o.ref, `${where}.ref`, 64, { re: REF }),
753
+ name: text(o.name, `${where}.name`, 60, true),
754
+ title: text(o.title, `${where}.title`, 80),
755
+ instructions: text(o.instructions, `${where}.instructions`, 20_000),
756
+ look: botonLook,
757
+ provider: str(o.provider, `${where}.provider`, 40, { re: PROVIDER }),
758
+ model: text(o.model, `${where}.model`, 120),
759
+ effort: text(o.effort, `${where}.effort`, 24),
760
+ access: o.access as AccessLevel,
761
+ timezone,
762
+ paused: bool(o.paused, `${where}.paused`),
763
+ connections,
764
+ skills,
765
+ askFirst: checked(`${where}.askFirst`, () => askFirstList(o.askFirst)),
766
+ alwaysAllow: checked(`${where}.alwaysAllow`, () => alwaysAllowList(o.alwaysAllow)),
767
+ browserSites: list(o.browserSites, `${where}.browserSites`, MAX_SITES, (x, w) => str(x, w, 300, { min: 1, oneLine: true })),
768
+ dailyCapUsd: capUsd(o.dailyCapUsd, `${where}.dailyCapUsd`),
769
+ routines: list(o.routines, `${where}.routines`, MAX_ROUTINES_PER_BOT, (x, w) => routine(x, w, timezone)),
770
+ };
771
+ }
772
+
773
+ function author(v: unknown, where: string, refs: Set<string>): PortableSkill['author'] {
774
+ const kind = v && typeof v === 'object' ? (v as J).kind : undefined;
775
+ if (kind === 'owner') return obj(v, where, ['kind']) && { kind: 'owner' };
776
+ if (kind !== 'boton') return bad(`${where}.kind`, 'must be "owner" or "boton"');
777
+ const o = obj(v, where, ['kind', 'ref', 'name']);
778
+ const ref = o.ref === null ? null : str(o.ref, `${where}.ref`, 64, { re: REF });
779
+ if (ref !== null && !refs.has(ref)) bad(`${where}.ref`, 'names no Boton in the file');
780
+ return { kind: 'boton', ref, name: text(o.name, `${where}.name`, 60, true) };
781
+ }
782
+
783
+ function skill(v: unknown, where: string, refs: Set<string>): PortableSkill {
784
+ const o = obj(v, where, ['name', 'description', 'body', 'disabled', 'author', 'source', 'history']);
785
+ return {
786
+ name: str(o.name, `${where}.name`, 64, { re: SKILL_NAME }),
787
+ description: text(o.description, `${where}.description`, 1024, true),
788
+ body: text(o.body, `${where}.body`, 50_000, true),
789
+ disabled: bool(o.disabled, `${where}.disabled`),
790
+ author: author(o.author, `${where}.author`, refs),
791
+ source: source(o.source, `${where}.source`),
792
+ history: list(o.history, `${where}.history`, SKILL_HISTORY_KEEP, (x, w) => {
793
+ const h = obj(x, w, ['description', 'body', 'source', 'replacedAt']);
794
+ return { description: text(h.description, `${w}.description`, 1024, true), body: text(h.body, `${w}.body`, 50_000, true), source: source(h.source, `${w}.source`), replacedAt: str(h.replacedAt, `${w}.replacedAt`, 40, { min: 1 }) };
795
+ }),
796
+ };
797
+ }
798
+
799
+ function connector(v: unknown, where: string): PortableConnector {
800
+ const kind = v && typeof v === 'object' ? (v as J).kind : undefined;
801
+ const base = ['id', 'kind', 'name', 'onRequest', 'grant', 'enabled', 'leftOut'];
802
+ const o = kind === 'library' ? obj(v, where, [...base, 'transport', 'command', 'args', 'url', 'envKeys', 'headerKeys']) : kind === 'oauth' ? obj(v, where, [...base, 'url', 'keepConnected', 'unknownJobs']) : bad(`${where}.kind`, 'must be "library" or "oauth"');
803
+ const name = str(o.name, `${where}.name`, 40, { re: CONNECTOR_NAME });
804
+ if (o.id !== `${kind === 'library' ? 'lib' : 'oauth'}:${name}`) bad(`${where}.id`, 'does not match its kind and name');
805
+ if (o.grant !== 'approval' && o.grant !== 'immediate') bad(`${where}.grant`, 'must be "approval" or "immediate"');
806
+ const common = {
807
+ id: o.id as string,
808
+ name,
809
+ onRequest: bool(o.onRequest, `${where}.onRequest`),
810
+ grant: o.grant as ConnectorGrant,
811
+ enabled: bool(o.enabled, `${where}.enabled`),
812
+ leftOut: list(o.leftOut, `${where}.leftOut`, 60, (x, w) => str(x, w, 200)),
813
+ };
814
+ if (kind === 'oauth') {
815
+ if (o.unknownJobs !== 'nowhere' && o.unknownJobs !== 'primary') bad(`${where}.unknownJobs`, 'must be "nowhere" or "primary"');
816
+ return { ...common, kind: 'oauth', url: plainUrl(o.url, `${where}.url`), keepConnected: bool(o.keepConnected, `${where}.keepConnected`), unknownJobs: o.unknownJobs as 'nowhere' | 'primary' };
817
+ }
818
+ // The browser Botons runs itself is never a library entry: no file can give it a command.
819
+ if (name === BROWSER_SERVER) bad(`${where}.name`, `is the name of the browser Botons runs itself`);
820
+ if (o.transport !== 'stdio' && o.transport !== 'http') bad(`${where}.transport`, 'must be "stdio" or "http"');
821
+ const envKeys = list(o.envKeys, `${where}.envKeys`, 20, (x, w) => {
822
+ const k = str(x, w, 64, { re: ENV_KEY });
823
+ if (/^(BOTONS?|BOTONS?)_|^BOTON/.test(k) || k.startsWith('LC_')) bad(w, 'is reserved');
824
+ return k;
825
+ });
826
+ const headerKeys = list(o.headerKeys, `${where}.headerKeys`, 20, (x, w) => (tokenShapedName(str(x, w, 64, { re: HEADER_KEY })) ? bad(w, 'looks like a token, not a header name') : (x as string)));
827
+ unique(envKeys, `${where}.envKeys`);
828
+ unique(headerKeys, `${where}.headerKeys`);
829
+ const stdio = o.transport === 'stdio';
830
+ const out = {
831
+ ...common,
832
+ kind: 'library' as const,
833
+ transport: o.transport as 'stdio' | 'http',
834
+ command: str(o.command, `${where}.command`, 500, { min: stdio ? 1 : 0, oneLine: true }),
835
+ args: list(o.args, `${where}.args`, 50, (x, w) => str(x, w, 2000)),
836
+ url: plainUrl(o.url, `${where}.url`, { empty: stdio }),
837
+ envKeys,
838
+ headerKeys,
839
+ };
840
+ if (stdio ? out.url || out.headerKeys.length : out.command || out.args.length || out.envKeys.length) bad(where, `mixes a ${out.transport} server's fields with the other kind's`);
841
+ if (!stdio && !out.url) bad(`${where}.url`, 'is missing');
842
+ return out;
843
+ }
844
+
845
+ /** The setting as the server checks it, and exactly as it keeps it: each emoji once, as written. */
846
+ function reactionList(v: unknown): string[] {
847
+ const out = checked('settings.reactionFollowUps', () => followUpsInput(v));
848
+ if (JSON.stringify(out) !== JSON.stringify(v)) bad('settings.reactionFollowUps', 'is not the list as this server keeps it (each emoji once)');
849
+ return out;
850
+ }
851
+
852
+ function settings(v: unknown, refs: Set<string>): PortableSettings {
853
+ const o = obj(v, 'settings', ['ownerName', 'timezone', 'primaryBoton', 'ntfy', 'reactionFollowUps']);
854
+ const n = obj(o.ntfy, 'settings.ntfy', ['enabled', 'server', 'topic', 'preview', 'tokenLeftOut']);
855
+ const timezone = str(o.timezone, 'settings.timezone', 80);
856
+ if (!isValidTimezone(timezone)) bad('settings.timezone', 'is not a timezone this server knows');
857
+ const primary = o.primaryBoton === null ? null : str(o.primaryBoton, 'settings.primaryBoton', 64, { re: REF });
858
+ if (primary !== null && !refs.has(primary)) bad('settings.primaryBoton', 'names no Boton in the file');
859
+ const server = plainUrl(n.server, 'settings.ntfy.server', { empty: true });
860
+ if (server && new URL(server).search) bad('settings.ntfy.server', 'has a query');
861
+ const topic = str(n.topic, 'settings.ntfy.topic', 64);
862
+ if (topic && !NTFY_TOPIC.test(topic)) bad('settings.ntfy.topic', 'may use only letters, digits, - and _');
863
+ const enabled = bool(n.enabled, 'settings.ntfy.enabled');
864
+ if (enabled && (!server || !topic)) bad('settings.ntfy', 'is on without a server and topic');
865
+ return {
866
+ ownerName: text(o.ownerName, 'settings.ownerName', 80, true),
867
+ timezone,
868
+ primaryBoton: primary,
869
+ ntfy: { enabled, server, topic, preview: bool(n.preview, 'settings.ntfy.preview'), tokenLeftOut: bool(n.tokenLeftOut, 'settings.ntfy.tokenLeftOut') },
870
+ reactionFollowUps: o.reactionFollowUps === null ? null : reactionList(o.reactionFollowUps),
871
+ };
872
+ }
873
+
874
+ /**
875
+ * The sidebar with exactly its fields at every level (an unknown one refuses the file, never dropped), refs of
876
+ * Botons in the file, group names as the server keeps them (trimmed, unique in any case), and nothing the
877
+ * server's own reader (sidebarTransfer) would repair: what it returns must be what the file says.
878
+ */
879
+ function sidebar(v: unknown, refs: Set<string>): SidebarTransfer {
880
+ const o = obj(v, 'sidebar', ['format', 'version', 'pinned', 'groups', 'ungrouped']);
881
+ const section = (x: unknown, where: string, keys: readonly string[] = ['collapsed', 'botIds']) => {
882
+ const s = obj(x, where, keys);
883
+ const botIds = list(s.botIds, `${where}.botIds`, MAX_BOTONS, (id, w) => {
884
+ const ref = str(id, w, 64, { re: REF });
885
+ if (!refs.has(ref)) bad(w, `names ${ref}, which is no Boton in the file`);
886
+ return ref;
887
+ });
888
+ return { collapsed: bool(s.collapsed, `${where}.collapsed`), botIds };
889
+ };
890
+ const read: SidebarTransfer = {
891
+ format: o.format as SidebarTransfer['format'],
892
+ version: o.version as SidebarTransfer['version'],
893
+ pinned: section(o.pinned, 'sidebar.pinned'),
894
+ groups: list(o.groups, 'sidebar.groups', MAX_BOTONS, (g, w) => {
895
+ const x = obj(g, w, ['name', 'collapsed', 'botIds']);
896
+ return { name: text(x.name, `${w}.name`, 200, true), ...section(x, w, ['name', 'collapsed', 'botIds']) };
897
+ }),
898
+ ungrouped: section(o.ungrouped, 'sidebar.ungrouped'),
899
+ };
900
+ const kept = checked('sidebar:', () => sidebarTransfer(read));
901
+ if (JSON.stringify(kept) !== JSON.stringify(read)) bad('sidebar', 'is not a layout as this server keeps it');
902
+ return kept;
903
+ }
904
+
905
+ /** The file whole, at this server's version, or a 400 naming the first thing wrong with it. Reads nothing else. */
906
+ export function readPortable(input: unknown): { file: PortableConfig; upgradedFrom: number | null } {
907
+ scan(input);
908
+ if (!input || typeof input !== 'object' || Array.isArray(input)) bad('the file', 'must be a JSON object');
909
+ // A file exported under deprecated names reads with this server's names (shared/legacy-names.mjs).
910
+ let raw = currentPortable(input as J);
911
+ if (raw.format !== PORTABLE_FORMAT) bad('format', `must be "${PORTABLE_FORMAT}" (a Botons configuration export)`);
912
+ const version = raw.version;
913
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) return bad('version', 'must be a whole number from 1');
914
+ if (version > PORTABLE_VERSION) bad('version', `is ${version}, made by a newer Botons: this server reads up to version ${PORTABLE_VERSION}. Upgrade this server first.`);
915
+ for (let v = version; v < PORTABLE_VERSION; v++) {
916
+ const up = UPGRADES[v];
917
+ if (!up) bad('version', `${v} is not one this server can upgrade`);
918
+ raw = up(raw);
919
+ }
920
+ const o = obj(raw, 'file', ['format', 'version', 'exportedAt', 'source', 'settings', 'botons', 'skills', 'connectors', 'sidebar', 'templates']);
921
+ const src = obj(o.source, 'source', ['app', 'schema']);
922
+ if (typeof src.schema !== 'number' || !Number.isInteger(src.schema) || src.schema < 1) bad('source.schema', 'must be a whole number');
923
+ const botons = list(o.botons, 'botons', MAX_BOTONS, boton);
924
+ const refs = new Set(botons.map((m) => m.ref));
925
+ if (refs.size !== botons.length) bad('botons', 'has two Botons with the same ref');
926
+ const skills = list(o.skills, 'skills', MAX_SKILLS, (x, w) => skill(x, w, refs));
927
+ unique(
928
+ skills.map((s) => s.name),
929
+ 'skills (by name)',
930
+ );
931
+ const connectors = list(o.connectors, 'connectors', MAX_LIBRARY + MAX_OAUTH, connector);
932
+ unique(
933
+ connectors.map((c) => c.id),
934
+ 'connectors (by id)',
935
+ );
936
+ if (connectors.filter((c) => c.kind === 'oauth').length > MAX_OAUTH) bad('connectors', `has more than ${MAX_OAUTH} OAuth connectors`);
937
+ const layout = sidebar(o.sidebar, refs);
938
+ const templates = list(o.templates, 'templates', MAX_TEMPLATES, template);
939
+ unique(
940
+ templates.map((t) => t.ref),
941
+ 'templates (by ref)',
942
+ );
943
+ return {
944
+ file: {
945
+ format: PORTABLE_FORMAT,
946
+ version: PORTABLE_VERSION,
947
+ exportedAt: str(o.exportedAt, 'exportedAt', 40),
948
+ source: { app: str(src.app, 'source.app', 40), schema: src.schema as number },
949
+ settings: settings(o.settings, refs),
950
+ botons,
951
+ skills,
952
+ connectors,
953
+ sidebar: layout,
954
+ templates,
955
+ },
956
+ upgradedFrom: version === PORTABLE_VERSION ? null : version,
957
+ };
958
+ }
959
+
960
+ // ---------------------------------------------------------------- exporting
961
+
962
+ /** A connector's URL as its endpoint: without its user name and password, query and #fragment. */
963
+ function endpointUrl(url: string): { url: string; why: string | null } {
964
+ if (!url) return { url, why: null };
965
+ const c = cleanUrl(url, true);
966
+ return c.url === LEFT_OUT ? { url: '', why: 'its URL (it held a credential)' } : { url: c.url, why: c.why && `its URL's ${c.why.replace(/its /g, '')}` };
967
+ }
968
+
969
+ /**
970
+ * Where text in the file holds something shaped like a credential, by place (never the text itself): every text the
971
+ * file carries as written, each earlier text of a skill by its date, and connector arguments a hand-made file has.
972
+ */
973
+ export function textWarningsOf(file: PortableConfig): string[] {
974
+ const out: string[] = [];
975
+ const watch = (where: string, ...texts: (string | null | undefined)[]) => {
976
+ if (texts.some((t) => t && proseHasCredential(t))) out.push(where);
977
+ };
978
+ const sourceTexts = (src: SkillSource | null) => (src ? [src.label, ...Object.values(src.fields)] : []);
979
+ watch('settings: your name', file.settings.ownerName);
980
+ for (const m of file.botons) {
981
+ watch(`Boton ${m.name}: name and title`, m.name, m.title);
982
+ watch(`Boton ${m.name}: instructions`, m.instructions);
983
+ for (const r of m.routines) watch(`Boton ${m.name}: routine ${r.name}`, r.name, r.instruction, r.webhookFilter && JSON.stringify(r.webhookFilter));
984
+ }
985
+ for (const sk of file.skills) {
986
+ watch(`skill ${sk.name}: its current text`, sk.description, sk.body, ...sourceTexts(sk.source));
987
+ sk.history.forEach((h, i) => watch(`skill ${sk.name}: its earlier text ${i + 1} of ${sk.history.length}, replaced ${h.replacedAt}`, h.description, h.body, ...sourceTexts(h.source)));
988
+ }
989
+ for (const c of file.connectors) {
990
+ if (c.kind !== 'library') continue;
991
+ const args = cleanArgs(c.args);
992
+ if (args.args.some((a, i) => a !== c.args[i])) out.push(`connector ${c.id}: ${args.leftOut.map((x) => x.replace(/ \(.*$/, '')).join(', ')} (in the file as written)`);
993
+ if (cleanCommand(c.command).text !== c.command) out.push(`connector ${c.id}: its command (in the file as written)`);
994
+ }
995
+ for (const t of file.templates) {
996
+ watch(`template ${t.name}: its text`, t.name, t.description, t.bot.name, t.bot.title, t.bot.instructions);
997
+ for (const r of t.routines) watch(`template ${t.name}: routine ${r.name}`, r.name, r.instruction, r.webhookFilter && JSON.stringify(r.webhookFilter));
998
+ }
999
+ return out;
1000
+ }
1001
+
1002
+ /** How a schedule reads, in its own zone; it was checked as the file was read. */
1003
+ function scheduleText(schedule: string, zone: string): string {
1004
+ try {
1005
+ return describeSchedule(toSpec(parseSchedule(schedule, zone)), zone);
1006
+ } catch {
1007
+ return schedule;
1008
+ }
1009
+ }
1010
+
1011
+ const commandLine = (command: string, args: string[]) => [command, ...args].map((a) => (/^[\w@%+=:,./<>-]+$/.test(a) && !/\s/.test(a) ? a : JSON.stringify(a))).join(' ');
1012
+
1013
+ export interface PortableDeps {
1014
+ cfg: Config;
1015
+ store: Store;
1016
+ skills: Skills;
1017
+ connectors: Connectors;
1018
+ oauth: OAuthConnections;
1019
+ browser: Pick<Browser, 'policy' | 'importSites'>;
1020
+ templates: Pick<Templates, 'savedTemplates' | 'stage'>;
1021
+ spending: Pick<Spending, 'view' | 'importCaps'>;
1022
+ providers: ProviderRegistry;
1023
+ app: string;
1024
+ schema: number;
1025
+ }
1026
+
1027
+ /** What import() acts on: the preview, and per Boton what it makes. */
1028
+ interface Plan {
1029
+ preview: PortableImportPreview;
1030
+ file: PortableConfig;
1031
+ botons: (PortableBotonPlan & { m: PortableBoton })[];
1032
+ /** The templates to make, by ref, as they will be saved (narrowed). */
1033
+ templates: { ref: string; entry: Pick<StoredTemplate, 'name' | 'description' | 'bot' | 'routines'> }[];
1034
+ ntfy: { on: boolean; dropToken: boolean };
1035
+ }
1036
+
1037
+ export class Portable {
1038
+ #d: PortableDeps;
1039
+
1040
+ constructor(deps: PortableDeps) {
1041
+ this.#d = deps;
1042
+ }
1043
+
1044
+ export(): PortableExport {
1045
+ const { store, skills, connectors, oauth, browser } = this.#d;
1046
+ const leftOut: string[] = [];
1047
+ const layout = store.exportSidebar();
1048
+ // Refs in the sidebar's order, so the file reads as the sidebar does; never the Botons' ids.
1049
+ const order = [...layout.pinned.botIds, ...layout.groups.flatMap((g) => g.botIds), ...layout.ungrouped.botIds];
1050
+ const bots = store.listBots().sort((a, b) => {
1051
+ const ia = order.indexOf(a.id);
1052
+ const ib = order.indexOf(b.id);
1053
+ return (ia < 0 ? Infinity : ia) - (ib < 0 ? Infinity : ib) || a.createdAt.localeCompare(b.createdAt);
1054
+ });
1055
+ const refs = new Map(bots.map((b, i) => [b.id, `m${i + 1}`]));
1056
+ const botons: PortableBoton[] = bots.map((b) => {
1057
+ const where = `Boton ${b.name}`;
1058
+ const routines = store.listRoutines(b.id).filter((r) => !(r.schedule && isPastOneOff(r.schedule) && !r.webhook));
1059
+ // Its caps, never its spending: the account of requests stays on this server (and in its backups).
1060
+ const caps = this.#d.spending.view(b.id);
1061
+ const runCap = new Map(caps.routines.map((x) => [x.id, x.perRunCapUsd]));
1062
+ return {
1063
+ ref: refs.get(b.id)!,
1064
+ name: b.name,
1065
+ title: b.title,
1066
+ instructions: b.instructions,
1067
+ look: b.look,
1068
+ provider: b.provider,
1069
+ model: b.model,
1070
+ effort: b.effort,
1071
+ access: b.access,
1072
+ timezone: b.timezone,
1073
+ paused: b.paused,
1074
+ connections: b.connections,
1075
+ skills: b.skills,
1076
+ askFirst: b.askFirst,
1077
+ alwaysAllow: b.alwaysAllow,
1078
+ browserSites: browser.policy(b.id).sites,
1079
+ dailyCapUsd: caps.dailyCapUsd,
1080
+ routines: routines.map((r) => {
1081
+ if (r.webhook) leftOut.push(`${where}: routine ${r.name}'s webhook key${r.webhook.hasSecret ? ', signing secret' : ''}${r.webhook.callbackUrl ? ' and callback URL' : ''}`);
1082
+ return { ...templateRoutine(r, b), enabled: r.enabled, webhookSecretLeftOut: !!r.webhook?.hasSecret, callbackLeftOut: !!r.webhook?.callbackUrl, perRunCapUsd: runCap.get(r.id) ?? null };
1083
+ }),
1084
+ };
1085
+ });
1086
+ const portableSkills: PortableSkill[] = skills.list().map((s) => {
1087
+ const by = s.updatedBy;
1088
+ return {
1089
+ name: s.name,
1090
+ description: s.description,
1091
+ body: s.body,
1092
+ disabled: s.disabled,
1093
+ author: by.kind === 'owner' ? { kind: 'owner' } : { kind: 'boton', ref: refs.get(by.botId) ?? null, name: by.name },
1094
+ source: s.source,
1095
+ history: skills.history(s.name).map((h) => ({ description: h.description, body: h.body, source: h.source, replacedAt: h.replacedAt })),
1096
+ };
1097
+ });
1098
+ const policy = (id: string) => {
1099
+ const p = connectors.policyOf(id);
1100
+ return { onRequest: p.onRequest, grant: p.grant, enabled: !connectors.isOff(id) };
1101
+ };
1102
+ const portableConnectors: PortableConnector[] = [];
1103
+ for (const s of connectors.listServers()) {
1104
+ const id = `lib:${s.name}`;
1105
+ const out: string[] = [];
1106
+ const url = endpointUrl(s.url);
1107
+ if (url.why) out.push(url.why);
1108
+ const args = cleanArgs(s.args);
1109
+ out.push(...args.leftOut);
1110
+ let command = s.command;
1111
+ const cmd = cleanCommand(command);
1112
+ if (cmd.text !== command) {
1113
+ command = LEFT_OUT;
1114
+ out.push(`its command (${[...new Set(cmd.why)].join('; ')})`);
1115
+ }
1116
+ if (s.envKeys.length) out.push(`env values (${s.envKeys.join(', ')})`);
1117
+ if (s.headerKeys.length) out.push(`header values (${s.headerKeys.join(', ')})`);
1118
+ for (const x of out) leftOut.push(`connector ${id}: ${x}`);
1119
+ portableConnectors.push({ id, kind: 'library', name: s.name, ...policy(id), leftOut: out, transport: s.transport, command, args: args.args, url: url.url, envKeys: s.envKeys, headerKeys: s.headerKeys });
1120
+ }
1121
+ const shown = new Map(oauth.list().map((c) => [c.name, c]));
1122
+ for (const rec of oauth.records()) {
1123
+ const id = `oauth:${rec.name}`;
1124
+ const c = shown.get(rec.name)!;
1125
+ const url = endpointUrl(rec.url);
1126
+ const out = ['its sign-in and tokens', ...(url.why ? [url.why] : [])];
1127
+ for (const x of out) leftOut.push(`connector ${id}: ${x}`);
1128
+ portableConnectors.push({ id, kind: 'oauth', name: rec.name, ...policy(id), leftOut: out, url: url.url, keepConnected: c.keepConnected, unknownJobs: c.unknownJobs });
1129
+ }
1130
+ const s = store.getSettings();
1131
+ if (s.ntfy.hasToken) leftOut.push('settings: the ntfy token');
1132
+ const ref = (ids: string[]) => ids.map((id) => refs.get(id)!).filter(Boolean);
1133
+ const sidebar: SidebarTransfer = {
1134
+ format: layout.format,
1135
+ version: layout.version,
1136
+ pinned: { collapsed: layout.pinned.collapsed, botIds: ref(layout.pinned.botIds) },
1137
+ groups: layout.groups.map((g) => ({ name: g.name, collapsed: g.collapsed, botIds: ref(g.botIds) })),
1138
+ ungrouped: { collapsed: layout.ungrouped.collapsed, botIds: ref(layout.ungrouped.botIds) },
1139
+ };
1140
+ const file: PortableConfig = {
1141
+ format: PORTABLE_FORMAT,
1142
+ version: PORTABLE_VERSION,
1143
+ exportedAt: nowIso(),
1144
+ source: { app: this.#d.app, schema: this.#d.schema },
1145
+ settings: {
1146
+ ownerName: s.ownerName,
1147
+ timezone: s.timezone,
1148
+ primaryBoton: s.primaryBotId ? (refs.get(s.primaryBotId) ?? null) : null,
1149
+ ntfy: { enabled: s.ntfy.enabled, server: s.ntfy.server, topic: s.ntfy.topic, preview: s.ntfy.preview, tokenLeftOut: s.ntfy.hasToken },
1150
+ reactionFollowUps: s.reactionFollowUps,
1151
+ },
1152
+ botons,
1153
+ skills: portableSkills,
1154
+ connectors: portableConnectors,
1155
+ sidebar,
1156
+ // Saved templates hold no secret (never webhook keys, signing secrets or callback URLs): as saved, by ref.
1157
+ templates: [],
1158
+ };
1159
+ // Each as an import would read it: one this format can't carry exactly is named and left out, not changed.
1160
+ for (const t of this.#d.templates.savedTemplates()) {
1161
+ const entry = { ref: `t${file.templates.length + 1}`, name: t.name, description: t.description, bot: t.bot, routines: t.routines };
1162
+ try {
1163
+ file.templates.push(template(JSON.parse(JSON.stringify(entry)), `template ${t.name}`));
1164
+ } catch (err) {
1165
+ leftOut.push(`template ${t.name}: all of it (${(err as Error).message.replace(/^Not a configuration this server can import: /, '')})`);
1166
+ }
1167
+ }
1168
+ log.info('configuration exported', { botons: botons.length, skills: portableSkills.length, connectors: portableConnectors.length, templates: file.templates.length });
1169
+ return { file, leftOut: [...NEVER_EXPORTED.map((x) => `never in the file: ${x}`), ...leftOut], textWarnings: textWarningsOf(file) };
1170
+ }
1171
+
1172
+ // ---------------------------------------------------------------- importing
1173
+
1174
+ async preview(input: PortableImportInput): Promise<PortableImportPreview> {
1175
+ return (await this.#plan(input)).preview;
1176
+ }
1177
+
1178
+ async #plan(input: PortableImportInput): Promise<Plan> {
1179
+ if (!input || typeof input !== 'object') throw new HttpError(400, 'Send { file, choices }');
1180
+ const { file, upgradedFrom } = readPortable(input.file);
1181
+ const { store, skills, connectors, oauth, providers, cfg } = this.#d;
1182
+ const choices = input.choices ?? {};
1183
+ if (typeof choices !== 'object' || choices === null || Array.isArray(choices)) throw new HttpError(400, 'choices must be an object');
1184
+ const known = (o: unknown, what: string, names: Set<string>) => {
1185
+ if (o === undefined) return {} as Record<string, unknown>;
1186
+ if (!o || typeof o !== 'object' || Array.isArray(o)) throw new HttpError(400, `choices.${what} must be an object`);
1187
+ for (const k of Object.keys(o)) if (!names.has(k)) throw new HttpError(400, `choices.${what}: ${k.slice(0, 64)} is not in the file`);
1188
+ return o as Record<string, unknown>;
1189
+ };
1190
+ const botonChoices = known(choices.botons, 'botons', new Set(file.botons.map((m) => m.ref)));
1191
+ const skillChoices = known(choices.skills, 'skills', new Set(file.skills.map((s) => s.name)));
1192
+ const connectorChoices = known(choices.connectors, 'connectors', new Set(file.connectors.map((c) => c.id)));
1193
+ const templateChoices = known(choices.templates, 'templates', new Set(file.templates.map((t) => t.ref)));
1194
+
1195
+ const bots = store.listBots();
1196
+ const fresh = bots.length === 0;
1197
+ const botNames = new Set(bots.map((b) => b.name.toLowerCase()));
1198
+ const catalog = connectors.catalog().connectors;
1199
+ const hostConnectors = new Set(catalog.map((c) => c.id));
1200
+ const liveHere = new Set(catalog.filter((c) => c.enabled && (c.status === 'ok' || c.managed)).map((c) => c.id));
1201
+ const hostSkills = new Map(skills.list().map((s) => [s.name, s]));
1202
+ const providerIds = [...providers.ids()].sort();
1203
+ const defaultProvider = await providers.defaultProvider();
1204
+ const reconnect: PortableReconnect[] = [];
1205
+
1206
+ // Connectors: one here by the same id is kept (the file's is shown, not applied); the rest are made, off.
1207
+ const connectorPlans = file.connectors.map((c) => {
1208
+ const exists = hostConnectors.has(c.id);
1209
+ const chosen = connectorChoices[c.id];
1210
+ const action = (chosen ?? (exists ? 'keep' : 'create')) as PortableConnectorAction;
1211
+ if (!(exists ? ['keep', 'skip'] : ['create', 'skip']).includes(action)) throw new HttpError(400, `choices.connectors.${c.id} must be ${exists ? '"keep" or "skip" (one is here already)' : '"create" or "skip"'}`);
1212
+ if (action === 'create' && c.kind === 'oauth') {
1213
+ const refused = refusedOutbound(c.url, cfg.oauth.allow);
1214
+ if (refused) throw new HttpError(400, `${c.id} can't be made here: Botons won't connect to its URL (${refused})`);
1215
+ }
1216
+ const secrets = c.kind === 'library' ? [...c.envKeys.map((k) => `env ${k}`), ...c.headerKeys.map((k) => `header ${k}`)] : ['a sign-in'];
1217
+ return {
1218
+ id: c.id,
1219
+ kind: c.kind,
1220
+ name: c.name,
1221
+ action,
1222
+ exists,
1223
+ target: c.kind === 'library' ? (c.transport === 'stdio' ? commandLine(c.command, c.args) : c.url) : c.url,
1224
+ runsCommand: c.kind === 'library' && c.transport === 'stdio',
1225
+ secrets,
1226
+ leftOut: c.leftOut,
1227
+ onRequest: c.onRequest,
1228
+ grant: c.grant,
1229
+ };
1230
+ });
1231
+ const oauthMade = connectorPlans.filter((c) => c.kind === 'oauth' && c.action === 'create').length;
1232
+ if (oauthMade && oauth.list().length + oauthMade > MAX_OAUTH) throw new HttpError(400, `This server can have at most ${MAX_OAUTH} OAuth connectors: skip some of the file's`);
1233
+ for (const c of connectorPlans.filter((x) => x.action === 'create')) {
1234
+ if (c.kind === 'oauth') reconnect.push({ kind: 'connector-sign-in', target: c.id, text: `Sign ${c.name} in: Connect it in Settings → Connectors.` });
1235
+ else if (c.secrets.length) reconnect.push({ kind: 'connector-secret', target: c.id, text: `Enter ${c.name}'s ${c.secrets.join(', ')} in Settings → Connectors.` });
1236
+ // Its secrets are named above; anything else the export took out of it is put back by hand too.
1237
+ const rest = c.leftOut.filter((x) => !x.startsWith('env values') && !x.startsWith('header values') && x !== 'its sign-in and tokens');
1238
+ if (rest.length) reconnect.push({ kind: 'connector-left-out', target: c.id, text: `Put back what the export left out of ${c.name}: ${rest.join('; ')}.` });
1239
+ reconnect.push({ kind: 'connector-off', target: c.id, text: `${c.name} was imported turned off: check its setup${c.runsCommand ? ' (its command runs in the turns of Botons that have it)' : ''}, then turn it on in Settings → Connectors.` });
1240
+ }
1241
+ const usableConnectors = new Set([...hostConnectors, ...connectorPlans.filter((c) => c.action === 'create').map((c) => c.id)]);
1242
+ const fileConnector = new Map(connectorPlans.map((c) => [c.id, c]));
1243
+
1244
+ // Skills: made, put in place of the one here, or this server's kept.
1245
+ const skillPlans = file.skills.map((s) => {
1246
+ const here = hostSkills.get(s.name);
1247
+ const sameText = !!here && here.version === skillVersion(s.description, s.body);
1248
+ const chosen = skillChoices[s.name];
1249
+ const action = (chosen ?? (here ? 'keep' : 'create')) as PortableSkillAction;
1250
+ if (!(here ? ['replace', 'keep', 'skip'] : ['create', 'skip']).includes(action)) throw new HttpError(400, `choices.skills.${s.name} must be ${here ? '"replace", "keep" or "skip" (one is here already)' : '"create" or "skip"'}`);
1251
+ return {
1252
+ name: s.name,
1253
+ action,
1254
+ exists: !!here,
1255
+ sameText,
1256
+ disabled: action === 'keep' ? here!.disabled : s.disabled,
1257
+ pending: s.author.kind === 'boton',
1258
+ source: s.source ? `${s.source.folder}${s.source.label ? ` (${s.source.label})` : ''}` : null,
1259
+ history: s.history.length,
1260
+ asks: asksOf({ description: s.description, body: s.body, fields: s.source?.fields ?? {} }),
1261
+ };
1262
+ });
1263
+ const library = new Set([...hostSkills.keys(), ...skillPlans.filter((s) => s.action !== 'skip').map((s) => s.name)]);
1264
+ const fileSkill = new Map(file.skills.map((s) => [s.name, s]));
1265
+ const skillPlan = new Map(skillPlans.map((s) => [s.name, s]));
1266
+
1267
+ // Botons: one with a name in use here is left out unless the owner makes it anyway.
1268
+ const botonPlans = file.botons.map((m): PortableBotonPlan & { m: PortableBoton } => {
1269
+ const c = (botonChoices[m.ref] ?? {}) as Record<string, unknown>;
1270
+ if (typeof c !== 'object' || Array.isArray(c)) throw new HttpError(400, `choices.botons.${m.ref} must be an object`);
1271
+ for (const k of Object.keys(c)) if (!['action', 'provider', 'fullAccess', 'alwaysAllow', 'caps'].includes(k)) throw new HttpError(400, `choices.botons.${m.ref}.${k.slice(0, 40)} is not a choice`);
1272
+ const conflict = botNames.has(m.name.toLowerCase());
1273
+ const action = (c.action ?? (conflict ? 'skip' : 'create')) as PortableBotonAction;
1274
+ if (action !== 'create' && action !== 'skip') throw new HttpError(400, `choices.botons.${m.ref}.action must be "create" or "skip"`);
1275
+ const available = providerIds.includes(m.provider);
1276
+ const provider = (c.provider ?? (available ? m.provider : defaultProvider)) as string;
1277
+ if (typeof provider !== 'string' || !providerIds.includes(provider)) throw new HttpError(400, `choices.botons.${m.ref}.provider must be one of ${providerIds.join(', ')}`);
1278
+ if (c.fullAccess !== undefined && typeof c.fullAccess !== 'boolean') throw new HttpError(400, `choices.botons.${m.ref}.fullAccess must be true or false`);
1279
+ if (c.alwaysAllow !== undefined && typeof c.alwaysAllow !== 'boolean') throw new HttpError(400, `choices.botons.${m.ref}.alwaysAllow must be true or false`);
1280
+ // The file's caps by default; arriving without them is the owner's choice alone. No choice sets a higher one.
1281
+ const capsChoice = (c.caps ?? 'file') as PortableCapsChoice;
1282
+ if (capsChoice !== 'file' && capsChoice !== 'none') throw new HttpError(400, `choices.botons.${m.ref}.caps must be "file" or "none"`);
1283
+ const keepCaps = capsChoice === 'file';
1284
+ const same = provider === m.provider;
1285
+ const model = same ? m.model : '';
1286
+ const effort = m.effort && !effortProblem(provider, m.effort, store.effortCaps(provider, model)) ? m.effort : '';
1287
+ // Full computer access and Always allow rules are what the owner ticks, never what the file says alone.
1288
+ const access: AccessLevel = m.access === 'host' && c.fullAccess === true ? 'host' : 'workspace';
1289
+ const alwaysAllow = c.alwaysAllow === true ? m.alwaysAllow : [];
1290
+ const connections = { imported: [] as string[], live: [] as string[], dropped: [] as { id: string; why: string }[] };
1291
+ for (const id of m.connections) {
1292
+ if (usableConnectors.has(id)) {
1293
+ connections.imported.push(id);
1294
+ if (liveHere.has(id)) connections.live.push(id);
1295
+ }
1296
+ else if (id === BROWSER_CONNECTOR) connections.dropped.push({ id, why: 'this server has no managed browser' });
1297
+ else connections.dropped.push({ id, why: fileConnector.get(id) ? 'left out of this import' : 'not in the file and not on this server' });
1298
+ }
1299
+ const skillList = m.skills === null ? null : { imported: m.skills.filter((n) => library.has(n)), dropped: m.skills.filter((n) => !library.has(n)).map((name) => ({ name, why: skillPlan.has(name) ? 'left out of this import' : 'not in the file and not on this server' })) };
1300
+ const sites = connections.imported.includes(BROWSER_CONNECTOR) || m.browserSites.length ? effectiveSites(m.browserSites, access, cfg.browser.allowPrivate) : [];
1301
+ const has = (name: string) => skillList === null || skillList.imported.includes(name);
1302
+ const routines = m.routines.map((r) => {
1303
+ const own = r.skill ? fileSkill.get(r.skill) : undefined;
1304
+ const plan = r.skill ? skillPlan.get(r.skill) : undefined;
1305
+ const here = r.skill ? hostSkills.get(r.skill) : undefined;
1306
+ const fromFile = plan && plan.action !== 'skip' && plan.action !== 'keep';
1307
+ const disabled = fromFile ? own!.disabled : here?.disabled;
1308
+ const pendingFor = fromFile ? (own!.author.kind === 'boton' ? own!.author.ref : undefined) : here?.pending ? null : undefined;
1309
+ const skill = r.skill && library.has(r.skill) && has(r.skill) && !disabled && (pendingFor === undefined || pendingFor === m.ref) ? r.skill : null;
1310
+ let rModel = same ? r.model : null;
1311
+ let rEffort = r.effort && !effortProblem(provider, r.effort, store.effortCaps(provider, rModel || model)) ? r.effort : null;
1312
+ if (rModel && !rEffort && inheritedEffortProblem(provider, effort, store.effortCaps(provider, rModel))) {
1313
+ rModel = null;
1314
+ rEffort = r.effort && !effortProblem(provider, r.effort, store.effortCaps(provider, model)) ? r.effort : null;
1315
+ }
1316
+ return {
1317
+ name: r.name,
1318
+ perRunCap: { file: r.perRunCapUsd, imported: keepCaps ? r.perRunCapUsd : null },
1319
+ schedule: r.schedule ? scheduleText(r.schedule, r.timezone ?? m.timezone) : null,
1320
+ webhook: r.webhook,
1321
+ fullAccessWebhook: { file: r.webhook && !r.webhookWorkspace, imported: r.webhook && !r.webhookWorkspace && access === 'host' },
1322
+ wasEnabled: r.enabled,
1323
+ skill: { file: r.skill, imported: skill },
1324
+ model: { file: r.model, imported: rModel },
1325
+ effort: { file: r.effort, imported: rEffort },
1326
+ };
1327
+ });
1328
+ const daily = keepCaps ? m.dailyCapUsd : null;
1329
+ const priced = apiPriceKnown(provider, model);
1330
+ const caps = { choice: capsChoice, daily: { file: m.dailyCapUsd, imported: daily }, applies: priced !== null, unpriced: (daily !== null && priced === false) || routines.some((r) => (daily !== null || r.perRunCap.imported !== null) && apiPriceKnown(provider, r.model.imported ?? model) === false) };
1331
+ if (action === 'create') {
1332
+ if (!available) reconnect.push({ kind: 'brain', target: m.ref, text: `${m.name}'s brain (${m.provider}) isn't on this server: it was imported with ${provider}. Change it in its settings once that brain is set up.` });
1333
+ const on = routines.filter((r) => r.wasEnabled).length;
1334
+ if (on) reconnect.push({ kind: 'routine-off', target: m.ref, text: `${m.name}: ${on === 1 ? '1 routine was' : `${on} routines were`} on and arrived off. Turn ${on === 1 ? 'it' : 'them'} on in its Routines when you're ready.` });
1335
+ for (const r of m.routines.filter((x) => x.webhook)) {
1336
+ const extra = [r.webhookSecretLeftOut && 'its signing secret', r.callbackLeftOut && 'its callback URL'].filter(Boolean).join(' and ');
1337
+ reconnect.push({ kind: 'webhook', target: m.ref, text: `${m.name} / ${r.name}: give the sender its new webhook URL and key${extra ? `, and set ${extra} again` : ''}.` });
1338
+ }
1339
+ }
1340
+ return {
1341
+ m,
1342
+ ref: m.ref,
1343
+ name: m.name,
1344
+ action,
1345
+ conflict,
1346
+ provider: { file: m.provider, imported: provider, available, options: providerIds },
1347
+ model: { file: m.model, imported: model },
1348
+ effort: { file: m.effort, imported: effort },
1349
+ access: { file: m.access, imported: access },
1350
+ alwaysAllow: { file: m.alwaysAllow, imported: alwaysAllow },
1351
+ askFirst: m.askFirst,
1352
+ paused: m.paused,
1353
+ connections,
1354
+ skills: skillList === null ? { imported: null, dropped: [] } : skillList,
1355
+ browserSites: { imported: sites, dropped: m.browserSites.filter((x) => !sites.includes(x)) },
1356
+ caps,
1357
+ routines,
1358
+ };
1359
+ });
1360
+ const spendingNotes: string[] = [];
1361
+ const making = botonPlans.filter((m) => m.action === 'create');
1362
+ const fileHasCaps = file.botons.some((m) => m.dailyCapUsd !== null || m.routines.some((r) => r.perRunCapUsd !== null));
1363
+ if (upgradedFrom !== null && upgradedFrom < 3) spendingNotes.push(`The file is format version ${upgradedFrom}, which carries no spending caps: imported Botons arrive with none. Set them in each Boton's Spending once it is here.`);
1364
+ if (making.some((m) => m.caps.applies || m.caps.daily.imported !== null || m.routines.some((r) => r.perRunCap.imported !== null))) {
1365
+ spendingNotes.push("What Botons spent where the file was made isn't in it: each Boton made here is new, with its own account starting at $0 today, counted against the caps it arrives with. No Boton already here has its caps or spending changed.");
1366
+ }
1367
+ for (const m of making) {
1368
+ const capText = [m.caps.daily.imported !== null && `${usd(m.caps.daily.imported)} a day`, ...m.routines.filter((r) => r.perRunCap.imported !== null).map((r) => `${usd(r.perRunCap.imported)} a run for ${r.name}`)].filter(Boolean).join(', ');
1369
+ const dropped = m.caps.daily.file !== null || m.routines.some((r) => r.perRunCap.file !== null);
1370
+ if (m.caps.choice === 'none' && dropped) spendingNotes.push(`${m.name}: you chose to leave out the file's spending caps; it arrives with none.`);
1371
+ if (capText && !m.caps.applies) spendingNotes.push(`${m.name}: its caps (${capText}) are kept, but count only API-key turns, and it arrives on ${m.provider.imported}.`);
1372
+ if (m.caps.daily.imported !== null && apiPriceKnown(m.provider.imported, m.model.imported) === false) spendingNotes.push(`${m.name}: its model has no known price here, so with a cap set its turns are refused until you pick a model with a known price or remove the cap.`);
1373
+ for (const r of m.routines) {
1374
+ if ((m.caps.daily.imported !== null || r.perRunCap.imported !== null) && apiPriceKnown(m.provider.imported, r.model.imported ?? m.model.imported) === false) {
1375
+ spendingNotes.push(`${m.name} / ${r.name}: its effective model (${r.model.imported || m.model.imported || 'the brain default'}) has no known price here, so this routine's capped turns are refused until you pick a model with a known price or remove its applicable caps.`);
1376
+ }
1377
+ }
1378
+ }
1379
+ if (fileHasCaps && !making.length) spendingNotes.push('No Boton is made, so none of the file\'s spending caps are.');
1380
+ if (botonPlans.some((m) => m.action === 'create')) {
1381
+ reconnect.push({ kind: 'brain', target: '', text: "Brains' sign-ins and API keys are never in the file: sign each brain in on this server, or save its key in Settings → API keys." });
1382
+ }
1383
+
1384
+ // Settings and the sidebar: by default only on a server with no Botons yet, which is what moving one is.
1385
+ if (choices.settings !== undefined && typeof choices.settings !== 'boolean') throw new HttpError(400, 'choices.settings must be true or false');
1386
+ const applySettings = choices.settings ?? fresh;
1387
+ const now = store.getSettings();
1388
+ const s = file.settings;
1389
+ const changes: { field: string; from: string; to: string }[] = [];
1390
+ const change = (field: string, from: string, to: string) => from !== to && changes.push({ field, from, to });
1391
+ const created = (ref: string | null) => (ref && botonPlans.find((m) => m.ref === ref && m.action === 'create') ? ref : null);
1392
+ // This server's push token belongs to its push server: kept only if the file names the same one (scheme, host
1393
+ // and port), else removed, never sent to another. Pushes the file had on with a token stay off until one is entered.
1394
+ const sameServer = ntfyOrigin(s.ntfy.server) === ntfyOrigin(now.ntfy.server);
1395
+ const keepsToken = now.ntfy.hasToken && sameServer;
1396
+ const dropsToken = now.ntfy.hasToken && !sameServer;
1397
+ const needsToken = s.ntfy.tokenLeftOut && !keepsToken;
1398
+ const ntfyOn = s.ntfy.enabled && !needsToken;
1399
+ const notes: string[] = [];
1400
+ if (applySettings) {
1401
+ change('Your name', now.ownerName, s.ownerName);
1402
+ change('Default timezone', now.timezone, s.timezone);
1403
+ change('Push server', now.ntfy.server, s.ntfy.server);
1404
+ change('Push topic', now.ntfy.topic, s.ntfy.topic);
1405
+ change('Push notifications', now.ntfy.enabled ? 'on' : 'off', ntfyOn ? 'on' : 'off');
1406
+ if (dropsToken) change('Push access token', `saved (for ${ntfyOrigin(now.ntfy.server) || 'no server'})`, 'removed: it is never sent to another server');
1407
+ else if (keepsToken && s.ntfy.tokenLeftOut) notes.push("This server's saved push access token is kept: the file names the same push server.");
1408
+ change('Message text in pushes', now.ntfy.preview ? 'shown' : 'hidden', s.ntfy.preview ? 'shown' : 'hidden');
1409
+ if (s.reactionFollowUps === null) notes.push(`The file doesn't say which reactions start a follow-up (format version ${upgradedFrom ?? PORTABLE_VERSION}): this server's are kept (${now.reactionFollowUps.join(' ') || 'none'}).`);
1410
+ else change('Reactions that start a follow-up', now.reactionFollowUps.join(' ') || 'none', s.reactionFollowUps.join(' ') || 'none');
1411
+ const primary = created(s.primaryBoton);
1412
+ if (primary) change('Primary Boton', now.primaryBotId ? (bots.find((b) => b.id === now.primaryBotId)?.name ?? '') : 'none', file.botons.find((m) => m.ref === primary)!.name);
1413
+ if (s.ntfy.enabled && needsToken) reconnect.push({ kind: 'ntfy-token', target: 'ntfy', text: 'Push notifications used an access token, which is never in the file: enter it in Settings → Push notifications, then turn pushes on.' });
1414
+ }
1415
+ const sidebarMode = (choices.sidebar ?? (fresh && store.sidebarLayout().groups.length === 0 ? 'replace' : 'merge')) as PortableSidebarMode;
1416
+ if (!['replace', 'merge', 'skip'].includes(sidebarMode)) throw new HttpError(400, 'choices.sidebar must be "replace", "merge" or "skip"');
1417
+
1418
+ const textWarnings = textWarningsOf(file);
1419
+
1420
+ // Saved templates: each a new one, unless one here has its name and content already. What it would widen when
1421
+ // used (Full computer access, Full access webhook runs) or names that this server won't have is left out of it.
1422
+ const savedHere = this.#d.templates.savedTemplates();
1423
+ const templatePlans = file.templates.map((t) => {
1424
+ const narrowed: string[] = [];
1425
+ const bot = { ...t.bot };
1426
+ if (bot.access === 'host') {
1427
+ bot.access = 'workspace';
1428
+ narrowed.push('Full computer access (choose it when you make a Boton from it)');
1429
+ }
1430
+ const lostBrain = bot.provider !== null && !providerIds.includes(bot.provider);
1431
+ if (lostBrain) {
1432
+ narrowed.push(`its brain ${bot.provider}, which this server doesn't have${bot.model ? `, with its model ${bot.model}` : ''}${bot.effort ? ` and effort ${bot.effort}` : ''}`);
1433
+ bot.provider = null;
1434
+ bot.model = '';
1435
+ bot.effort = '';
1436
+ }
1437
+ const lostConnections = bot.connections.filter((id) => !usableConnectors.has(id));
1438
+ if (lostConnections.length) narrowed.push(`connectors this server won't have: ${lostConnections.join(', ')}`);
1439
+ bot.connections = bot.connections.filter((id) => usableConnectors.has(id));
1440
+ if (bot.skills) {
1441
+ const lost = bot.skills.filter((n) => !library.has(n));
1442
+ if (lost.length) narrowed.push(`skills this server won't have: ${lost.join(', ')}`);
1443
+ bot.skills = bot.skills.filter((n) => library.has(n));
1444
+ }
1445
+ const fullHooks = t.routines.filter((r) => r.webhook && !r.webhookWorkspace).length;
1446
+ if (fullHooks) narrowed.push(`Full access for ${fullHooks === 1 ? 'a webhook routine' : `${fullHooks} webhook routines`}`);
1447
+ // Each routine's own losses, by name: a skill this server won't have, a model for a brain it doesn't have.
1448
+ const routines = t.routines.map((r) => {
1449
+ const lost: string[] = [];
1450
+ if (r.skill && !library.has(r.skill)) lost.push(`its skill ${r.skill}, which this server won't have`);
1451
+ if (lostBrain && r.model) lost.push(`its model ${r.model}, for the brain this server doesn't have`);
1452
+ if (lost.length) narrowed.push(`routine ${r.name}: ${lost.join('; ')}`);
1453
+ return { ...r, webhookWorkspace: true, skill: r.skill && library.has(r.skill) ? r.skill : null, model: lostBrain ? null : r.model };
1454
+ });
1455
+ const content = JSON.stringify([t.description, bot, routines]);
1456
+ const same = savedHere.some((x) => x.name === t.name && JSON.stringify([x.description, x.bot, x.routines]) === content);
1457
+ const action = (templateChoices[t.ref] ?? (same ? 'skip' : 'create')) as PortableTemplateAction;
1458
+ if (action !== 'create' && action !== 'skip') throw new HttpError(400, `choices.templates.${t.ref} must be "create" or "skip"`);
1459
+ return { plan: { ref: t.ref, name: t.name, action, same, routines: t.routines.length, narrowed }, entry: { name: t.name, description: t.description, bot, routines } };
1460
+ });
1461
+
1462
+ const body: Omit<PortableImportPreview, 'fingerprint'> = {
1463
+ source: { format: PORTABLE_FORMAT, version: upgradedFrom ?? PORTABLE_VERSION, app: file.source.app, schema: file.source.schema, exportedAt: file.exportedAt },
1464
+ upgradedFrom,
1465
+ choices: {
1466
+ botons: Object.fromEntries(
1467
+ botonPlans.map((m) => [
1468
+ m.ref,
1469
+ { action: m.action, provider: m.provider.imported, fullAccess: m.access.imported === 'host', alwaysAllow: m.alwaysAllow.imported.length > 0 || (botonChoices[m.ref] as { alwaysAllow?: boolean } | undefined)?.alwaysAllow === true, caps: m.caps.choice },
1470
+ ]),
1471
+ ),
1472
+ skills: Object.fromEntries(skillPlans.map((x) => [x.name, x.action])),
1473
+ connectors: Object.fromEntries(connectorPlans.map((x) => [x.id, x.action])),
1474
+ templates: Object.fromEntries(templatePlans.map((x) => [x.plan.ref, x.plan.action])),
1475
+ settings: applySettings,
1476
+ sidebar: sidebarMode,
1477
+ },
1478
+ settings: { apply: applySettings, changes, notes },
1479
+ botons: botonPlans.map(({ m: _m, ...p }) => p),
1480
+ skills: skillPlans,
1481
+ connectors: connectorPlans,
1482
+ sidebar: { mode: sidebarMode, groups: file.sidebar.groups.map((g) => g.name) },
1483
+ spending: { notes: spendingNotes },
1484
+ templates: templatePlans.map((x) => x.plan),
1485
+ textWarnings,
1486
+ reconnect,
1487
+ };
1488
+ // What was shown, and the file itself: a different file, or a change here that changes the plan, gets another.
1489
+ const fingerprint = sha(
1490
+ JSON.stringify([PLAN_REVISION, body, sha(JSON.stringify(file)), templatePlans.map((x) => x.entry), now, store.sidebarLayout(), [...hostSkills.values()].map((x) => [x.name, x.version, x.disabled, x.pending]), savedHere.map((x) => [x.id, x.name])]),
1491
+ );
1492
+ return { preview: { ...body, fingerprint }, file, botons: botonPlans, templates: templatePlans.map((x) => ({ ref: x.plan.ref, entry: x.entry })), ntfy: { on: ntfyOn, dropToken: dropsToken } };
1493
+ }
1494
+
1495
+ /**
1496
+ * Makes exactly what the preview with `fingerprint` showed, all at once or not at all (409 if it would differ now).
1497
+ * `record` writes the audit inside the same transaction, so an import is never kept unrecorded; nothing it does is
1498
+ * published (events, approvals, the templates' files) until the transaction commits, and none of it if it rolls back.
1499
+ */
1500
+ async import(input: PortableImportInput, record: (result: PortableImportResult, made: Bot[]) => void): Promise<PortableImportResult> {
1501
+ const plan = await this.#plan(input);
1502
+ if (typeof input.fingerprint !== 'string' || input.fingerprint !== plan.preview.fingerprint) {
1503
+ throw new HttpError(409, 'The file, your choices or this server changed since the preview: preview it again.');
1504
+ }
1505
+ const { store, skills, connectors, oauth, browser, providers } = this.#d;
1506
+ const { preview, file } = plan;
1507
+ const ids = providers.ids();
1508
+ const made = new Map<string, Bot>();
1509
+ const toMake = plan.templates.filter((t) => preview.choices.templates[t.ref] === 'create');
1510
+ // Written aside now, listed in the transaction, put in place once it commits; removed if it doesn't (templates.ts).
1511
+ const staged = this.#d.templates.stage(toMake.map((t) => t.entry));
1512
+ let result: PortableImportResult;
1513
+ try {
1514
+ result = store.atomically((): PortableImportResult => this.#make(plan, made, ids, staged, toMake, record));
1515
+ } catch (err) {
1516
+ staged.discard();
1517
+ throw err;
1518
+ }
1519
+ log.info('configuration imported', { botons: result.botons.length, skills: result.skills.length, connectors: result.connectors.length, templates: result.templates.length });
1520
+ return result;
1521
+ }
1522
+
1523
+ #make(
1524
+ plan: Plan,
1525
+ made: Map<string, Bot>,
1526
+ ids: Set<string>,
1527
+ staged: { record(): void },
1528
+ toMake: Plan['templates'],
1529
+ record: (result: PortableImportResult, made: Bot[]) => void,
1530
+ ): PortableImportResult {
1531
+ const { store, skills, connectors, oauth, browser } = this.#d;
1532
+ const { preview, file } = plan;
1533
+ // Connectors first, so the Botons' lists name them; each made off, at a new request-policy generation.
1534
+ const connectorsMade: string[] = [];
1535
+ for (const c of file.connectors) {
1536
+ if (preview.choices.connectors[c.id] !== 'create') continue;
1537
+ if (c.kind === 'library') {
1538
+ connectors.createServer({ name: c.name, transport: c.transport, ...(c.transport === 'stdio' ? { command: c.command, args: c.args } : { url: c.url }) });
1539
+ } else {
1540
+ const conn = oauth.create({ name: c.name, url: c.url }, { quiet: true });
1541
+ oauth.update(conn.id, { keepConnected: c.keepConnected, unknownJobs: c.unknownJobs }, { quiet: true });
1542
+ oauth.emit(conn.id);
1543
+ }
1544
+ connectors.setPolicy(c.id, { onRequest: c.onRequest, grant: c.grant });
1545
+ connectors.setEnabled(c.id, false, 'Imported from a configuration file: check it, then turn it on');
1546
+ connectorsMade.push(c.id);
1547
+ }
1548
+
1549
+ const placed = new Map<string, { group: string; pinned: boolean }>();
1550
+ if (preview.sidebar.mode !== 'skip') {
1551
+ for (const g of file.sidebar.groups) for (const ref of g.botIds) placed.set(ref, { group: g.name, pinned: false });
1552
+ for (const ref of file.sidebar.pinned.botIds) placed.set(ref, { group: placed.get(ref)?.group ?? '', pinned: true });
1553
+ }
1554
+ for (const p of plan.botons) {
1555
+ if (p.action !== 'create') continue;
1556
+ const m = p.m;
1557
+ const where = placed.get(m.ref);
1558
+ const bot = store.createBot(
1559
+ {
1560
+ name: m.name,
1561
+ title: m.title,
1562
+ instructions: m.instructions,
1563
+ look: m.look,
1564
+ provider: p.provider.imported,
1565
+ model: p.model.imported,
1566
+ effort: p.effort.imported,
1567
+ access: p.access.imported,
1568
+ timezone: m.timezone,
1569
+ connections: p.connections.imported,
1570
+ skills: p.skills.imported,
1571
+ ...(where ? { group: where.group, pinned: where.pinned } : {}),
1572
+ },
1573
+ ids,
1574
+ p.provider.imported,
1575
+ );
1576
+ if (m.askFirst.length || p.alwaysAllow.imported.length) store.updateBot(bot.id, { askFirst: m.askFirst, alwaysAllow: p.alwaysAllow.imported }, ids);
1577
+ if (m.paused) store.setBotPaused(bot.id, true);
1578
+ browser.importSites(bot.id, p.browserSites.imported, p.access.imported);
1579
+ made.set(m.ref, bot);
1580
+ }
1581
+
1582
+ // Skills after the Botons, so a Boton's pending skill stays its own here.
1583
+ const skillsMade: string[] = [];
1584
+ for (const s of file.skills) {
1585
+ const action = preview.choices.skills[s.name];
1586
+ if (action !== 'create' && action !== 'replace') continue;
1587
+ const by: SkillAuthor = s.author.kind === 'owner' ? { kind: 'owner' } : { kind: 'boton', botId: (s.author.ref && made.get(s.author.ref)?.id) || '', name: s.author.name };
1588
+ skills.importSkill({ ...s, author: by }, action === 'replace');
1589
+ skillsMade.push(s.name);
1590
+ }
1591
+
1592
+ const caps = new Map<string, PortableImportResult['botons'][number]['caps']>();
1593
+ for (const p of plan.botons) {
1594
+ const bot = made.get(p.ref);
1595
+ if (!bot) continue;
1596
+ const runCaps: [string, number][] = [];
1597
+ const capped: { id: string; name: string; perRunCapUsd: number }[] = [];
1598
+ p.m.routines.forEach((r, i) => {
1599
+ const rp = p.routines[i];
1600
+ const routineMade = store.createRoutine(
1601
+ bot.id,
1602
+ {
1603
+ name: r.name,
1604
+ instruction: r.instruction,
1605
+ schedule: r.schedule,
1606
+ timezone: r.timezone ?? bot.timezone,
1607
+ webhook: r.webhook,
1608
+ // Off until the owner turns it on: nothing imported runs by itself.
1609
+ enabled: false,
1610
+ notify: r.notify,
1611
+ webhookWorkspace: !rp.fullAccessWebhook.imported,
1612
+ ...(r.webhook ? { webhookSource: r.webhookSource, webhookFilter: r.webhookFilter } : {}),
1613
+ skill: rp.skill.imported,
1614
+ model: rp.model.imported,
1615
+ effort: rp.effort.imported,
1616
+ session: r.session,
1617
+ timeLimitMs: r.timeLimitMs === null ? null : Math.min(r.timeLimitMs, this.#d.cfg.turnTimeoutMs),
1618
+ },
1619
+ { chip: false },
1620
+ );
1621
+ if (rp.perRunCap.imported !== null) {
1622
+ runCaps.push([routineMade.id, rp.perRunCap.imported]);
1623
+ capped.push({ id: routineMade.id, name: routineMade.name, perRunCapUsd: rp.perRunCap.imported });
1624
+ }
1625
+ });
1626
+ // On the ids just made, in this transaction: bound to nothing that was here before (spend.ts refuses that).
1627
+ this.#d.spending.importCaps(bot.id, p.caps.daily.imported, runCaps);
1628
+ caps.set(p.ref, { dailyCapUsd: p.caps.daily.imported, routines: capped });
1629
+ const n = p.m.routines.length;
1630
+ store.addMessage(bot.id, 'event', `Imported from a configuration file${n ? `; ${n === 1 ? '1 routine' : `${n} routines`}, off` : ''}`, { kind: 'bot.imported', copiedRoutines: n, sourceName: p.name });
1631
+ }
1632
+
1633
+ if (preview.settings.apply) {
1634
+ const s = file.settings;
1635
+ const primary = s.primaryBoton ? made.get(s.primaryBoton)?.id : undefined;
1636
+ store.updateSettings({
1637
+ ownerName: s.ownerName,
1638
+ timezone: s.timezone,
1639
+ ntfy: { enabled: plan.ntfy.on, server: s.ntfy.server, topic: s.ntfy.topic, preview: s.ntfy.preview, ...(plan.ntfy.dropToken ? { token: null } : {}) },
1640
+ ...(s.reactionFollowUps !== null ? { reactionFollowUps: s.reactionFollowUps } : {}),
1641
+ ...(primary ? { primaryBotId: primary } : {}),
1642
+ });
1643
+ }
1644
+
1645
+ if (preview.sidebar.mode === 'replace') {
1646
+ const id = (refs: string[]) => refs.flatMap((r) => (made.has(r) ? [made.get(r)!.id] : []));
1647
+ store.importSidebar({
1648
+ ...file.sidebar,
1649
+ pinned: { ...file.sidebar.pinned, botIds: id(file.sidebar.pinned.botIds) },
1650
+ groups: file.sidebar.groups.map((g) => ({ ...g, botIds: id(g.botIds) })),
1651
+ ungrouped: { ...file.sidebar.ungrouped, botIds: id(file.sidebar.ungrouped.botIds) },
1652
+ });
1653
+ }
1654
+
1655
+ const result: PortableImportResult = {
1656
+ botons: [...made].map(([ref, b]) => ({ ref, botId: b.id, name: b.name, caps: caps.get(ref)! })),
1657
+ skills: skillsMade,
1658
+ connectors: connectorsMade,
1659
+ templates: toMake.map((t) => t.entry.name),
1660
+ settings: preview.settings.apply,
1661
+ sidebar: preview.sidebar.mode,
1662
+ reconnect: preview.reconnect,
1663
+ };
1664
+ record(result, [...made.values()].map((b) => store.publicBot(store.getBot(b.id))));
1665
+ // In the same transaction as the audit: shown once it commits, or after a crash once the server starts again.
1666
+ staged.record();
1667
+ return result;
1668
+ }
1669
+ }