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,335 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { READ_METHODS, WRITE_METHODS } from './browser-policy.ts';
3
+
4
+ /*
5
+ * What the managed browser's approval cards are made of, as pure functions. What a page sends because the Boton
6
+ * clicked or typed (a form, a script's POST, or a GET: a site can act on an address too) leaves only as one request
7
+ * that Chromium's routing holds at the network boundary while the owner reads it: the card shows that request in full
8
+ * (method, full address with its query, what it carries, the names of its cookies) and is bound to a fingerprint of
9
+ * all of it, so allowing it sends exactly those bytes once (wireHeaders, browser-send.ts), never a request the page
10
+ * made since, and never with a cookie other than the ones it was held with. Those may only be cookies the browser
11
+ * itself held for the request's address while the step ran (heldCookies): a site's own cookies change as it works (a
12
+ * session refreshed, a sign-in step, a script's cookie), and that churn is fine; a cookie from nowhere is not.
13
+ *
14
+ * Where a request can't be shown in full (too long, binary, a file in it), there is no card: it is refused.
15
+ */
16
+
17
+
18
+ /** What a card may show at most: a request whose description is longer is refused, never cut. */
19
+ export const SHOWN_MAX = 3500;
20
+
21
+ /** Headers every request carries, which say nothing the page chose: left off the card (cookies are named, never shown). */
22
+ const ORDINARY_HEADERS = new Set([
23
+ 'accept',
24
+ 'accept-encoding',
25
+ 'accept-language',
26
+ 'cache-control',
27
+ 'connection',
28
+ 'content-length',
29
+ 'content-type',
30
+ 'cookie',
31
+ 'host',
32
+ 'origin',
33
+ 'pragma',
34
+ 'priority',
35
+ 'referer',
36
+ 'upgrade-insecure-requests',
37
+ 'user-agent',
38
+ ]);
39
+
40
+ export interface HeldRequest {
41
+ method: string;
42
+ url: string;
43
+ headers: Record<string, string>;
44
+ body: Buffer | null;
45
+ /** The cookies it carries that the page set or changed while the step ran (see heldCookies): named on its card. */
46
+ changed?: string[];
47
+ }
48
+
49
+ /** Headers that carry a credential: on a card by name only, never their value (the fingerprint still binds it). */
50
+ const CREDENTIAL = /^(authorization|proxy-authorization|cookie|x-csrf-token|x-xsrf-token)$|token|secret|auth|session|api-?key|password|credential/i;
51
+
52
+ /** Headers that belong to one connection, not to the request: never sent as held, the sender sets its own. */
53
+ const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'content-length']);
54
+
55
+ /**
56
+ * The headers that go on the wire for a held request, in order: exactly the ones it was held with, but for HTTP/2's
57
+ * pseudo-headers (the method and address, sent as such) and those of one connection; `host` from its address when
58
+ * Chromium spoke HTTP/2, and the body's length. Nothing else: no cookie it didn't carry, none from the browser's jar.
59
+ */
60
+ export function wireHeaders(r: HeldRequest): [string, string][] {
61
+ const out: [string, string][] = [];
62
+ let host = false;
63
+ for (const [k, v] of Object.entries(r.headers)) {
64
+ const name = k.toLowerCase();
65
+ if (name.startsWith(':') || HOP.has(name)) continue;
66
+ if (name === 'host') host = true;
67
+ out.push([name, v]);
68
+ }
69
+ if (!host) out.unshift(['host', new URL(r.url).host]);
70
+ if (r.body?.length || !READ_METHODS.has(r.method.toUpperCase())) out.push(['content-length', String(r.body?.length ?? 0)]);
71
+ out.push(['connection', 'close']);
72
+ return out;
73
+ }
74
+
75
+ /** A cookie as the browser holds it. */
76
+ export interface JarCookie {
77
+ name: string;
78
+ value: string;
79
+ domain: string;
80
+ path: string;
81
+ expires: number;
82
+ httpOnly: boolean;
83
+ secure: boolean;
84
+ sameSite: string;
85
+ partitionKey?: string;
86
+ /** Chromium's half of a partition: whether the frame that set it had a cross-site ancestor (Playwright's name; true when unsaid). */
87
+ _crHasCrossSiteAncestor?: boolean;
88
+ }
89
+
90
+ /** Whether the browser keeps `c` for `url` (domain, path, Secure), as Playwright's cookies(url) decides it. */
91
+ function appliesTo(c: JarCookie, url: URL): boolean {
92
+ const host = url.hostname;
93
+ if (host !== c.domain && !(c.domain.startsWith('.') && `.${host}`.endsWith(c.domain))) return false;
94
+ const path = url.pathname;
95
+ if (path !== c.path && !(path.startsWith(c.path) && (c.path.endsWith('/') || path[c.path.length] === '/'))) return false;
96
+ const local = host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(host);
97
+ return !c.secure || url.protocol === 'https:' || local;
98
+ }
99
+
100
+ /** A Cookie header as the pairs it carries, in order (a pair without "=" is a cookie with no name, as Chromium writes it). */
101
+ function carriedPairs(header: string): [string, string][] {
102
+ return header
103
+ .split(';')
104
+ .map((p) => p.trim())
105
+ .filter(Boolean)
106
+ .map((p) => (p.includes('=') ? [p.slice(0, p.indexOf('=')).trim(), p.slice(p.indexOf('=') + 1).trim()] : ['', p]));
107
+ }
108
+
109
+ /**
110
+ * One place a cookie lives in the jar: a second cookie of one name is another place (another path, domain or
111
+ * partition). Chromium keys a partition by its top-level site and whether the frame had a cross-site ancestor, so
112
+ * that bit is part of the place; Playwright takes it as true when a partitioned cookie leaves it out.
113
+ */
114
+ const slotOf = (c: JarCookie) => JSON.stringify([c.domain, c.path, c.name, c.partitionKey ?? null, c.partitionKey ? (c._crHasCrossSiteAncestor ?? true) : null]);
115
+
116
+ /**
117
+ * The cookies of `jar` in no place `kept` has a cookie in (its partition counted, as Chromium keeps it): the ones a
118
+ * site dropped, to go when `kept` is the whole jar. Same name, domain and path in another partition is another cookie.
119
+ */
120
+ export function staleCookies<C extends JarCookie>(jar: C[], kept: JarCookie[]): C[] {
121
+ const keep = new Set(kept.map(slotOf));
122
+ return jar.filter((c) => !keep.has(slotOf(c)));
123
+ }
124
+
125
+ /**
126
+ * The cookies a held request goes with, or why it gets no card. `start` is the whole jar as the step began, `now` the
127
+ * whole jar read after the request was held. Chromium wrote the request's Cookie header from its jar at some moment
128
+ * between the two, and a site changes its own cookies as it works (a session refreshed, the next step of a sign-in,
129
+ * a script's cookie), so the jar may change in between. What may not happen is a cookie the browser never held:
130
+ * each pair the header carries must be a cookie the browser held for the request's address (its domain, path and
131
+ * Secure) at the step's start or after the hold, each cookie counted once. Carrying fewer than the jar holds is fine
132
+ * (a request made without credentials, or without the ones SameSite or a partition keeps back). The ones it carries
133
+ * that are new or changed since the step began are returned by name, for its card. The header is sent exactly as
134
+ * held, so a change to the jar after this changes nothing that goes.
135
+ */
136
+ export function heldCookies(header: string | undefined, url: string, start: JarCookie[] | null, now: JarCookie[]): { changed: string[] } | { why: string } {
137
+ if (!start) return { why: "the browser's cookies could not be read as the step began" };
138
+ if (!header) return { changed: [] };
139
+ const u = new URL(url);
140
+ // Every place a cookie for this address was, with each value it had: as the step began, and as the request was held.
141
+ const slots = new Map<string, { name: string; before: string | null; values: Set<string> }>();
142
+ for (const [c, at] of [...start.map((c) => [c, 'start'] as const), ...now.map((c) => [c, 'now'] as const)]) {
143
+ if (!appliesTo(c, u)) continue;
144
+ const key = slotOf(c);
145
+ const slot = slots.get(key) ?? { name: c.name, before: null, values: new Set<string>() };
146
+ if (at === 'start') slot.before = c.value;
147
+ slot.values.add(c.value);
148
+ slots.set(key, slot);
149
+ }
150
+ const used = new Set<string>();
151
+ const changed = new Set<string>();
152
+ for (const [name, value] of carriedPairs(header)) {
153
+ const found = [...slots].find(([key, s]) => !used.has(key) && s.name === name && s.values.has(value));
154
+ if (!found) return { why: `it carries a cookie the browser does not hold for ${u.host}` };
155
+ used.add(found[0]);
156
+ if (found[1].before !== value) changed.add(name || '(no name)');
157
+ }
158
+ return { changed: [...changed].sort() };
159
+ }
160
+
161
+ /** Headers that differ between two reads of one request without changing what it asks: its cookies (judged by heldCookies) and the browser's client hints. */
162
+ const churns = (name: string) => name === 'cookie' || name.startsWith('sec-ch-');
163
+
164
+ /**
165
+ * Why `now` is not the request on a card (`card`), or null when it is: the same method, full address and body, and the
166
+ * same headers but for cookie churn and client hints. What goes is always the card's request, never `now`.
167
+ */
168
+ export function sameRequest(card: HeldRequest, now: HeldRequest): string | null {
169
+ if (card.method.toUpperCase() !== now.method.toUpperCase()) return 'its method is not the one on the card';
170
+ if (card.url !== now.url) return 'its address is not the one on the card';
171
+ const body = (r: HeldRequest) => r.body ?? Buffer.alloc(0);
172
+ if (!body(card).equals(body(now))) return 'what it carries is not what the card shows';
173
+ const rest = (r: HeldRequest) =>
174
+ JSON.stringify(
175
+ Object.entries(r.headers)
176
+ .map(([k, v]) => [k.toLowerCase(), v])
177
+ .filter(([k]) => !churns(k))
178
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
179
+ );
180
+ if (rest(card) !== rest(now)) return 'its headers are not the ones on the card';
181
+ return null;
182
+ }
183
+
184
+ /** What binds a card: the Boton, the turn, the browser session and page document, and every byte of the request (its Cookie header included). */
185
+ export interface Binding {
186
+ botId: string;
187
+ tokenId: string;
188
+ session: string;
189
+ revision: number;
190
+ }
191
+
192
+ export function fingerprint(b: Binding, r: HeldRequest): string {
193
+ const headers = Object.entries(r.headers)
194
+ .map(([k, v]) => [k.toLowerCase(), v])
195
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
196
+ const h = createHash('sha256');
197
+ h.update(JSON.stringify([b.botId, b.tokenId, b.session, b.revision, r.method.toUpperCase(), r.url, headers]));
198
+ h.update('\n');
199
+ if (r.body) h.update(r.body);
200
+ return h.digest('hex');
201
+ }
202
+
203
+ /**
204
+ * Text from a page or a request, safe to show: control characters, invisible ones and those that reorder text (bidi
205
+ * overrides) are written out as \u{…}, so a card can't hide or reorder what it says.
206
+ */
207
+ export function shown(text: string): string {
208
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff\ufff9-\ufffb]/g, (c) => `\\u{${c.codePointAt(0)!.toString(16)}}`);
209
+ }
210
+
211
+ /** One line of a field: its value on the card, newlines written out too. */
212
+ const oneLine = (text: string) => shown(text).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
213
+
214
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
215
+
216
+ function decode(body: Buffer): string | null {
217
+ try {
218
+ const t = utf8.decode(body);
219
+ // Text, not bytes: anything below a space other than tab and newlines means it isn't.
220
+ return /[\u0000-\u0008\u000e-\u001f]/.test(t) ? null : t;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ function formFields(text: string): string[] {
227
+ if (!text) return ['(no fields)'];
228
+ return text.split('&').map((pair) => {
229
+ const at = pair.indexOf('=');
230
+ const dec = (s: string) => {
231
+ try {
232
+ return decodeURIComponent(s.replace(/\+/g, ' '));
233
+ } catch {
234
+ return s;
235
+ }
236
+ };
237
+ return at < 0 ? `${oneLine(dec(pair))} = ` : `${oneLine(dec(pair.slice(0, at)))} = ${oneLine(dec(pair.slice(at + 1)))}`;
238
+ });
239
+ }
240
+
241
+ /** A multipart body's fields, or why it can't be shown (a file in it, or not a body we can read). */
242
+ function multipartFields(contentType: string, text: string): string[] | { why: string } {
243
+ const boundary = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
244
+ if (!boundary) return { why: 'it is a multipart body without a boundary' };
245
+ const sep = `--${boundary[1] ?? boundary[2]}`;
246
+ const parts = text.split(sep);
247
+ if (parts.length < 3 || !/^--\s*$/.test(parts.at(-1)!)) return { why: 'its multipart body could not be read' };
248
+ const out: string[] = [];
249
+ for (const raw of parts.slice(1, -1)) {
250
+ const part = raw.replace(/^\r\n/, '').replace(/\r\n$/, '');
251
+ const split = part.indexOf('\r\n\r\n');
252
+ if (split < 0) return { why: 'its multipart body could not be read' };
253
+ const head = part.slice(0, split);
254
+ if (/filename\*?=/i.test(head)) return { why: 'it sends a file, and the browser never uploads' };
255
+ const name = /name="([^"]*)"/i.exec(head)?.[1] ?? '(no name)';
256
+ out.push(`${oneLine(name)} = ${oneLine(part.slice(split + 4))}`);
257
+ }
258
+ return out.length ? out : ['(no fields)'];
259
+ }
260
+
261
+ export interface Described {
262
+ title: string;
263
+ details: string;
264
+ }
265
+
266
+ /**
267
+ * The card for one held request, or why there is none (it is then refused). `control` is the page's own label for
268
+ * what was clicked or typed into, shown as the site's text: never a reason to allow it.
269
+ */
270
+ export function describeRequest(r: HeldRequest, control: string | null, key: string): Described | { why: string } {
271
+ let u: URL;
272
+ try {
273
+ u = new URL(r.url);
274
+ } catch {
275
+ return { why: 'its address could not be read' };
276
+ }
277
+ const method = r.method.toUpperCase();
278
+ if (!WRITE_METHODS.has(method) && !READ_METHODS.has(method)) return { why: `${method} requests are not sent` };
279
+ const headers: Record<string, string> = {};
280
+ for (const [k, v] of Object.entries(r.headers)) headers[k.toLowerCase()] = v;
281
+ const type = headers['content-type'] ?? '';
282
+ const lines = [`${method} ${oneLine(u.href)}`];
283
+ lines.push(control ? `After: ${control} (that label is the site's own text, not proof of what this does)` : 'Made by the page itself while the browser acted (no control of its own)');
284
+ lines.push('');
285
+ const body = r.body && r.body.length ? r.body : null;
286
+ if (READ_METHODS.has(method)) lines.push(`A ${method} sends what its address says (the query above): a site can act on that too, so it waits on you like a form.`);
287
+ if (!body) lines.push('It carries nothing (no body).');
288
+ else {
289
+ const text = decode(body);
290
+ if (text === null) return { why: 'it carries bytes that are not text, which a card cannot show' };
291
+ const mime = type.split(';')[0].trim().toLowerCase();
292
+ if (mime === 'application/x-www-form-urlencoded') lines.push('Fields (a form):', ...formFields(text).map((f) => ` ${f}`));
293
+ else if (mime === 'multipart/form-data') {
294
+ const fields = multipartFields(type, text);
295
+ if (!Array.isArray(fields)) return fields;
296
+ lines.push('Fields (a multipart form):', ...fields.map((f) => ` ${f}`));
297
+ } else lines.push(`Body (${oneLine(type || 'no type given')}), as sent:`, shown(text));
298
+ }
299
+ const extra = Object.entries(headers).filter(([k]) => !ORDINARY_HEADERS.has(k) && !k.startsWith('sec-'));
300
+ if (extra.length) lines.push('', 'Headers the page set:', ...extra.map(([k, v]) => ` ${oneLine(k)}: ${CREDENTIAL.test(k) ? '(a credential, not shown)' : oneLine(v)}`));
301
+ const names = headers.cookie ? [...new Set(carriedPairs(headers.cookie).map(([n]) => oneLine(n || '(no name)')))] : [];
302
+ lines.push('', names.length ? `It goes with the browser's own cookies for ${u.host} (values not shown): ${names.join(', ')}.` : 'It goes without cookies.');
303
+ if (r.changed?.length) lines.push(`The site set or changed ${r.changed.map(oneLine).join(', ')} while the browser acted: still its own cookies, sent as they were when the page made it.`);
304
+ lines.push('', `If you allow it, exactly this request is sent once, now. Nothing else the page sends is allowed by it. (Request ${key.slice(0, 16)})`);
305
+ const details = lines.join('\n');
306
+ if (details.length > SHOWN_MAX) return { why: `it is too long to show you in full on a card (${details.length.toLocaleString('en-US')} characters)` };
307
+ const path = u.pathname.length > 80 ? `${u.pathname.slice(0, 79)}…` : u.pathname;
308
+ return { title: oneLine(`Let the browser send ${method} ${u.host}${path}${u.search ? '?…' : ''}`).slice(0, 200), details };
309
+ }
310
+
311
+ /** Kinds of field the browser never types into: passwords, codes, card numbers and other secrets. */
312
+ const SENSITIVE = /pass(word|wd|code|phrase)?\b|\bpwd\b|\bpin\b|\botp\b|otp.?code|one.?time|totp|2fa|mfa|verif(y|ication).?code|security.?code|\bcvv|\bcvc|\bcsc\b|card.?(number|num|no)|\bcc.?(num|number|exp|csc)|credit.?card|\biban\b|routing.?number|account.?number|\bssn\b|social.?security|secret|api.?key|token|private.?key/i;
313
+
314
+ export interface FieldInfo {
315
+ tag: string;
316
+ type: string;
317
+ name: string;
318
+ id: string;
319
+ autocomplete: string;
320
+ label: string;
321
+ }
322
+
323
+ /** Why the browser won't type into this field, or null. Judged from what the page says of it, so when in doubt, it won't. */
324
+ export function typingProblem(f: FieldInfo): string | null {
325
+ const tag = f.tag.toLowerCase();
326
+ const type = f.type.toLowerCase() || 'text';
327
+ if (tag !== 'input' && tag !== 'textarea') return 'it is not a text field';
328
+ if (tag === 'input' && !['text', 'search', 'email', 'url', 'tel', 'number'].includes(type)) {
329
+ return type === 'password' ? 'it is a password field' : `it is not a text field (type ${type})`;
330
+ }
331
+ const auto = f.autocomplete.toLowerCase();
332
+ if (/(^|\s)(current-password|new-password|one-time-code|cc-[a-z-]+|transaction-amount)(\s|$)/.test(auto)) return 'the page marks it as a password, code or card field';
333
+ for (const v of [f.name, f.id, f.label]) if (SENSITIVE.test(v)) return 'it looks like a field for a password, a code, a card number or another secret';
334
+ return null;
335
+ }
@@ -0,0 +1,56 @@
1
+ import type { BrowserActionResult, FileRef, Message } from '../../shared/types.ts';
2
+ import { saveNewFile } from './files.ts';
3
+
4
+ const SCREENSHOTS = 'browser-screenshots';
5
+
6
+ /** Posts a file in the Boton's chat (the same as `botons share`): the message it recorded, and the file as shared. */
7
+ export type ShareFile = (botId: string, path: string, title: string) => Promise<{ message: Message | null; file: FileRef }>;
8
+
9
+ /**
10
+ * A screenshot already saved in the workspace, shared in the chat as `botons share` would. The result says it is shown
11
+ * in the chat only when the chat recorded it; otherwise it names the workspace copy, which stays either way.
12
+ */
13
+ export async function deliverScreenshot(
14
+ share: ShareFile,
15
+ botId: string,
16
+ saved: FileRef,
17
+ page: { url: string; title: string },
18
+ ): Promise<BrowserActionResult> {
19
+ let why: string;
20
+ try {
21
+ const shared = await share(botId, saved.path, `Browser: ${page.title || page.url}`.slice(0, 200));
22
+ if (shared.message?.meta?.kind === 'file.shared' && shared.message.meta.file?.path === shared.file.path) {
23
+ return { outcome: 'done', message: `Saved a screenshot as ${shared.file.path} in your workspace; it is shown in your chat.`, page, file: shared.file, shown: true };
24
+ }
25
+ why = 'the chat did not record it';
26
+ } catch (err) {
27
+ why = (err as Error).message || 'it could not be shared';
28
+ }
29
+ return {
30
+ outcome: 'done',
31
+ message: `Saved a screenshot as ${saved.path} in your workspace, but it is not shown in your chat (${why}). Give its workspace path, ${saved.path}, if your owner should see it.`,
32
+ page,
33
+ file: saved,
34
+ shown: false,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * A screenshot's bytes (in memory) saved as a new file in the Boton's workspace, by a write that follows no link the Boton
40
+ * planted there, then shared in its chat as deliverScreenshot says. Not saved: refused, with nothing shared.
41
+ */
42
+ export async function screenshotToChat(
43
+ share: ShareFile,
44
+ bot: { id: string; workspace: string },
45
+ bytes: Uint8Array,
46
+ page: { url: string; title: string },
47
+ now = new Date(),
48
+ ): Promise<BrowserActionResult> {
49
+ let file: FileRef;
50
+ try {
51
+ file = await saveNewFile(bot.workspace, SCREENSHOTS, now.toISOString().replace(/[:.]/g, '-'), 'page.png', bytes);
52
+ } catch (err) {
53
+ return { outcome: 'refused', message: `Not saved: ${(err as Error).message}.`, page };
54
+ }
55
+ return deliverScreenshot(share, bot.id, file, page);
56
+ }
@@ -0,0 +1,155 @@
1
+ import { type ClientRequest, type IncomingMessage, request as httpRequest } from 'node:http';
2
+ import { isIP, type Socket } from 'node:net';
3
+ import { connect as tlsConnect, type TLSSocket } from 'node:tls';
4
+ import { portOf } from './browser-policy.ts';
5
+
6
+ /*
7
+ * The one send of a request the owner allowed on a card (browser.ts): from here, not from Chromium or Playwright's
8
+ * request context, so what goes is exactly what the card was bound to. Chromium would add the cookies it holds when it
9
+ * sends, and Playwright's route.fetch adds its context's cookies to a request that had none: either could send a
10
+ * session the card never saw. Here there is no cookie jar at all, only the headers given (wireHeaders), no redirect
11
+ * followed and no retry.
12
+ *
13
+ * It goes through the browser's own proxy (a CONNECT tunnel, for http:// too, on a one-time pass: the proxy is sealed
14
+ * for Chromium by then), which checks the sites and every address the name resolves to as it does for Chromium, and
15
+ * cuts the tunnel when the sites change. Over https the
16
+ * certificate is checked against Node's roots (or `ca`, which only tests give, for a certificate of their own).
17
+ *
18
+ * What it reports is what is known about delivery, which is what decides whether trying again could send it twice:
19
+ * - `not-sent`: it failed before a byte of the request left for the site (the proxy refused, the site couldn't be
20
+ * reached, TLS failed, connecting took too long, or it was withdrawn first);
21
+ * - `unknown`: the request was written and then the connection broke, timed out or was cut before an answer: the site
22
+ * may have acted on it;
23
+ * - `sent`: the site answered (its status is known).
24
+ */
25
+
26
+ export interface Sent {
27
+ delivery: 'sent';
28
+ status: number;
29
+ headers: [string, string][];
30
+ /** The answer's body, or null if it was cut or larger than `maxBody` (it was still sent). */
31
+ body: Buffer | null;
32
+ }
33
+
34
+ export type Delivery = Sent | { delivery: 'not-sent'; why: string } | { delivery: 'unknown'; why: string };
35
+
36
+ export interface SendOptions {
37
+ proxy: { port: number; username: string; password: string };
38
+ method: string;
39
+ url: string;
40
+ headers: [string, string][];
41
+ body: Buffer | null;
42
+ /** Checked last, right before the request is written: why it must not go now (the turn ended, the sites changed), or null. */
43
+ ready: () => string | null;
44
+ signal: AbortSignal;
45
+ /** The whole send, from the call: connecting through the proxy, TLS, writing and the whole answer. */
46
+ timeoutMs: number;
47
+ maxBody: number;
48
+ /** Tests only: the certificate their own HTTPS site uses, trusted for this one connection. */
49
+ ca?: string;
50
+ }
51
+
52
+ const why = (err: unknown) => String((err as Error)?.message ?? err).split('\n')[0].slice(0, 200);
53
+
54
+ /**
55
+ * One deadline, `timeoutMs` from the call, covers all of it: the connection to the proxy, its answer to CONNECT, TLS,
56
+ * writing the request and the whole answer. When it passes, or the send is withdrawn, every socket it opened is
57
+ * destroyed at once, whichever step it was waiting on, and what is known of delivery is said.
58
+ */
59
+ export function sendHeld(o: SendOptions): Promise<Delivery> {
60
+ return new Promise<Delivery>((resolve) => {
61
+ const u = new URL(o.url);
62
+ const host = u.hostname.replace(/^\[|\]$/g, '');
63
+ const port = portOf(u);
64
+ /** Everything opened for this send, destroyed when it ends however it ends. */
65
+ const open = new Set<{ destroy(): unknown }>();
66
+ const hold = <T extends { destroy(): unknown; on(e: 'error', f: () => void): unknown }>(x: T): T => {
67
+ open.add(x);
68
+ x.on('error', () => {});
69
+ return x;
70
+ };
71
+ let written = false;
72
+ let settled = false;
73
+ let answered: { status: number; headers: [string, string][] } | null = null;
74
+ const finish = (d: Delivery) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ clearTimeout(timer);
78
+ o.signal.removeEventListener('abort', withdrawn);
79
+ // Newest first: the answer and request, then TLS, then the tunnel beneath it.
80
+ for (const x of [...open].reverse()) x.destroy();
81
+ open.clear();
82
+ resolve(d);
83
+ };
84
+ /** It ended before an answer came in full: what is known depends on how far it got. */
85
+ const broke = (before: string, after: string) =>
86
+ finish(
87
+ answered
88
+ ? { delivery: 'sent', ...answered, body: null }
89
+ : written
90
+ ? { delivery: 'unknown', why: `${after} after it was sent, before the site answered` }
91
+ : { delivery: 'not-sent', why: before },
92
+ );
93
+ const withdrawn = () => broke('it was withdrawn before it was sent', 'it was withdrawn (the browser closed, the turn ended, or the sites changed)');
94
+ const timer = setTimeout(() => broke('the proxy or the site took too long to connect', 'the site took too long'), o.timeoutMs);
95
+ o.signal.addEventListener('abort', withdrawn, { once: true });
96
+ if (o.signal.aborted) return withdrawn();
97
+
98
+ const write = (socket: Socket) => {
99
+ if (settled) return;
100
+ // Checked last, right before the request is written.
101
+ const late = o.ready();
102
+ if (late) return finish({ delivery: 'not-sent', why: late });
103
+ // createConnection with no agent: this one socket, this one request.
104
+ const req: ClientRequest = hold(
105
+ httpRequest({ method: o.method, path: `${u.pathname}${u.search}`, headers: o.headers.flat(), setHost: false, createConnection: () => socket }),
106
+ );
107
+ req.on('error', (err) => broke(`the connection broke (${why(err)})`, `the connection broke (${why(err)})`));
108
+ req.on('response', (res) => {
109
+ hold(res);
110
+ const headers: [string, string][] = [];
111
+ for (let i = 0; i + 1 < res.rawHeaders.length; i += 2) headers.push([res.rawHeaders[i].toLowerCase(), res.rawHeaders[i + 1]]);
112
+ answered = { status: res.statusCode ?? 0, headers };
113
+ const chunks: Buffer[] = [];
114
+ let size = 0;
115
+ let whole = true;
116
+ res.on('data', (c: Buffer) => {
117
+ size += c.length;
118
+ if (size > o.maxBody) {
119
+ whole = false;
120
+ res.destroy();
121
+ } else chunks.push(c);
122
+ });
123
+ const end = () => finish({ delivery: 'sent', status: res.statusCode ?? 0, headers, body: whole && res.complete ? Buffer.concat(chunks) : null });
124
+ res.on('end', end);
125
+ res.on('close', end);
126
+ res.on('error', end);
127
+ });
128
+ written = true;
129
+ req.end(o.body ?? undefined);
130
+ };
131
+
132
+ // A tunnel through the proxy to host:port (for http:// too), on the pass the proxy was given for this send.
133
+ const auth = `Basic ${Buffer.from(`${o.proxy.username}:${o.proxy.password}`).toString('base64')}`;
134
+ const target = `${host.includes(':') ? `[${host}]` : host}:${port}`;
135
+ const connectReq = hold(httpRequest({ host: '127.0.0.1', port: o.proxy.port, method: 'CONNECT', path: target, headers: { host: target, 'proxy-authorization': auth }, agent: false }));
136
+ connectReq.on('connect', (res: IncomingMessage, raw: Socket, head: Buffer) => {
137
+ hold(raw);
138
+ if (settled) return void raw.destroy();
139
+ if (res.statusCode !== 200) return finish({ delivery: 'not-sent', why: `the proxy refused it (HTTP ${res.statusCode})` });
140
+ if (head.length) raw.unshift(head);
141
+ if (u.protocol !== 'https:') return write(raw);
142
+ const tls: TLSSocket = hold(tlsConnect({ socket: raw, host, servername: isIP(host) ? undefined : host, ALPNProtocols: ['http/1.1'], ...(o.ca ? { ca: o.ca } : {}) }));
143
+ tls.once('secureConnect', () => write(tls));
144
+ tls.on('error', (err) => {
145
+ if (!written) finish({ delivery: 'not-sent', why: `TLS with ${u.host} failed: ${why(err)}` });
146
+ });
147
+ });
148
+ connectReq.on('response', (res) => {
149
+ res.resume();
150
+ finish({ delivery: 'not-sent', why: `the proxy refused it (HTTP ${res.statusCode})` });
151
+ });
152
+ connectReq.on('error', (err) => broke(why(err), why(err)));
153
+ connectReq.end();
154
+ });
155
+ }