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.
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { BrowserHandoverInput, HandoverKey, HandoverMod } from '../../shared/types.ts';
|
|
2
|
+
import { HANDOVER_KEYS } from '../../shared/util.ts';
|
|
3
|
+
import { HttpError } from './store.ts';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The owner's handover of a Boton's browser, as pure parts: what an input may be, and the limits on how often. The
|
|
7
|
+
* owner signs a Boton's browser in (or looks at a page) themselves, from one paired device, while the Boton can't
|
|
8
|
+
* use it; browser.ts runs it, as a live view (a screencast) the owner drives with fixed inputs a person makes: open an
|
|
9
|
+
* address, back, forward, reload; the pointer's moves, presses and clicks at a point; the wheel; text typed or pasted;
|
|
10
|
+
* one key, with Shift, or with Control only for editing (EDIT_COMBOS); a copy of the owner's own selection; the page's
|
|
11
|
+
* size following the view. Nothing runs the page's script for it, reads its cookies or reaches Chromium's DevTools for
|
|
12
|
+
* the page: the one read is a copy, of the selection alone (never from a password field), in a world of its own the
|
|
13
|
+
* page's script can't reach, and its text goes only to the device that drives it. Typed and pasted text goes into the
|
|
14
|
+
* page and nowhere else: never into a message, the log, the journal, the audit or an error, which say only that text
|
|
15
|
+
* was typed.
|
|
16
|
+
*
|
|
17
|
+
* Network authority while the owner has the browser: it loads reads (GET, HEAD) from the Boton's sites as at any
|
|
18
|
+
* time, and sends what a page sends otherwise (a sign-in form's POST, a script's) only to those sites and only within
|
|
19
|
+
* INTENT_MS of the owner's own press, click, key, typing or paste (never a move, a wheel or a resize): a page's
|
|
20
|
+
* background request (a poll, a beacon) made at any other moment is refused, and nothing goes after the owner
|
|
21
|
+
* finishes. Opening an address, going back or forward and reloading
|
|
22
|
+
* end that moment at once and cut what the page had open, so what a page sends as it unloads is refused too. The
|
|
23
|
+
* proxy decides this for every request as it leaves, HTTPS included (it ends that browser's TLS itself), whatever in
|
|
24
|
+
* Chromium sent it; Chromium's routing also refuses, where it sees them, writes from a worker or from a page the
|
|
25
|
+
* owner is not on. Within the moment the proxy can't tell which of them sent a write: it goes, and is journaled.
|
|
26
|
+
* The Boton has no say in any of it: its browser tools are refused for as long as the handover lasts.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The page's size until the owner's view says otherwise: clicks are in these coordinates, and frames are this size. */
|
|
30
|
+
export const VIEWPORT = { width: 1280, height: 800 } as const;
|
|
31
|
+
/** The sizes the page may take to fit the owner's view (a phone's narrow sheet up to a large screen). */
|
|
32
|
+
export const VIEWPORT_MIN = { width: 320, height: 320 } as const;
|
|
33
|
+
export const VIEWPORT_MAX = { width: 1920, height: 1400 } as const;
|
|
34
|
+
/** The most text one input types, and one paste puts in. */
|
|
35
|
+
export const MAX_OWNER_TEXT = 1000;
|
|
36
|
+
export const MAX_PASTE = 5000;
|
|
37
|
+
/** The most of a selection one copy gives back to the owner's device. */
|
|
38
|
+
export const MAX_COPY = 20_000;
|
|
39
|
+
/** After the owner's click, key or typing, how long what the page sends because of it may go. */
|
|
40
|
+
export const INTENT_MS = 10_000;
|
|
41
|
+
/** A handover with no input and no view open for this long ends on its own, and the Boton has its browser back. */
|
|
42
|
+
export const IDLE_MS = 10 * 60_000;
|
|
43
|
+
/** And it ends after this long, whatever happens: a forgotten tab doesn't keep the Boton's browser. */
|
|
44
|
+
export const MAX_HANDOVER_MS = 30 * 60_000;
|
|
45
|
+
/** At most one polled frame in this long (and one at a time): the fallback when the live view can't stream. */
|
|
46
|
+
export const FRAME_MIN_MS = 200;
|
|
47
|
+
/** At most this many inputs in INPUT_WINDOW_MS: enough to type and move the pointer as a person does. */
|
|
48
|
+
export const MAX_INPUTS = 600;
|
|
49
|
+
export const INPUT_WINDOW_MS = 10_000;
|
|
50
|
+
|
|
51
|
+
const ACTIONS = ['open', 'resume', 'click', 'down', 'up', 'move', 'wheel', 'type', 'paste', 'copy', 'key', 'scroll', 'back', 'forward', 'reload', 'resize'] as const;
|
|
52
|
+
const MODS: readonly HandoverMod[] = ['Shift', 'Control', 'Meta'];
|
|
53
|
+
/**
|
|
54
|
+
* With Control (or Meta, which is Control here): only editing inside the page, never a browser shortcut (address bar,
|
|
55
|
+
* tabs, print, save, find, devtools). Copy and paste are inputs of their own: a copy reads only the owner's selection,
|
|
56
|
+
* a paste types the owner's text.
|
|
57
|
+
*/
|
|
58
|
+
const EDIT_COMBOS = new Set(['a', 'z', 'y', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Backspace', 'Delete', 'Home', 'End']);
|
|
59
|
+
|
|
60
|
+
/** The page's size for a view of `width` × `height` CSS pixels: whole pixels within the bounds. */
|
|
61
|
+
export function fitViewport(width: number, height: number): { width: number; height: number } {
|
|
62
|
+
const fit = (v: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(Number.isFinite(v) ? v : min)));
|
|
63
|
+
return { width: fit(width, VIEWPORT_MIN.width, VIEWPORT_MAX.width), height: fit(height, VIEWPORT_MIN.height, VIEWPORT_MAX.height) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An input as the API received it, checked: a fixed action with values in range, or 400. The messages are fixed
|
|
68
|
+
* words: they never repeat what was sent (it may be a password). Points are in the page's CSS pixels (browser.ts keeps
|
|
69
|
+
* them inside the page's current size).
|
|
70
|
+
*/
|
|
71
|
+
export function parseOwnerInput(raw: unknown): BrowserHandoverInput {
|
|
72
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'Send an input as an object');
|
|
73
|
+
const v = raw as Record<string, unknown>;
|
|
74
|
+
if (typeof v.handover !== 'string' || !/^ho_[A-Za-z0-9_-]{8,40}$/.test(v.handover)) throw new HttpError(400, 'handover must be the id of the handover');
|
|
75
|
+
const handover = v.handover;
|
|
76
|
+
const action = v.action;
|
|
77
|
+
if (typeof action !== 'string' || !(ACTIONS as readonly string[]).includes(action)) throw new HttpError(400, `action must be one of ${ACTIONS.join(', ')}`);
|
|
78
|
+
const int = (x: unknown, min: number, max: number, what: string) => {
|
|
79
|
+
if (typeof x !== 'number' || !Number.isFinite(x) || x < min || x > max) throw new HttpError(400, `${what} must be a number from ${min} to ${max}`);
|
|
80
|
+
return Math.round(x);
|
|
81
|
+
};
|
|
82
|
+
const point = () => ({ x: int(v.x, 0, VIEWPORT_MAX.width - 1, 'x'), y: int(v.y, 0, VIEWPORT_MAX.height - 1, 'y') });
|
|
83
|
+
const button = () => {
|
|
84
|
+
if (v.button === undefined) return 'left' as const;
|
|
85
|
+
if (v.button !== 'left' && v.button !== 'right' && v.button !== 'middle') throw new HttpError(400, 'button must be left, right or middle');
|
|
86
|
+
return v.button;
|
|
87
|
+
};
|
|
88
|
+
const text = (max: number, lines: boolean) => {
|
|
89
|
+
const t = v.text;
|
|
90
|
+
if (typeof t !== 'string' || !t.length) throw new HttpError(400, `${action} needs text`);
|
|
91
|
+
if (t.length > max) throw new HttpError(400, `${action} takes at most ${max} characters at a time`);
|
|
92
|
+
// Keys are pressed with `key`: control characters are refused, not typed (a paste may hold new lines and tabs).
|
|
93
|
+
if ((lines ? /[\u0000-\u0008\u000b-\u001f\u007f]/ : /[\u0000-\u001f\u007f]/).test(t)) {
|
|
94
|
+
throw new HttpError(400, lines ? 'paste takes text without control characters' : 'type takes text without new lines or control characters (press Enter or Tab with key)');
|
|
95
|
+
}
|
|
96
|
+
return t;
|
|
97
|
+
};
|
|
98
|
+
switch (action as (typeof ACTIONS)[number]) {
|
|
99
|
+
case 'open': {
|
|
100
|
+
if (typeof v.url !== 'string' || !v.url.trim() || v.url.length > 2000) throw new HttpError(400, 'open needs an address (at most 2000 characters)');
|
|
101
|
+
return { handover, action: 'open', url: v.url.trim() };
|
|
102
|
+
}
|
|
103
|
+
case 'click': {
|
|
104
|
+
const count = v.count === undefined ? 1 : int(v.count, 1, 3, 'count');
|
|
105
|
+
return { handover, action: 'click', ...point(), ...(count > 1 ? { count } : {}) };
|
|
106
|
+
}
|
|
107
|
+
case 'down':
|
|
108
|
+
case 'up': {
|
|
109
|
+
// The press's count (a double or triple click is the second or third press in a row).
|
|
110
|
+
const count = v.count === undefined ? 1 : int(v.count, 1, 3, 'count');
|
|
111
|
+
return { handover, action: action as 'down' | 'up', ...point(), button: button(), ...(count > 1 ? { count } : {}) };
|
|
112
|
+
}
|
|
113
|
+
case 'move':
|
|
114
|
+
return { handover, action: 'move', ...point() };
|
|
115
|
+
case 'wheel':
|
|
116
|
+
return { handover, action: 'wheel', ...point(), dx: int(v.dx ?? 0, -4000, 4000, 'dx'), dy: int(v.dy ?? 0, -4000, 4000, 'dy') };
|
|
117
|
+
case 'type':
|
|
118
|
+
return { handover, action: 'type', text: text(MAX_OWNER_TEXT, false) };
|
|
119
|
+
case 'paste':
|
|
120
|
+
return { handover, action: 'paste', text: text(MAX_PASTE, true) };
|
|
121
|
+
case 'copy':
|
|
122
|
+
return { handover, action: 'copy' };
|
|
123
|
+
case 'resume':
|
|
124
|
+
return { handover, action: 'resume' };
|
|
125
|
+
case 'key': {
|
|
126
|
+
const key = v.key;
|
|
127
|
+
const named = typeof key === 'string' && HANDOVER_KEYS.includes(key as HandoverKey);
|
|
128
|
+
if (!named && !(typeof key === 'string' && /^[a-z]$/.test(key))) throw new HttpError(400, `key must be one of ${HANDOVER_KEYS.join(', ')}, or a letter with Control`);
|
|
129
|
+
const raw = v.mods === undefined ? [] : v.mods;
|
|
130
|
+
if (!Array.isArray(raw) || raw.length > MODS.length || raw.some((m) => !MODS.includes(m as HandoverMod)) || new Set(raw).size !== raw.length) {
|
|
131
|
+
throw new HttpError(400, `mods must be some of ${MODS.join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
const mods = raw as HandoverMod[];
|
|
134
|
+
const control = mods.includes('Control') || mods.includes('Meta');
|
|
135
|
+
if (control && !EDIT_COMBOS.has(key as string)) throw new HttpError(400, 'That shortcut is not sent to the page: only editing ones (select all, undo, redo, word and line moves)');
|
|
136
|
+
if (!named && !control) throw new HttpError(400, 'Letters are typed with type, not pressed with key');
|
|
137
|
+
if (key === 'Shift+Tab' && mods.length) throw new HttpError(400, 'Shift+Tab takes no other keys');
|
|
138
|
+
return { handover, action: 'key', key: key as HandoverKey | string, ...(mods.length ? { mods } : {}) } as BrowserHandoverInput;
|
|
139
|
+
}
|
|
140
|
+
case 'scroll':
|
|
141
|
+
return { handover, action: 'scroll', dy: int(v.dy, -4000, 4000, 'dy') };
|
|
142
|
+
case 'back':
|
|
143
|
+
return { handover, action: 'back' };
|
|
144
|
+
case 'forward':
|
|
145
|
+
return { handover, action: 'forward' };
|
|
146
|
+
case 'reload':
|
|
147
|
+
return { handover, action: 'reload' };
|
|
148
|
+
case 'resize':
|
|
149
|
+
return { handover, action: 'resize', width: int(v.width, 1, 10_000, 'width'), height: int(v.height, 1, 10_000, 'height') };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The key as Playwright presses it, its modifiers first (Meta is Control here: the owner's Mac shortcut on Chromium for Linux). */
|
|
154
|
+
export const pressed = (k: string, mods: readonly HandoverMod[] = []): string => {
|
|
155
|
+
const key = k === 'Space' ? ' ' : k;
|
|
156
|
+
const held = [...new Set(mods.map((m) => (m === 'Meta' ? 'Control' : m)))].sort((a, b) => MODS.indexOf(a) - MODS.indexOf(b));
|
|
157
|
+
return [...held, key].join('+');
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** At most `limit` takes in any `ms`. */
|
|
161
|
+
export class RateWindow {
|
|
162
|
+
#times: number[] = [];
|
|
163
|
+
#limit: number;
|
|
164
|
+
#ms: number;
|
|
165
|
+
constructor(limit: number, ms: number) {
|
|
166
|
+
this.#limit = limit;
|
|
167
|
+
this.#ms = ms;
|
|
168
|
+
}
|
|
169
|
+
take(now = Date.now()): boolean {
|
|
170
|
+
this.#times = this.#times.filter((t) => now - t < this.#ms);
|
|
171
|
+
if (this.#times.length >= this.#limit) return false;
|
|
172
|
+
this.#times.push(now);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import type { AccessLevel, BrowserPolicy, BrowserPolicyInput } from '../../shared/types.ts';
|
|
3
|
+
import { BROWSER_CONNECTOR, MAX_BROWSER_SITES } from '../../shared/util.ts';
|
|
4
|
+
import { allowance, refusedAddress } from './ssrf.ts';
|
|
5
|
+
import { HttpError } from './store.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The managed browser's rules, as pure functions: which sites a Boton's browser may reach, and that it only reads
|
|
9
|
+
* (GET and HEAD). The browser (browser.ts) enforces them where the Boton can't reach: in the server's own proxy,
|
|
10
|
+
* which every request the browser makes goes through, and in its request routing, before a request leaves Chromium.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const NO_BROWSER: BrowserPolicy = { sites: [], updatedAt: null };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A new Boton's browser (blank, from a template or a starter): on, for any public site. "*" never covers this machine
|
|
17
|
+
* or a private network (covers), and every write still waits on the owner's card.
|
|
18
|
+
*/
|
|
19
|
+
export const NEW_BOTON_SITES = ['*'];
|
|
20
|
+
|
|
21
|
+
/** A new Boton's connectors with the built-in browser among them (first, as the catalog lists it). */
|
|
22
|
+
export function withBrowser(ids: string[]): string[] {
|
|
23
|
+
return ids.includes(BROWSER_CONNECTOR) ? ids : [BROWSER_CONNECTOR, ...ids];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a brain can run the managed browser: only an agent CLI's turns drive it (an API key brain's tools can't). */
|
|
27
|
+
export const drivesBrowser = (provider: { cli?: unknown } | null | undefined): boolean => !!provider?.cli;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A new Boton's connectors: the ones the request names, exactly; otherwise `saved` (a template's, those this server
|
|
31
|
+
* has) with the browser, when its brain can run one (drivesBrowser), and without it when it can't.
|
|
32
|
+
*/
|
|
33
|
+
export const newBotonConnections = (asked: string[] | undefined, saved: string[], browser: boolean): string[] =>
|
|
34
|
+
asked ?? (browser ? withBrowser(saved) : saved.filter((id) => id !== BROWSER_CONNECTOR));
|
|
35
|
+
|
|
36
|
+
/** Whether a Boton given the browser has one: not on a server found to have no Chromium (its turns go without it). */
|
|
37
|
+
export const browserUsable = (enabled: boolean, known: { available: boolean } | null): boolean => enabled && known?.available !== false;
|
|
38
|
+
|
|
39
|
+
const MAX_SITES = MAX_BROWSER_SITES;
|
|
40
|
+
const HOST = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
41
|
+
const LOCAL_NAME = /(^|\.)localhost$/;
|
|
42
|
+
|
|
43
|
+
/** A site entry: "*", "example.com" (and its subdomains), or "host:port" (exactly that address). */
|
|
44
|
+
export interface Site {
|
|
45
|
+
any: boolean;
|
|
46
|
+
host: string;
|
|
47
|
+
port: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseHostPort(entry: string): { host: string; port: number | null } | null {
|
|
51
|
+
const v6 = /^\[([0-9a-f:.]+)\](?::(\d{1,5}))?$/.exec(entry);
|
|
52
|
+
if (v6) return isIP(v6[1]) === 6 ? { host: v6[1], port: v6[2] ? Number(v6[2]) : null } : null;
|
|
53
|
+
const m = /^([^:/\s]+)(?::(\d{1,5}))?$/.exec(entry);
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
const host = m[1].replace(/\.$/, '');
|
|
56
|
+
if (!isIP(host) && !HOST.test(host)) return null;
|
|
57
|
+
const port = m[2] ? Number(m[2]) : null;
|
|
58
|
+
if (port !== null && (port < 1 || port > 65535)) return null;
|
|
59
|
+
return { host, port };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseSite(raw: string): Site | null {
|
|
63
|
+
const entry = raw.trim().toLowerCase();
|
|
64
|
+
if (entry === '*') return { any: true, host: '', port: null };
|
|
65
|
+
const hp = parseHostPort(entry.replace(/^\*\./, ''));
|
|
66
|
+
return hp ? { any: false, ...hp } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Literal loopback, private or reserved addresses, and localhost names: the host's own services. */
|
|
70
|
+
export function isPrivateHost(host: string): boolean {
|
|
71
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
|
72
|
+
if (LOCAL_NAME.test(h)) return true;
|
|
73
|
+
if (isIP(h)) return refusedAddress(h, allowance([])) !== null;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const siteText = (s: Site): string => (s.any ? '*' : `${s.host.includes(':') ? `[${s.host}]` : s.host}${s.port ? `:${s.port}` : ''}`);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether `site` covers host:port. A name covers itself and its subdomains on any port; an address only itself;
|
|
81
|
+
* with a port, only that port. "*" covers any public host, never a private one.
|
|
82
|
+
*/
|
|
83
|
+
export function covers(site: Site, host: string, port: number): boolean {
|
|
84
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
85
|
+
if (site.any) return !isPrivateHost(h);
|
|
86
|
+
if (site.port !== null && site.port !== port) return false;
|
|
87
|
+
if (h === site.host) return true;
|
|
88
|
+
return !isIP(site.host) && !isIP(h) && h.endsWith(`.${site.host}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The entry that lets the browser reach host:port, or null. A private host needs an entry that names its port. */
|
|
92
|
+
export function siteFor(sites: string[], host: string, port: number): Site | null {
|
|
93
|
+
const priv = isPrivateHost(host);
|
|
94
|
+
for (const raw of sites) {
|
|
95
|
+
const s = parseSite(raw);
|
|
96
|
+
if (!s || !covers(s, host, port)) continue;
|
|
97
|
+
if (priv && s.port === null) continue;
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether a site entry may name loopback or private addresses: only with a port, and for a Workspace Boton only one the
|
|
105
|
+
* operator allowed. "*" is any public site at either access: it never covers a private one.
|
|
106
|
+
*/
|
|
107
|
+
function siteProblem(s: Site, access: AccessLevel, allowPrivate: string[]): string | null {
|
|
108
|
+
if (s.any) return null;
|
|
109
|
+
if (!isPrivateHost(s.host)) return null;
|
|
110
|
+
if (s.port === null) return `${siteText(s)} is this machine or a private network: name it with its port (host:port)`;
|
|
111
|
+
if (access !== 'host' && !allowPrivate.includes(siteText(s))) {
|
|
112
|
+
return `${siteText(s)} is this machine or a private network, which a Workspace Boton's browser can't reach (BOTONS_BROWSER_ALLOW can allow one address)`;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function cleanSites(v: unknown, field: string, access: AccessLevel, allowPrivate: string[]): string[] {
|
|
118
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) throw new HttpError(400, `${field} must be a list of sites such as example.com`);
|
|
119
|
+
const out: string[] = [];
|
|
120
|
+
for (const raw of v as string[]) {
|
|
121
|
+
if (!raw.trim()) continue;
|
|
122
|
+
const s = parseSite(raw);
|
|
123
|
+
if (!s) throw new HttpError(400, `${field}: "${raw.slice(0, 100)}" is not a site: use a host name such as example.com, or host:port`);
|
|
124
|
+
const problem = siteProblem(s, access, allowPrivate);
|
|
125
|
+
if (problem) throw new HttpError(400, `${field}: ${problem}`);
|
|
126
|
+
const text = siteText(s);
|
|
127
|
+
if (!out.includes(text)) out.push(text);
|
|
128
|
+
}
|
|
129
|
+
if (out.length > MAX_SITES) throw new HttpError(400, `${field}: at most ${MAX_SITES} sites`);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The owner's change, checked against the Boton's access. */
|
|
134
|
+
export function applyPolicy(before: BrowserPolicy, input: BrowserPolicyInput, access: AccessLevel, allowPrivate: string[]): BrowserPolicy {
|
|
135
|
+
if (!input || typeof input !== 'object') throw new HttpError(400, 'Send the browser settings as an object');
|
|
136
|
+
const sites = input.sites === undefined ? before.sites : cleanSites(input.sites, 'sites', access, allowPrivate);
|
|
137
|
+
return { sites, updatedAt: new Date().toISOString() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The sites as they apply under `access` now: a Boton moved to Workspace access loses the private addresses the
|
|
142
|
+
* operator didn't allow, without the owner having to edit its list.
|
|
143
|
+
*/
|
|
144
|
+
export function effectiveSites(sites: string[], access: AccessLevel, allowPrivate: string[]): string[] {
|
|
145
|
+
return sites.filter((raw) => {
|
|
146
|
+
const s = parseSite(raw);
|
|
147
|
+
return !!s && siteProblem(s, access, allowPrivate) === null;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parsePolicy(raw: string | undefined): BrowserPolicy {
|
|
152
|
+
if (!raw) return { ...NO_BROWSER };
|
|
153
|
+
try {
|
|
154
|
+
const v = JSON.parse(raw);
|
|
155
|
+
const list = (x: unknown) => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string') : []);
|
|
156
|
+
return { sites: list(v.sites), updatedAt: typeof v.updatedAt === 'string' ? v.updatedAt : null };
|
|
157
|
+
} catch {
|
|
158
|
+
return { ...NO_BROWSER };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What the browser may do with one request: load it, or refuse it with why (`write`: a write to an allowed site, which a card may let through once). */
|
|
163
|
+
export type RequestVerdict = { ok: true } | { ok: false; why: string; write?: true };
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The browser reads on its own: anything else (a form, a script's POST, a beacon, a CORS preflight for one) is refused
|
|
167
|
+
* before it leaves Chromium, and again in the proxy for plain HTTP. The one way past is the owner's card: while the
|
|
168
|
+
* Boton clicks or types, the first write to an allowed site is held, shown in full and sent once if allowed
|
|
169
|
+
* (browser.ts, browser-request.ts).
|
|
170
|
+
*/
|
|
171
|
+
export const READ_METHODS = new Set(['GET', 'HEAD']);
|
|
172
|
+
|
|
173
|
+
/** Requests that change something, which the browser sends only through a card. Anything else not a read is refused. */
|
|
174
|
+
export const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
175
|
+
|
|
176
|
+
export function portOf(u: URL): number {
|
|
177
|
+
return u.port ? Number(u.port) : u.protocol === 'https:' || u.protocol === 'wss:' ? 443 : 80;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const hostOf = (u: URL) => u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
181
|
+
|
|
182
|
+
/** For the journal and the cards: never a query, fragment or credentials, which may be secrets. */
|
|
183
|
+
export function displayUrl(raw: string): string {
|
|
184
|
+
try {
|
|
185
|
+
const u = new URL(raw);
|
|
186
|
+
if (u.protocol === 'about:' || u.protocol === 'data:' || u.protocol === 'blob:') return `${u.protocol}…`;
|
|
187
|
+
return `${u.protocol}//${u.host}${u.pathname}${u.search ? '?…' : ''}`;
|
|
188
|
+
} catch {
|
|
189
|
+
return '(not a URL)';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A request the page makes: only http(s) to allowed sites, and only reads. Where the browser is off, see browser.ts. */
|
|
194
|
+
export function judgeRequest(raw: string, method: string, sites: string[]): RequestVerdict {
|
|
195
|
+
let u: URL;
|
|
196
|
+
try {
|
|
197
|
+
u = new URL(raw);
|
|
198
|
+
} catch {
|
|
199
|
+
return { ok: false, why: 'not a URL' };
|
|
200
|
+
}
|
|
201
|
+
if (u.protocol === 'data:' || u.protocol === 'blob:' || u.protocol === 'about:') return { ok: true };
|
|
202
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, why: `${u.protocol} addresses are not for the browser` };
|
|
203
|
+
if (u.username || u.password) return { ok: false, why: 'an address with a user name or password in it' };
|
|
204
|
+
const host = hostOf(u);
|
|
205
|
+
const port = portOf(u);
|
|
206
|
+
if (!siteFor(sites, host, port)) return { ok: false, why: `${u.host} is not one of the allowed sites` };
|
|
207
|
+
const m = method.toUpperCase();
|
|
208
|
+
if (!READ_METHODS.has(m)) return { ok: false, why: `a ${m} request to ${u.host}: the browser only reads pages, unless you allow one on a card`, ...(WRITE_METHODS.has(m) ? { write: true as const } : {}) };
|
|
209
|
+
return { ok: true };
|
|
210
|
+
}
|