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,473 @@
|
|
|
1
|
+
import { serverCommand } from './install-type.ts';
|
|
2
|
+
import { managedBin, managedRoot } from './managed-clis.ts';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { accessSync, constants, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { homedir, hostname } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { isValidTimezone } from '../../shared/util.ts';
|
|
10
|
+
import { applyLegacyEnv, DATA_DIRS, earlierEnvNames, isLegacySetting, type LegacyEnvNotice } from '../../shared/legacy-names.mjs';
|
|
11
|
+
import { dbFileIn } from './backup.ts';
|
|
12
|
+
import { parsePrivatePaths } from './isolation.ts';
|
|
13
|
+
import { LOG_LEVELS, type LogLevel } from './log.ts';
|
|
14
|
+
import { type TailscaleSelf, tailscaleSelfSync } from './tailnet.ts';
|
|
15
|
+
|
|
16
|
+
// Node builtins only: scripts/backup.ts imports this module and must run without node_modules.
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const appRoot = resolve(here, '../..');
|
|
20
|
+
|
|
21
|
+
let buildCache: string | null | undefined;
|
|
22
|
+
/** The sha scripts/deploy.ts wrote to release.json, else the checkout's HEAD. */
|
|
23
|
+
export function buildId(root = appRoot): string | null {
|
|
24
|
+
if (root === appRoot && buildCache !== undefined) return buildCache;
|
|
25
|
+
let id: string | null = null;
|
|
26
|
+
try {
|
|
27
|
+
const sha = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).sha;
|
|
28
|
+
if (typeof sha === 'string' && sha) id = sha;
|
|
29
|
+
} catch {
|
|
30
|
+
try {
|
|
31
|
+
id = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
if (root === appRoot) buildCache = id;
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CallbacksConfig {
|
|
39
|
+
/** Hosts, addresses and CIDR ranges callbacks may reach although they are private or this machine's own. */
|
|
40
|
+
allow: string[];
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
/** The waits before each retry. */
|
|
43
|
+
retryDelaysMs: number[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Config {
|
|
47
|
+
home: string;
|
|
48
|
+
/** Why `home` is that folder (see chooseHome). */
|
|
49
|
+
homeSource: HomeSource;
|
|
50
|
+
/** Settings given under a deprecated name (see shared/legacy-names.mjs). */
|
|
51
|
+
envNotices: LegacyEnvNotice[];
|
|
52
|
+
dbPath: string;
|
|
53
|
+
workspacesDir: string;
|
|
54
|
+
sharedDir: string;
|
|
55
|
+
templatesDir: string;
|
|
56
|
+
/** Skill packages the owner puts here (a folder each, with a SKILL.md) can be reviewed and installed. Nothing in it is run. */
|
|
57
|
+
skillPackagesDir: string;
|
|
58
|
+
agentsDir: string;
|
|
59
|
+
backupsDir: string;
|
|
60
|
+
port: number;
|
|
61
|
+
/** Always includes loopback. */
|
|
62
|
+
hosts: string[];
|
|
63
|
+
tailscale: {
|
|
64
|
+
/** False with BOTONS_NO_TAILSCALE=1: no `tailscale` calls at all. */
|
|
65
|
+
enabled: boolean;
|
|
66
|
+
/** bind: listen on the Tailscale address too; names: only use its names (BOTONS_HOST already reaches the tailnet). */
|
|
67
|
+
mode: 'bind' | 'names' | 'off';
|
|
68
|
+
pollMs: number;
|
|
69
|
+
initial: TailscaleSelf;
|
|
70
|
+
};
|
|
71
|
+
/** These two follow the tailnet (see applyTailnet). */
|
|
72
|
+
publicUrl: string;
|
|
73
|
+
urls: string[];
|
|
74
|
+
publicUrlOverride: string | null;
|
|
75
|
+
allowedHosts: string[];
|
|
76
|
+
hostname: string;
|
|
77
|
+
defaultTimezone: string;
|
|
78
|
+
maxConcurrentTurns: number;
|
|
79
|
+
turnTimeoutMs: number;
|
|
80
|
+
approvalWaitMs: number;
|
|
81
|
+
webDist: string;
|
|
82
|
+
botonBinDir: string;
|
|
83
|
+
enableMock: boolean;
|
|
84
|
+
schedulerIntervalMs: number;
|
|
85
|
+
claudeBin: string;
|
|
86
|
+
codexBin: string;
|
|
87
|
+
drainMs: number;
|
|
88
|
+
build: string | null;
|
|
89
|
+
privatePaths: string[];
|
|
90
|
+
ssePingMs: number;
|
|
91
|
+
authEnabled: boolean;
|
|
92
|
+
ownerLogin: string | null;
|
|
93
|
+
routineRunsPerHour: number;
|
|
94
|
+
runsPerHour: number;
|
|
95
|
+
runRetries: number;
|
|
96
|
+
runRetryDelayMs: number;
|
|
97
|
+
logLevel: LogLevel;
|
|
98
|
+
/** The hooks-only listener, for the internet (Tailscale Funnel). */
|
|
99
|
+
hooks: { port: number | null; hosts: string[]; publicUrl: string | null; timeoutMs: number };
|
|
100
|
+
callbacks: CallbacksConfig;
|
|
101
|
+
/** OAuth connectors: the HTTPS base that reaches /oauth/callback (a Tailscale Serve path, say), and private hosts their servers may be on. */
|
|
102
|
+
oauth: { publicUrl: string | null; allow: string[] };
|
|
103
|
+
asksPerPairPerHour: number;
|
|
104
|
+
asksPerHour: number;
|
|
105
|
+
/** BOTONS_CONTAINER=1 (the Docker image): callers arrive through the bridge network or a sidecar. */
|
|
106
|
+
container: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* The managed browser: the Chromium to run (BOTONS_BROWSER_PATH; empty: look for one), whether it shows a window
|
|
109
|
+
* (BOTONS_BROWSER_HEADED=1, on a desktop host), whether Chromium's own sandbox is on (BOTONS_BROWSER_SANDBOX=0
|
|
110
|
+
* turns it off, where a host can't start it), and loopback or private host:port addresses a Workspace
|
|
111
|
+
* Boton's allowed sites may name (BOTONS_BROWSER_ALLOW; a Full computer Boton may name any).
|
|
112
|
+
*/
|
|
113
|
+
browser: { path: string; headed: boolean; sandbox: boolean; allowPrivate: string[] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The server reports every bad setting at once and exits 78 (EX_CONFIG). */
|
|
117
|
+
/** BOTONS_HOME cannot be created or written by this user: a restart cannot fix it, its owner or mode must change. */
|
|
118
|
+
export class DataDirError extends Error {
|
|
119
|
+
constructor(home: string, err: NodeJS.ErrnoException) {
|
|
120
|
+
super(
|
|
121
|
+
`The data folder ${home} is not writable by this user (${err.code}: ${err.message}). Nothing was changed. Run the server as the user that owns it, or give that user write access to it (for example chmod u+rwx on the folder), then start it again; \`${serverCommand('doctor')}\` checks it.`,
|
|
122
|
+
);
|
|
123
|
+
this.name = 'DataDirError';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class ConfigError extends Error {
|
|
128
|
+
readonly errors: string[];
|
|
129
|
+
constructor(errors: string[]) {
|
|
130
|
+
super(`Invalid configuration:\n${errors.map((e) => `- ${e}`).join('\n')}`);
|
|
131
|
+
this.name = 'ConfigError';
|
|
132
|
+
this.errors = errors;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The default data folder, under the user's home. */
|
|
137
|
+
export const DEFAULT_HOME = '.botons';
|
|
138
|
+
|
|
139
|
+
/** BOTONS_HOME, the default ~/.botons, or a data folder still under an earlier name (shared/legacy-names.mjs) before its first start here moves it. */
|
|
140
|
+
export type HomeSource = 'BOTONS_HOME' | 'default' | 'legacy';
|
|
141
|
+
|
|
142
|
+
export interface HomeChoice {
|
|
143
|
+
home: string;
|
|
144
|
+
source: HomeSource;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Neither folder can be chosen without risking the wrong data: two data folders exist, or two settings disagree. */
|
|
148
|
+
export class HomeConflictError extends ConfigError {
|
|
149
|
+
constructor(text: string) {
|
|
150
|
+
super([text]);
|
|
151
|
+
this.name = 'HomeConflictError';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const lstatOrNull = (p: string) => {
|
|
156
|
+
try {
|
|
157
|
+
return lstatSync(p);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const realOrNull = (p: string) => {
|
|
163
|
+
try {
|
|
164
|
+
return realpathSync(p);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The data folder, and why it is that one. Nothing here creates, moves or links anything.
|
|
172
|
+
* - BOTONS_HOME (or a deprecated name for it); two of them naming different folders is refused. A deprecated
|
|
173
|
+
* default folder named there that now links to ~/.botons counts as ~/.botons.
|
|
174
|
+
* - Otherwise ~/.botons. Before an install's first start under this name moves it there (server/src/legacy-upgrade.ts),
|
|
175
|
+
* its folder may still be under an earlier default name: that one, then. Two unrelated folders are refused.
|
|
176
|
+
*/
|
|
177
|
+
export function chooseHome(env: NodeJS.ProcessEnv = process.env, home = env.HOME?.trim() || homedir()): HomeChoice {
|
|
178
|
+
const fresh = join(home, DEFAULT_HOME);
|
|
179
|
+
const same = (p: string) => realOrNull(p) ?? resolve(p);
|
|
180
|
+
const named = ['BOTONS_HOME', ...earlierEnvNames('BOTONS_HOME')].flatMap((k) => (env[k]?.trim() ? [[k, resolve(env[k]!.trim())] as const] : []));
|
|
181
|
+
const other = named.find(([, p]) => same(p) !== same(named[0][1]));
|
|
182
|
+
if (other) {
|
|
183
|
+
throw new HomeConflictError(`${named[0][0]} (${named[0][1]}) and ${other[0]} (${other[1]}) name different data folders. Set only BOTONS_HOME, to the folder that holds this server's data. Nothing was changed.`);
|
|
184
|
+
}
|
|
185
|
+
const earlier = DATA_DIRS.map((d) => join(home, d));
|
|
186
|
+
if (named.length) {
|
|
187
|
+
const p = named[0][1];
|
|
188
|
+
// A deprecated default folder that the upgrade moved (and left a link at) is ~/.botons by its own name.
|
|
189
|
+
if (earlier.includes(p) && lstatOrNull(p)?.isSymbolicLink() && lstatOrNull(fresh) && realOrNull(p) === realOrNull(fresh)) return { home: fresh, source: 'BOTONS_HOME' };
|
|
190
|
+
// Or one a stopped upgrade moved before it could leave the link: never a new, empty folder in its place.
|
|
191
|
+
if (earlier.includes(p) && !lstatOrNull(p) && lstatOrNull(fresh)?.isDirectory()) return { home: fresh, source: 'BOTONS_HOME' };
|
|
192
|
+
return { home: p, source: 'BOTONS_HOME' };
|
|
193
|
+
}
|
|
194
|
+
const freshReal = lstatOrNull(fresh) ? realOrNull(fresh) : null;
|
|
195
|
+
const legacy = [...new Set(earlier.filter((p) => lstatOrNull(p)).map((p) => realOrNull(p)).filter((p): p is string => p !== null && p !== freshReal))];
|
|
196
|
+
if (legacy.length > 1 || (legacy.length === 1 && lstatOrNull(fresh))) {
|
|
197
|
+
const both = lstatOrNull(fresh) ? [fresh, legacy[0]] : legacy;
|
|
198
|
+
throw new HomeConflictError(
|
|
199
|
+
`Both ${both[0]} and ${both[1]} exist, and neither links to the other, so which holds this server's data is not clear. Set BOTONS_HOME to the one to use (and move the other away). Nothing was changed.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (legacy.length === 1) return { home: legacy[0], source: 'legacy' };
|
|
203
|
+
return { home: fresh, source: 'default' };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function botonsHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
207
|
+
return chooseHome(env).home;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A server or turn setting (BOTONS_*, BOTON_*), under its name or a deprecated one: env files and copies of the environment keep these. */
|
|
211
|
+
export const isBotonsSetting = (name: string) => /^BOTONS?_[A-Z0-9_]+$/.test(name) || isLegacySetting(name);
|
|
212
|
+
|
|
213
|
+
/** Empty counts as unset. */
|
|
214
|
+
function read(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
|
215
|
+
const v = env[name]?.trim();
|
|
216
|
+
return v ? v : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** scripts/install-service.ts sets the unit's TimeoutStopSec above it. */
|
|
220
|
+
export const DEFAULT_DRAIN_MS = 2 * 60_000;
|
|
221
|
+
|
|
222
|
+
const UNITS: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
223
|
+
|
|
224
|
+
/** "20m", "90s", "1.5h", "250ms", or bare milliseconds; else NaN. */
|
|
225
|
+
export function parseDuration(raw: string): number {
|
|
226
|
+
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i.exec(raw.trim());
|
|
227
|
+
return m ? Math.round(Number(m[1]) * UNITS[(m[2] ?? 'ms').toLowerCase()]) : NaN;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function formatDuration(ms: number): string {
|
|
231
|
+
for (const [unit, size] of [['d', 86_400_000], ['h', 3_600_000], ['m', 60_000], ['s', 1000]] as const) {
|
|
232
|
+
if (ms >= size && ms % size === 0) return `${ms / size}${unit}`;
|
|
233
|
+
}
|
|
234
|
+
return `${ms}ms`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Anything else is an error: a typo must not silently leave auth on or off.
|
|
238
|
+
const AUTH_VALUES = ['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'] as const;
|
|
239
|
+
const AUTH_OFF = new Set<string>(['off', 'false', '0', 'no']);
|
|
240
|
+
|
|
241
|
+
/** Addresses that let Botons reach the server at http://127.0.0.1:<port>. */
|
|
242
|
+
const LOOPBACK_OK = new Set(['127.0.0.1', '0.0.0.0', '::']);
|
|
243
|
+
const isLoopback = (h: string) => h === 'localhost' || h === '::1' || h.startsWith('127.');
|
|
244
|
+
|
|
245
|
+
/** Throws ConfigError before creating any folder. `create: false` (for `${serverCommand('doctor')}`) creates none at all. */
|
|
246
|
+
export function loadConfig(given: NodeJS.ProcessEnv = process.env, probe: () => TailscaleSelf = tailscaleSelfSync, opts: { create?: boolean } = {}): Config {
|
|
247
|
+
const errors: string[] = [];
|
|
248
|
+
const env: NodeJS.ProcessEnv = { ...given };
|
|
249
|
+
const envNotices = applyLegacyEnv(env);
|
|
250
|
+
const int = (name: string, fallback: number, min: number, max: number): number => {
|
|
251
|
+
const raw = read(env, name);
|
|
252
|
+
if (raw === undefined) return fallback;
|
|
253
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
254
|
+
if (!Number.isSafeInteger(n)) errors.push(`${name} must be a whole number; got "${raw}"`);
|
|
255
|
+
else if (n < min || n > max) errors.push(`${name} must be between ${min} and ${max}; got ${n}`);
|
|
256
|
+
else return n;
|
|
257
|
+
return fallback;
|
|
258
|
+
};
|
|
259
|
+
const duration = (name: string, fallback: number, min: number, max: number): number => {
|
|
260
|
+
const raw = read(env, name);
|
|
261
|
+
if (raw === undefined) return fallback;
|
|
262
|
+
const ms = parseDuration(raw);
|
|
263
|
+
if (Number.isNaN(ms)) errors.push(`${name} must be a duration such as 20m, 90s or 1200000 (milliseconds); got "${raw}"`);
|
|
264
|
+
else if (ms < min || ms > max) errors.push(`${name} must be between ${formatDuration(min)} and ${formatDuration(max)}; got "${raw}"`);
|
|
265
|
+
else return ms;
|
|
266
|
+
return fallback;
|
|
267
|
+
};
|
|
268
|
+
const choice = <T extends string>(name: string, options: readonly T[], fallback: T): T => {
|
|
269
|
+
const raw = read(env, name);
|
|
270
|
+
if (raw === undefined) return fallback;
|
|
271
|
+
const v = raw.toLowerCase();
|
|
272
|
+
if ((options as readonly string[]).includes(v)) return v as T;
|
|
273
|
+
errors.push(`${name} must be one of ${options.join(', ')}; got "${raw}"`);
|
|
274
|
+
return fallback;
|
|
275
|
+
};
|
|
276
|
+
const { home, source: homeSource } = chooseHome(env);
|
|
277
|
+
|
|
278
|
+
// BOTONS_PORT wins over PORT, which other tools set. Not 0: every listener and Boton uses cfg.port.
|
|
279
|
+
const portVar = read(env, 'BOTONS_PORT') !== undefined ? 'BOTONS_PORT' : 'PORT';
|
|
280
|
+
const port = int(portVar, 7777, 1, 65535);
|
|
281
|
+
|
|
282
|
+
// Default: loopback plus the Tailscale address (bound by the app), so the tailnet reaches it but not the LAN.
|
|
283
|
+
const hostVar = read(env, 'BOTONS_HOST');
|
|
284
|
+
const hosts = hostVar ? hostVar.split(',').map((h) => h.trim()).filter(Boolean) : ['127.0.0.1'];
|
|
285
|
+
if (hostVar && !hosts.some((h) => LOOPBACK_OK.has(h))) {
|
|
286
|
+
errors.push(`BOTONS_HOST must include 127.0.0.1 (or 0.0.0.0 or ::), because Botons reach the server at http://127.0.0.1:<port>; got "${hostVar}"`);
|
|
287
|
+
}
|
|
288
|
+
const noTailscale = env.BOTONS_NO_TAILSCALE === '1';
|
|
289
|
+
const mode: Config['tailscale']['mode'] = noTailscale ? 'off' : !hostVar ? 'bind' : hosts.some((h) => !isLoopback(h)) ? 'names' : 'off';
|
|
290
|
+
const pollMs = duration('BOTONS_TAILSCALE_POLL_MS', 20_000, 100, 3_600_000);
|
|
291
|
+
|
|
292
|
+
const tzVar = read(env, 'BOTONS_TZ');
|
|
293
|
+
if (tzVar && !isValidTimezone(tzVar)) errors.push(`BOTONS_TZ is not a timezone this system knows (an IANA name such as America/New_York); got "${tzVar}"`);
|
|
294
|
+
const defaultTimezone = tzVar && isValidTimezone(tzVar) ? tzVar : (Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC');
|
|
295
|
+
|
|
296
|
+
const baseUrl = (name: string): string | null => {
|
|
297
|
+
const raw = read(env, name);
|
|
298
|
+
if (!raw) return null;
|
|
299
|
+
try {
|
|
300
|
+
const u = new URL(raw);
|
|
301
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('not http');
|
|
302
|
+
return raw.replace(/\/+$/, '');
|
|
303
|
+
} catch {
|
|
304
|
+
errors.push(`${name} must be an http:// or https:// URL; got "${raw}"`);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const publicUrlOverride = baseUrl('BOTONS_PUBLIC_URL');
|
|
309
|
+
|
|
310
|
+
const hooksPort = read(env, 'BOTONS_HOOKS_PORT') === undefined ? null : int('BOTONS_HOOKS_PORT', 0, 1, 65535) || null;
|
|
311
|
+
if (hooksPort !== null && hooksPort === port) errors.push(`BOTONS_HOOKS_PORT must differ from the main port (${port}); got ${hooksPort}`);
|
|
312
|
+
const hooksHostVar = read(env, 'BOTONS_HOOKS_HOST');
|
|
313
|
+
const hooksHosts = hooksHostVar ? hooksHostVar.split(',').map((h) => h.trim()).filter(Boolean) : ['127.0.0.1'];
|
|
314
|
+
const hooksPublicUrl = baseUrl('BOTONS_HOOKS_PUBLIC_URL');
|
|
315
|
+
const allowList = (name: string): string[] => {
|
|
316
|
+
const list = (read(env, name) ?? '')
|
|
317
|
+
.split(',')
|
|
318
|
+
.map((h) => h.trim().toLowerCase())
|
|
319
|
+
.filter(Boolean);
|
|
320
|
+
for (const entry of list) {
|
|
321
|
+
const [ip, bits, ...extra] = entry.split('/');
|
|
322
|
+
const family = isIP(ip);
|
|
323
|
+
const max = family === 6 ? 128 : 32;
|
|
324
|
+
if (bits === undefined ? /[/:\s]/.test(entry) && !family : !family || extra.length || !/^\d{1,3}$/.test(bits) || Number(bits) > max) {
|
|
325
|
+
errors.push(`${name} entries must be host names, IP addresses or CIDR ranges such as 100.64.0.0/10; got "${entry}"`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return list;
|
|
329
|
+
};
|
|
330
|
+
const callbackAllow = allowList('BOTONS_CALLBACK_ALLOW');
|
|
331
|
+
const oauthAllow = allowList('BOTONS_OAUTH_ALLOW');
|
|
332
|
+
const browserAllow = (read(env, 'BOTONS_BROWSER_ALLOW') ?? '')
|
|
333
|
+
.split(',')
|
|
334
|
+
.map((h) => h.trim().toLowerCase())
|
|
335
|
+
.filter(Boolean);
|
|
336
|
+
for (const entry of browserAllow) {
|
|
337
|
+
if (!/^(\[[0-9a-f:.]+\]|[a-z0-9.-]+):\d{1,5}$/.test(entry)) errors.push(`BOTONS_BROWSER_ALLOW entries must be host:port, such as 127.0.0.1:8080; got "${entry}"`);
|
|
338
|
+
}
|
|
339
|
+
// Only a bare HTTPS origin: the redirect URI is exactly <it>/oauth/callback, and the app itself stays on HTTP.
|
|
340
|
+
const oauthRaw = read(env, 'BOTONS_OAUTH_PUBLIC_URL');
|
|
341
|
+
let oauthPublicUrl: string | null = null;
|
|
342
|
+
if (oauthRaw) {
|
|
343
|
+
let u: URL | null = null;
|
|
344
|
+
try {
|
|
345
|
+
u = new URL(oauthRaw);
|
|
346
|
+
} catch {}
|
|
347
|
+
if (!u || u.protocol !== 'https:' || u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) {
|
|
348
|
+
errors.push(`BOTONS_OAUTH_PUBLIC_URL must be an https:// origin with no path, such as https://myhost.example-tailnet.ts.net; got "${oauthRaw}"`);
|
|
349
|
+
} else oauthPublicUrl = u.origin;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const maxConcurrentTurns = int('BOTONS_MAX_CONCURRENT', 3, 1, 64);
|
|
353
|
+
const turnTimeoutMs = duration('BOTONS_TURN_TIMEOUT_MS', 20 * 60_000, 1000, 24 * 3_600_000);
|
|
354
|
+
const approvalWaitMs = Math.min(duration('BOTONS_APPROVAL_WAIT', 5 * 60_000, 1000, 3_600_000), Math.floor(turnTimeoutMs / 2));
|
|
355
|
+
const schedulerIntervalMs = duration('BOTONS_SCHEDULER_MS', 10_000, 50, 3_600_000);
|
|
356
|
+
const drainMs = duration('BOTONS_DRAIN_MS', DEFAULT_DRAIN_MS, 0, 30 * 60_000);
|
|
357
|
+
const ssePingMs = duration('BOTONS_SSE_PING_MS', 25_000, 1000, 3_600_000);
|
|
358
|
+
const routineRunsPerHour = int('BOTONS_ROUTINE_RUNS_PER_HOUR', 30, 1, 100_000);
|
|
359
|
+
const runsPerHour = int('BOTONS_RUNS_PER_HOUR', 100, 1, 100_000);
|
|
360
|
+
const runRetries = int('BOTONS_RUN_RETRIES', 3, 0, 10);
|
|
361
|
+
const runRetryDelayMs = duration('BOTONS_RUN_RETRY_DELAY', 60_000, 10, 3_600_000);
|
|
362
|
+
const logLevel = choice('BOTONS_LOG_LEVEL', LOG_LEVELS, 'info');
|
|
363
|
+
const auth = choice('BOTONS_AUTH', AUTH_VALUES, 'on');
|
|
364
|
+
const asksPerPairPerHour = int('BOTONS_ASKS_PER_PAIR_PER_HOUR', 20, 1, 100_000);
|
|
365
|
+
const asksPerHour = int('BOTONS_ASKS_PER_HOUR', 100, 1, 100_000);
|
|
366
|
+
|
|
367
|
+
if (errors.length) throw new ConfigError(errors);
|
|
368
|
+
|
|
369
|
+
const workspacesDir = join(home, 'workspaces');
|
|
370
|
+
const sharedDir = join(home, 'shared');
|
|
371
|
+
if (opts.create !== false) {
|
|
372
|
+
try {
|
|
373
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
374
|
+
accessSync(home, constants.W_OK | constants.X_OK);
|
|
375
|
+
mkdirSync(workspacesDir, { recursive: true });
|
|
376
|
+
mkdirSync(sharedDir, { recursive: true });
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (['EACCES', 'EPERM', 'EROFS'].includes((err as NodeJS.ErrnoException).code ?? '')) throw new DataDirError(home, err as NodeJS.ErrnoException);
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const cfg: Config = {
|
|
384
|
+
home,
|
|
385
|
+
homeSource,
|
|
386
|
+
envNotices,
|
|
387
|
+
dbPath: dbFileIn(home),
|
|
388
|
+
workspacesDir,
|
|
389
|
+
sharedDir,
|
|
390
|
+
templatesDir: join(home, 'templates'),
|
|
391
|
+
skillPackagesDir: join(home, 'skill-packages'),
|
|
392
|
+
agentsDir: join(home, 'run', 'agents'),
|
|
393
|
+
backupsDir: join(home, 'backups'),
|
|
394
|
+
port,
|
|
395
|
+
hosts,
|
|
396
|
+
tailscale: { enabled: !noTailscale, mode, pollMs, initial: mode === 'off' ? { ip: null, dnsName: null } : probe() },
|
|
397
|
+
publicUrl: '',
|
|
398
|
+
urls: [],
|
|
399
|
+
publicUrlOverride,
|
|
400
|
+
allowedHosts: (read(env, 'BOTONS_ALLOWED_HOSTS') ?? '')
|
|
401
|
+
.split(',')
|
|
402
|
+
.map((h) => h.trim().toLowerCase())
|
|
403
|
+
.filter(Boolean),
|
|
404
|
+
hostname: hostname(),
|
|
405
|
+
defaultTimezone,
|
|
406
|
+
maxConcurrentTurns,
|
|
407
|
+
turnTimeoutMs,
|
|
408
|
+
approvalWaitMs,
|
|
409
|
+
webDist: resolve(here, '../../web/dist'),
|
|
410
|
+
botonBinDir: resolve(here, '../bin'),
|
|
411
|
+
enableMock: env.BOTONS_ENABLE_MOCK === '1',
|
|
412
|
+
schedulerIntervalMs,
|
|
413
|
+
claudeBin: managedBin(home, 'claude-cli', read(env, 'BOTONS_CLAUDE_BIN') ?? 'claude', managedRoot(env.HOME), env.PATH),
|
|
414
|
+
codexBin: managedBin(home, 'codex-cli', read(env, 'BOTONS_CODEX_BIN') ?? 'codex', managedRoot(env.HOME), env.PATH),
|
|
415
|
+
drainMs,
|
|
416
|
+
build: buildId(),
|
|
417
|
+
privatePaths: parsePrivatePaths(env.BOTONS_PRIVATE_PATHS),
|
|
418
|
+
ssePingMs,
|
|
419
|
+
authEnabled: !AUTH_OFF.has(auth),
|
|
420
|
+
ownerLogin: read(env, 'BOTONS_OWNER_LOGIN') ?? null,
|
|
421
|
+
routineRunsPerHour,
|
|
422
|
+
runsPerHour,
|
|
423
|
+
runRetries,
|
|
424
|
+
runRetryDelayMs,
|
|
425
|
+
logLevel,
|
|
426
|
+
hooks: { port: hooksPort, hosts: hooksHosts, publicUrl: hooksPublicUrl, timeoutMs: 30_000 },
|
|
427
|
+
callbacks: {
|
|
428
|
+
allow: callbackAllow,
|
|
429
|
+
timeoutMs: 10_000,
|
|
430
|
+
retryDelaysMs: [5_000, 30_000, 120_000],
|
|
431
|
+
},
|
|
432
|
+
oauth: { publicUrl: oauthPublicUrl, allow: oauthAllow },
|
|
433
|
+
browser: { path: read(env, 'BOTONS_BROWSER_PATH') ?? '', headed: env.BOTONS_BROWSER_HEADED === '1', sandbox: env.BOTONS_BROWSER_SANDBOX !== '0', allowPrivate: browserAllow },
|
|
434
|
+
asksPerPairPerHour,
|
|
435
|
+
asksPerHour,
|
|
436
|
+
container: env.BOTONS_CONTAINER === '1',
|
|
437
|
+
};
|
|
438
|
+
// Only a candidate until the server has bound it, but at startup it nearly always binds.
|
|
439
|
+
applyTailnet(cfg, cfg.tailscale.initial);
|
|
440
|
+
return cfg;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Recompute `urls` and `publicUrl` when the Tailscale address appears or changes; everything reads cfg live. */
|
|
444
|
+
export function applyTailnet(cfg: Config, ts: TailscaleSelf): void {
|
|
445
|
+
const urls: string[] = [];
|
|
446
|
+
if (ts.dnsName) urls.push(`http://${ts.dnsName}:${cfg.port}`);
|
|
447
|
+
if (ts.ip) urls.push(`http://${ts.ip}:${cfg.port}`);
|
|
448
|
+
for (const h of cfg.hosts) {
|
|
449
|
+
if (h === '0.0.0.0' || h === '::') urls.push(`http://${cfg.hostname}:${cfg.port}`);
|
|
450
|
+
else if (!urls.some((u) => u.includes(`//${h}:`))) urls.push(`http://${h.includes(':') ? `[${h}]` : h}:${cfg.port}`);
|
|
451
|
+
}
|
|
452
|
+
cfg.urls = [...new Set(urls)];
|
|
453
|
+
cfg.publicUrl = cfg.publicUrlOverride ?? cfg.urls[0] ?? `http://127.0.0.1:${cfg.port}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Besides IP literals, which are always accepted. */
|
|
457
|
+
export function allowedHostNames(cfg: Config): Set<string> {
|
|
458
|
+
const host = (u: string) => new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
459
|
+
return new Set(
|
|
460
|
+
[
|
|
461
|
+
'localhost',
|
|
462
|
+
cfg.hostname.toLowerCase(),
|
|
463
|
+
// And the short MagicDNS name, e.g. "myhost".
|
|
464
|
+
...cfg.urls.flatMap((u) => [host(u), host(u).split('.')[0]]),
|
|
465
|
+
host(cfg.publicUrl),
|
|
466
|
+
// A proxy may send the public webhook name to the main port too.
|
|
467
|
+
...(cfg.hooks.publicUrl ? [host(cfg.hooks.publicUrl)] : []),
|
|
468
|
+
// The proxy in front of /oauth/callback sends its own name.
|
|
469
|
+
...(cfg.oauth.publicUrl ? [host(cfg.oauth.publicUrl)] : []),
|
|
470
|
+
...cfg.allowedHosts,
|
|
471
|
+
].filter(Boolean),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ConnectorAccess, ConnectorAccessInput, ConnectorEnabledInput } from '../../shared/types.ts';
|
|
2
|
+
import type { Audit } from './audit.ts';
|
|
3
|
+
import type { Principal } from './auth.ts';
|
|
4
|
+
import { type Connectors, countedConnections, MAX_CONNECTIONS } from './connectors.ts';
|
|
5
|
+
import { log } from './log.ts';
|
|
6
|
+
import { HttpError, type Store } from './store.ts';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* One connector's side of the grants: which Botons have it, which may ask for it and which can't use it, set from
|
|
10
|
+
* the connector rather than one Boton at a time. A Boton's connectors are still its own list (bots.connections);
|
|
11
|
+
* this reads and writes that list, so a turn gets exactly what a Boton's settings would show.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AccessDeps {
|
|
15
|
+
connectors: Connectors;
|
|
16
|
+
store: Pick<Store, 'listBots' | 'atomically' | 'setConnections'>;
|
|
17
|
+
audit: Audit;
|
|
18
|
+
/** The turns running now that were given this connector (Engine.turnsWith). */
|
|
19
|
+
running?: (id: string) => { botId: string; turnId: string }[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function connectorOf(deps: AccessDeps, id: string) {
|
|
23
|
+
const c = deps.connectors.catalog().connectors.find((x) => x.id === id);
|
|
24
|
+
if (!c) throw new HttpError(404, `No connector ${id}`);
|
|
25
|
+
return c;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What was done to this connector and to who has it: its own events, and every grant naming it. */
|
|
29
|
+
function historyOf(deps: AccessDeps, id: string) {
|
|
30
|
+
const name = id.slice(id.indexOf(':') + 1);
|
|
31
|
+
const targets = [{ kind: 'connector', id }];
|
|
32
|
+
if (id.startsWith('lib:')) targets.push({ kind: 'mcp_server', id: name });
|
|
33
|
+
const rec = id.startsWith('oauth:') ? deps.connectors.oauth?.byName(name) : null;
|
|
34
|
+
if (rec) targets.push({ kind: 'oauth', id: rec.id });
|
|
35
|
+
return deps.audit.about(targets, id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function connectorAccess(deps: AccessDeps, id: string): ConnectorAccess {
|
|
39
|
+
const c = connectorOf(deps, id);
|
|
40
|
+
const botons = deps.store
|
|
41
|
+
.listBots()
|
|
42
|
+
.map((b) => {
|
|
43
|
+
const has = b.connections.includes(id);
|
|
44
|
+
const brainCanUse = c.providers.includes(b.provider);
|
|
45
|
+
return {
|
|
46
|
+
botId: b.id,
|
|
47
|
+
name: b.name,
|
|
48
|
+
provider: b.provider,
|
|
49
|
+
has,
|
|
50
|
+
mayAsk: !has && brainCanUse && !c.managed && c.onRequest && c.enabled,
|
|
51
|
+
brainCanUse,
|
|
52
|
+
withoutReason: has ? deps.connectors.withoutReason(b.id, id) : null,
|
|
53
|
+
};
|
|
54
|
+
})
|
|
55
|
+
.sort((a, b) => Number(b.has) - Number(a.has) || a.name.localeCompare(b.name));
|
|
56
|
+
const names = new Map(botons.map((m) => [m.botId, m.name]));
|
|
57
|
+
return {
|
|
58
|
+
connectorId: id,
|
|
59
|
+
name: c.name,
|
|
60
|
+
managed: !!c.managed,
|
|
61
|
+
onRequest: c.onRequest,
|
|
62
|
+
grant: c.grant,
|
|
63
|
+
enabled: c.enabled,
|
|
64
|
+
off: c.off,
|
|
65
|
+
botons,
|
|
66
|
+
running: (deps.running?.(id) ?? []).flatMap((t) => (names.has(t.botId) ? [{ ...t, name: names.get(t.botId)! }] : [])),
|
|
67
|
+
history: historyOf(deps, id),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ids = (v: unknown, field: string, what = 'Boton ids'): string[] => {
|
|
72
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) throw new HttpError(400, `${field} must be a list of ${what}`);
|
|
73
|
+
return [...new Set(v as string[])];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Gives the connector to exactly `botIds` and takes it off every other Boton, all at once or not at all. Refused if
|
|
78
|
+
* who had it is not what the owner was shown, for the managed browser (given in each Boton's settings, where its
|
|
79
|
+
* sites are), and for a Boton whose brain can't use it. Each Boton changed is audited as its own grant change.
|
|
80
|
+
*/
|
|
81
|
+
export function setConnectorAccess(deps: AccessDeps, id: string, input: ConnectorAccessInput, by: Principal | null): ConnectorAccess {
|
|
82
|
+
const c = connectorOf(deps, id);
|
|
83
|
+
if (c.managed) throw new HttpError(400, `${c.name} is given in each Boton's settings, under Browser, where its sites are`);
|
|
84
|
+
const wanted = ids(input?.botIds, 'botIds');
|
|
85
|
+
const expected = ids(input?.expected, 'expected');
|
|
86
|
+
const changed = deps.store.atomically(() => {
|
|
87
|
+
const bots = deps.store.listBots();
|
|
88
|
+
const byId = new Map(bots.map((b) => [b.id, b]));
|
|
89
|
+
const unknown = wanted.find((b) => !byId.has(b));
|
|
90
|
+
if (unknown) throw new HttpError(400, `No Boton with id ${unknown}`);
|
|
91
|
+
const current = bots.filter((b) => b.connections.includes(id)).map((b) => b.id);
|
|
92
|
+
const same = (a: string[], b: string[]) => a.length === b.length && a.every((x) => b.includes(x));
|
|
93
|
+
if (!same(current, expected)) throw new HttpError(409, `Which Botons have ${c.name} changed since you looked: review it again`);
|
|
94
|
+
const out: { botId: string; name: string; added: boolean }[] = [];
|
|
95
|
+
for (const b of bots) {
|
|
96
|
+
const add = wanted.includes(b.id) && !b.connections.includes(id);
|
|
97
|
+
const remove = !wanted.includes(b.id) && b.connections.includes(id);
|
|
98
|
+
if (add) {
|
|
99
|
+
if (!c.providers.includes(b.provider)) throw new HttpError(400, `${b.name}'s brain can't use connectors`);
|
|
100
|
+
if (countedConnections(b.connections) >= MAX_CONNECTIONS) throw new HttpError(400, `${b.name} already has ${MAX_CONNECTIONS} connectors, the most a Boton can have`);
|
|
101
|
+
deps.store.setConnections(b.id, [...b.connections, id]);
|
|
102
|
+
} else if (remove) deps.store.setConnections(b.id, b.connections.filter((x) => x !== id));
|
|
103
|
+
if (add || remove) out.push({ botId: b.id, name: b.name, added: add });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
});
|
|
107
|
+
for (const m of changed) {
|
|
108
|
+
deps.audit.record(by, {
|
|
109
|
+
action: 'boton.permissions_changed',
|
|
110
|
+
target: { kind: 'boton', id: m.botId, label: m.name },
|
|
111
|
+
details: { [m.added ? 'connectionsAdded' : 'connectionsRemoved']: [id], via: 'connector' },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (changed.length) {
|
|
115
|
+
log.info("connector's Botons changed", { connector: id, added: changed.filter((m) => m.added).map((m) => m.botId), removed: changed.filter((m) => !m.added).map((m) => m.botId) });
|
|
116
|
+
}
|
|
117
|
+
return connectorAccess(deps, id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Turns the connector off or on for every Boton at once, keeping its setup, sign-in and grants. Refused if what the
|
|
122
|
+
* owner was shown (on or off, and who had it) is not how it is now, and for the managed browser. Turning it off doesn't
|
|
123
|
+
* reach into a turn already running with it, each of which keeps it until it ends, so it is refused as well while a
|
|
124
|
+
* turn runs with it that the owner wasn't shown (by turn, not by Boton: a Boton's next turn is a new one). One shown
|
|
125
|
+
* that has ended since is fine: what they were told still covers everything that keeps it. The state is compared, not
|
|
126
|
+
* a count of switches: should it have been turned off and on again meanwhile, what they were shown is still exactly
|
|
127
|
+
* true (who has it, what runs with it), and a card from before that is refused by the request-policy generation anyway.
|
|
128
|
+
* Audited once, with the Botons that had it.
|
|
129
|
+
*/
|
|
130
|
+
export function setConnectorEnabled(deps: AccessDeps, id: string, input: ConnectorEnabledInput, by: Principal | null): ConnectorAccess {
|
|
131
|
+
const c = connectorOf(deps, id);
|
|
132
|
+
if (c.managed) throw new HttpError(400, `${c.name} is turned on and off in each Boton's settings, under Browser`);
|
|
133
|
+
if (typeof input?.enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false');
|
|
134
|
+
if (input.reason !== undefined && (typeof input.reason !== 'string' || input.reason.length > 200)) throw new HttpError(400, 'reason must be at most 200 characters');
|
|
135
|
+
if (!input.expected || typeof input.expected !== 'object' || typeof input.expected.enabled !== 'boolean') throw new HttpError(400, 'expected must say whether it was on, and which Botons had it');
|
|
136
|
+
const expected = ids(input.expected.botIds, 'expected.botIds');
|
|
137
|
+
const shown = new Set(ids(input.expected.running, 'expected.running', 'turn ids'));
|
|
138
|
+
const result = deps.store.atomically(() => {
|
|
139
|
+
const now = connectorOf(deps, id);
|
|
140
|
+
const had = deps.store
|
|
141
|
+
.listBots()
|
|
142
|
+
.filter((b) => b.connections.includes(id))
|
|
143
|
+
.map((b) => ({ id: b.id, name: b.name }));
|
|
144
|
+
const same = had.length === expected.length && had.every((b) => expected.includes(b.id));
|
|
145
|
+
// In the same transaction (and tick) as the switch: no turn starts with it in between, and none after it is off.
|
|
146
|
+
const unseen = (deps.running?.(id) ?? []).filter((t) => !shown.has(t.turnId));
|
|
147
|
+
if (now.enabled !== input.expected.enabled || !same || unseen.length) throw new HttpError(409, `${c.name} changed since you looked: review it again`);
|
|
148
|
+
return { changed: deps.connectors.setEnabled(id, input.enabled, input.reason ?? ''), had };
|
|
149
|
+
});
|
|
150
|
+
if (result.changed) {
|
|
151
|
+
deps.audit.record(by, {
|
|
152
|
+
action: input.enabled ? 'connector.enabled' : 'connector.disabled',
|
|
153
|
+
target: { kind: 'connector', id, label: c.name },
|
|
154
|
+
details: { botons: result.had.map((b) => b.name), botonIds: result.had.map((b) => b.id), reason: input.enabled ? undefined : input.reason?.trim() || undefined },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return connectorAccess(deps, id);
|
|
158
|
+
}
|