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,139 @@
|
|
|
1
|
+
import './env-init.ts';
|
|
2
|
+
import { log, setLogLevel } from './log.ts';
|
|
3
|
+
import { type App, createApp } from './app.ts';
|
|
4
|
+
import { VERSION } from './api.ts';
|
|
5
|
+
import { type Config, ConfigError, DataDirError, formatDuration, loadConfig } from './config.ts';
|
|
6
|
+
import { NameUpgradeError, upgradeDataFolder } from './legacy-upgrade.ts';
|
|
7
|
+
import { DataDirInUseError } from './lock.ts';
|
|
8
|
+
import { SchemaError } from './schema.ts';
|
|
9
|
+
import { approveCommand } from './setup.ts';
|
|
10
|
+
|
|
11
|
+
// Node's own warnings as JSON log lines too, so `journalctl ... | jq .` parses every line.
|
|
12
|
+
process.removeAllListeners('warning');
|
|
13
|
+
process.on('warning', (w) => log.warn(w.message, { warning: w.name, code: (w as NodeJS.ErrnoException).code }));
|
|
14
|
+
|
|
15
|
+
let app: App | null = null;
|
|
16
|
+
// The process is in an unknown state: stop the agent processes and let systemd restart a clean server.
|
|
17
|
+
process.on('uncaughtException', (err, origin) => {
|
|
18
|
+
log.error(`fatal ${origin}: stopping running turns and exiting`, { origin, err });
|
|
19
|
+
try {
|
|
20
|
+
app?.engine.stopAll();
|
|
21
|
+
} catch (stopErr) {
|
|
22
|
+
log.error('could not stop running turns', { err: stopErr });
|
|
23
|
+
}
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
// Log and keep serving (instead of Node's default: an uncaught exception).
|
|
27
|
+
process.on('unhandledRejection', (reason) => {
|
|
28
|
+
log.error('unhandled promise rejection', { err: reason });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function config(): Config {
|
|
32
|
+
try {
|
|
33
|
+
return loadConfig();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof DataDirError) {
|
|
36
|
+
// EX_CONFIG: restarting cannot fix it (systemd: RestartPreventExitStatus=78).
|
|
37
|
+
log.error(`Botons cannot start: ${err.message}`);
|
|
38
|
+
process.exit(78);
|
|
39
|
+
}
|
|
40
|
+
if (!(err instanceof ConfigError)) throw err;
|
|
41
|
+
// EX_CONFIG: restarting cannot fix it, so systemd does not retry (RestartPreventExitStatus=78).
|
|
42
|
+
log.error(`Botons cannot start: fix these settings (environment variables) first: ${err.errors.join('; ')}`, { errors: err.errors });
|
|
43
|
+
process.exit(78);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// A data folder or database still under a deprecated name moves to its current one first (legacy-upgrade.ts).
|
|
47
|
+
try {
|
|
48
|
+
for (const line of upgradeDataFolder().lines) log.info(line);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err instanceof DataDirInUseError) {
|
|
51
|
+
// Not EX_CONFIG: the folder frees up when the other server stops, so a retry can succeed.
|
|
52
|
+
log.error(err.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
// A setting problem is reported by config() below; anything else leaves the data where it is, still in use.
|
|
56
|
+
if (!(err instanceof ConfigError)) log.warn(`the data folder keeps its deprecated name for now: ${(err as Error).message}`, { err: err instanceof NameUpgradeError ? undefined : err });
|
|
57
|
+
}
|
|
58
|
+
const cfg = config();
|
|
59
|
+
setLogLevel(cfg.logLevel);
|
|
60
|
+
for (const n of cfg.envNotices) {
|
|
61
|
+
if (n.conflict) log.warn('a setting is set under both its name and a deprecated name, to different values: its name is used', { setting: n.name, deprecated: n.old });
|
|
62
|
+
else log.warn('a setting uses a deprecated name, which works for this release only', { setting: n.name, deprecated: n.old });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function open(): Promise<App> {
|
|
66
|
+
try {
|
|
67
|
+
return createApp(cfg, undefined, { releaseChecks: true });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err instanceof DataDirInUseError) {
|
|
70
|
+
// Not EX_CONFIG: the folder frees up when the other server stops, so a retry can succeed.
|
|
71
|
+
log.error(err.message);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
if (!(err instanceof SchemaError)) throw err;
|
|
75
|
+
// EX_CONFIG: restarting cannot fix it (systemd: RestartPreventExitStatus=78).
|
|
76
|
+
log.error(`Botons cannot open ${cfg.dbPath}: ${err.message}`);
|
|
77
|
+
process.exit(78);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
app = await open();
|
|
81
|
+
const running = app;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await running.listen();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
log.error(`Could not start Botons on port ${cfg.port} (${cfg.hosts.join(', ')}): ${(err as Error).message}`, { err });
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// SIGHUP too: its default (the terminal running `npm start` closed) would skip the drain and leave agent CLIs running.
|
|
91
|
+
let signals = 0;
|
|
92
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
|
93
|
+
process.on(sig, () => {
|
|
94
|
+
signals++;
|
|
95
|
+
if (signals === 2) {
|
|
96
|
+
log.info(`shutdown: ${sig} again: stopping running turns now`);
|
|
97
|
+
running.engine.interruptAll();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (signals > 2) process.exit(1);
|
|
101
|
+
log.info(`shutdown: ${sig}: taking no new work; giving running turns up to ${formatDuration(cfg.drainMs)} to finish (${sig} again stops them now)`);
|
|
102
|
+
running.close({ drainMs: cfg.drainMs }).then(
|
|
103
|
+
({ interrupted }) => {
|
|
104
|
+
log.info(
|
|
105
|
+
interrupted
|
|
106
|
+
? `shutdown: stopped ${interrupted} turn${interrupted === 1 ? '' : 's'} that had not finished; the next start answers cut-off chat messages again (with a note) and marks cut-off routine runs as interrupted`
|
|
107
|
+
: 'shutdown: done',
|
|
108
|
+
{ interrupted },
|
|
109
|
+
);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
},
|
|
112
|
+
(err) => {
|
|
113
|
+
log.error('shutdown failed', { err });
|
|
114
|
+
process.exit(1);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const providers = await running.providers.infos();
|
|
121
|
+
log.info(`Botons ${VERSION}${cfg.build ? ` (${cfg.build.slice(0, 12)})` : ''} is running at ${cfg.urls.join(', ')}`, {
|
|
122
|
+
version: VERSION,
|
|
123
|
+
build: cfg.build,
|
|
124
|
+
urls: cfg.urls,
|
|
125
|
+
home: cfg.home,
|
|
126
|
+
logLevel: cfg.logLevel,
|
|
127
|
+
providers: providers.map((p) => ({ provider: p.id, available: p.available, version: p.version ?? undefined, loggedIn: p.loggedIn ?? undefined })),
|
|
128
|
+
});
|
|
129
|
+
for (const p of providers) {
|
|
130
|
+
if (p.available) continue;
|
|
131
|
+
// A CLI that is not installed may simply be unused.
|
|
132
|
+
(p.installed ? log.warn : log.info)(`${p.label} can't run turns: ${p.reason}`, { provider: p.id, installed: p.installed, loggedIn: p.loggedIn });
|
|
133
|
+
}
|
|
134
|
+
if (!cfg.authEnabled) log.warn('auth is OFF (BOTONS_AUTH=off): anyone who can reach this server can use it, Botons included.');
|
|
135
|
+
else if (!running.auth.claimed()) {
|
|
136
|
+
log.info(`No owner yet: open ${cfg.publicUrl}, which shows a code, and claim this server by approving it with \`${approveCommand(cfg)}\` on this machine (or with one click from your own Tailscale device).`);
|
|
137
|
+
} else if (!running.auth.devices(null).length) {
|
|
138
|
+
log.info(`No device is paired. Open ${cfg.publicUrl}, which shows a code, and approve it here with \`${approveCommand(cfg)}\`.`);
|
|
139
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, basename, resolve, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { installCommand, type InstallType } from '../../shared/install-commands.ts';
|
|
5
|
+
export function installType(container = false, app = resolve(dirname(fileURLToPath(import.meta.url)), '../..')): InstallType {
|
|
6
|
+
if (container) return 'docker';
|
|
7
|
+
return basename(dirname(app)) === 'releases' && existsSync(join(dirname(dirname(app)), 'installer.json')) ? 'managed' : 'service';
|
|
8
|
+
}
|
|
9
|
+
export function serverCommand(action: Parameters<typeof installCommand>[1], args = '', container = process.env.BOTONS_CONTAINER === '1'): string {
|
|
10
|
+
return installCommand(installType(container), action, args);
|
|
11
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readlinkSync, readSync, symlinkSync, unlinkSync, writeSync } from 'node:fs';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, sep } from 'node:path';
|
|
4
|
+
import { heldLookups, heldPath, holdBeneath, holdDir, LinkInTheWay, NoHeldLookups, under, type HeldDir } from './held-dir.ts';
|
|
5
|
+
import { log } from './log.ts';
|
|
6
|
+
|
|
7
|
+
// What agent CLIs load as standing instructions from a workspace: a sandboxed (webhook) turn must not
|
|
8
|
+
// leave any behind, or every later turn of the Boton, Full computer ones included, would follow them.
|
|
9
|
+
export const INSTRUCTION_FILES = ['CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', 'AGENTS.override.md'];
|
|
10
|
+
|
|
11
|
+
/** The paths a sandboxed turn in `cwd` may not write (files or folders; they need not exist). */
|
|
12
|
+
export function instructionPaths(cwd: string): string[] {
|
|
13
|
+
return [...INSTRUCTION_FILES, '.codex', '.agents'].map((name) => join(cwd, name));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const WALK_MAX_DEPTH = 8;
|
|
17
|
+
const WALK_MAX_ENTRIES = 20_000;
|
|
18
|
+
const SNAPSHOT_MAX_BYTES = 1024 * 1024;
|
|
19
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
20
|
+
|
|
21
|
+
type Entry = { kind: 'file'; data: Buffer | null; size: number; mtimeMs: number } | { kind: 'link'; target: string };
|
|
22
|
+
|
|
23
|
+
/** Tests only: called right before each instruction file is read, by its relative path (to change things there). */
|
|
24
|
+
export interface SnapshotHooks {
|
|
25
|
+
beforeRead?: (rel: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The entry `name` in the open folder `dir` (held-dir.ts), never through a link: a link is noted as a link, and a
|
|
30
|
+
* file is read from the descriptor opened, at most SNAPSHOT_MAX_BYTES of it. The turn can swap anything in its
|
|
31
|
+
* workspace meanwhile; what is read is still what was there, in the workspace.
|
|
32
|
+
*/
|
|
33
|
+
function entryIn(dir: HeldDir, name: string): Entry | null {
|
|
34
|
+
const at = under(dir, name);
|
|
35
|
+
let fd: number;
|
|
36
|
+
try {
|
|
37
|
+
// Non-blocking, so a named pipe put there doesn't hang the open; it is not a file, so it is left alone.
|
|
38
|
+
fd = openSync(at, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if ((err as NodeJS.ErrnoException).code !== 'ELOOP') return null;
|
|
41
|
+
try {
|
|
42
|
+
return { kind: 'link', target: readlinkSync(at) };
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const st = fstatSync(fd);
|
|
49
|
+
if (!st.isFile()) return null;
|
|
50
|
+
if (st.size > SNAPSHOT_MAX_BYTES) return { kind: 'file', size: st.size, mtimeMs: st.mtimeMs, data: null };
|
|
51
|
+
const buf = Buffer.alloc(SNAPSHOT_MAX_BYTES + 1);
|
|
52
|
+
let n = 0;
|
|
53
|
+
for (let got = 1; got > 0 && n < buf.length; n += got) got = readSync(fd, buf, n, buf.length - n, null);
|
|
54
|
+
// Grown past the limit since its size was read: too big to keep a copy of.
|
|
55
|
+
if (n > SNAPSHOT_MAX_BYTES) return { kind: 'file', size: n, mtimeMs: st.mtimeMs, data: null };
|
|
56
|
+
return { kind: 'file', size: n, mtimeMs: st.mtimeMs, data: Buffer.from(buf.subarray(0, n)) };
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
} finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function same(a: Entry, b: Entry): boolean {
|
|
65
|
+
if (a.kind === 'link' || b.kind === 'link') return a.kind === 'link' && b.kind === 'link' && a.target === b.target;
|
|
66
|
+
if (a.size !== b.size) return false;
|
|
67
|
+
// Too big to keep a copy of: unchanged if it wasn't written.
|
|
68
|
+
return a.data && b.data ? a.data.equals(b.data) : a.mtimeMs === b.mtimeMs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every instruction file under `root` (by relative path), within the walk's limits. Each folder is opened from the one
|
|
73
|
+
* above it and held open while it is listed and its files read, never through a link (held-dir.ts): the server reads
|
|
74
|
+
* here outside the turn's sandbox, so a folder the turn swapped for a link to one of the owner's is not followed.
|
|
75
|
+
*/
|
|
76
|
+
async function findInstructionFiles(root: string, hooks: SnapshotHooks): Promise<Map<string, Entry>> {
|
|
77
|
+
const found = new Map<string, Entry>();
|
|
78
|
+
let top: HeldDir;
|
|
79
|
+
try {
|
|
80
|
+
top = holdDir(root);
|
|
81
|
+
} catch {
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
let seen = 0;
|
|
85
|
+
const walk = async (dir: HeldDir, rel: string[], depth: number) => {
|
|
86
|
+
let names: import('node:fs').Dirent[];
|
|
87
|
+
try {
|
|
88
|
+
names = await readdir(heldPath(dir), { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const d of names) {
|
|
93
|
+
if (++seen > WALK_MAX_ENTRIES) return;
|
|
94
|
+
const path = [...rel, d.name];
|
|
95
|
+
if (INSTRUCTION_FILES.includes(d.name) && (d.isFile() || d.isSymbolicLink())) {
|
|
96
|
+
hooks.beforeRead?.(join(...path));
|
|
97
|
+
const e = entryIn(dir, d.name);
|
|
98
|
+
if (e) found.set(join(...path), e);
|
|
99
|
+
} else if (d.isDirectory() && depth < WALK_MAX_DEPTH && !SKIP_DIRS.has(d.name)) {
|
|
100
|
+
let sub: HeldDir;
|
|
101
|
+
try {
|
|
102
|
+
sub = holdBeneath(dir, [d.name]);
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await walk(sub, path, depth + 1);
|
|
108
|
+
} finally {
|
|
109
|
+
closeSync(sub.fd);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
await walk(top, [], 0);
|
|
116
|
+
} finally {
|
|
117
|
+
closeSync(top.fd);
|
|
118
|
+
}
|
|
119
|
+
return found;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Puts `e` (or nothing) at `rel`, by folders held open from the workspace down (held-dir.ts): the server does this
|
|
124
|
+
* outside the turn's sandbox, so a folder on the way the turn replaced with a link is not followed but refused.
|
|
125
|
+
*/
|
|
126
|
+
function putAt(top: HeldDir, rel: string, e: Entry | undefined): void {
|
|
127
|
+
const parts = rel.split(sep);
|
|
128
|
+
const name = parts.pop()!;
|
|
129
|
+
let dir: HeldDir;
|
|
130
|
+
try {
|
|
131
|
+
dir = holdBeneath(top, parts, e?.kind === 'file');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Nothing to take away under a folder that isn't there.
|
|
134
|
+
if (!e && (err as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const at = under(dir, name);
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(at);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
143
|
+
}
|
|
144
|
+
if (e?.kind === 'link') symlinkSync(e.target, at);
|
|
145
|
+
else if (e?.kind === 'file' && e.data) {
|
|
146
|
+
// A new file of its own: never through a link put there meanwhile.
|
|
147
|
+
const fd = openSync(at, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o666);
|
|
148
|
+
try {
|
|
149
|
+
for (let n = 0; n < e.data.length; ) n += writeSync(fd, e.data, n, e.data.length - n);
|
|
150
|
+
} finally {
|
|
151
|
+
closeSync(fd);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
if (dir !== top) closeSync(dir.fd);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Note the workspace's instruction files before a sandboxed turn. The function it returns puts back
|
|
161
|
+
* what the turn created, changed or deleted, and returns their relative paths.
|
|
162
|
+
*/
|
|
163
|
+
export async function snapshotInstructions(root: string, hooks: SnapshotHooks = {}): Promise<() => Promise<string[]>> {
|
|
164
|
+
// Without lookups in an open folder (not Linux) nothing is read or put back by path: a swap would redirect it.
|
|
165
|
+
if (!heldLookups) throw new NoHeldLookups();
|
|
166
|
+
const before = await findInstructionFiles(root, hooks);
|
|
167
|
+
return async () => {
|
|
168
|
+
const after = await findInstructionFiles(root, {});
|
|
169
|
+
const undone: string[] = [];
|
|
170
|
+
let top: HeldDir;
|
|
171
|
+
try {
|
|
172
|
+
top = holdDir(root);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
log.warn('could not put back the instruction files a sandboxed turn changed: its workspace could not be opened', { root, err });
|
|
175
|
+
return undone;
|
|
176
|
+
}
|
|
177
|
+
const put = (rel: string, e: Entry | undefined) => {
|
|
178
|
+
const path = join(root, rel);
|
|
179
|
+
try {
|
|
180
|
+
if (e?.kind === 'file' && !e.data) {
|
|
181
|
+
// What the turn left there still goes.
|
|
182
|
+
putAt(top, rel, undefined);
|
|
183
|
+
log.warn('could not put back an instruction file a sandboxed turn changed: it was too big to keep a copy of', { path });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
putAt(top, rel, e);
|
|
187
|
+
undone.push(rel);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (err instanceof LinkInTheWay) log.warn('did not put back an instruction file a sandboxed turn changed: a folder on its way is now a link, which is not followed', { path });
|
|
190
|
+
else log.warn('could not put back an instruction file a sandboxed turn changed', { path, err });
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
for (const [rel, e] of after) {
|
|
195
|
+
const was = before.get(rel);
|
|
196
|
+
if (!was || !same(was, e)) put(rel, was);
|
|
197
|
+
}
|
|
198
|
+
for (const [rel, was] of before) if (!after.has(rel)) put(rel, was);
|
|
199
|
+
} finally {
|
|
200
|
+
closeSync(top.fd);
|
|
201
|
+
}
|
|
202
|
+
return undone;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { CONFIG_DIRS, DATA_DIRS } from '../../shared/legacy-names.mjs';
|
|
4
|
+
import { delimiter, join, resolve, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** Botons run as the server's user, so file modes don't keep these out of a Workspace Boton; this list does. */
|
|
7
|
+
const HOME_SECRETS = [
|
|
8
|
+
'.ssh',
|
|
9
|
+
'.gnupg',
|
|
10
|
+
'.password-store',
|
|
11
|
+
'.local/share/keyrings',
|
|
12
|
+
'.pki',
|
|
13
|
+
'.local/share/pki',
|
|
14
|
+
'.config/op',
|
|
15
|
+
'.config/1Password',
|
|
16
|
+
'.config/Bitwarden',
|
|
17
|
+
'.aws',
|
|
18
|
+
'.azure',
|
|
19
|
+
'.kube',
|
|
20
|
+
'.docker',
|
|
21
|
+
'.netrc',
|
|
22
|
+
'.git-credentials',
|
|
23
|
+
'.config/gh',
|
|
24
|
+
'.config/hub',
|
|
25
|
+
'.config/glab-cli',
|
|
26
|
+
'.config/gcloud',
|
|
27
|
+
'.config/doctl',
|
|
28
|
+
'.config/rclone',
|
|
29
|
+
'.terraform.d/credentials.tfrc.json',
|
|
30
|
+
'.pulumi/credentials.json',
|
|
31
|
+
'.fly',
|
|
32
|
+
'.vault-token',
|
|
33
|
+
'.npmrc',
|
|
34
|
+
'.pypirc',
|
|
35
|
+
'.gem/credentials',
|
|
36
|
+
'.cargo/credentials',
|
|
37
|
+
'.cargo/credentials.toml',
|
|
38
|
+
'.claude.json',
|
|
39
|
+
'.config/anthropic',
|
|
40
|
+
'.config/github-copilot',
|
|
41
|
+
'.gemini',
|
|
42
|
+
'.pi',
|
|
43
|
+
'.hermes',
|
|
44
|
+
'.config/opencode',
|
|
45
|
+
'.local/share/opencode',
|
|
46
|
+
'.t3/userdata',
|
|
47
|
+
'.config/Slack',
|
|
48
|
+
'.config/discord',
|
|
49
|
+
'.config/Signal',
|
|
50
|
+
'.mozilla',
|
|
51
|
+
'.librewolf',
|
|
52
|
+
'.zen',
|
|
53
|
+
'.floorp',
|
|
54
|
+
'.waterfox',
|
|
55
|
+
'.config/google-chrome',
|
|
56
|
+
'.config/google-chrome-beta',
|
|
57
|
+
'.config/google-chrome-unstable',
|
|
58
|
+
'.config/chromium',
|
|
59
|
+
'.config/BraveSoftware',
|
|
60
|
+
'.config/vivaldi',
|
|
61
|
+
'.config/vivaldi-snapshot',
|
|
62
|
+
'.config/microsoft-edge',
|
|
63
|
+
'.config/microsoft-edge-beta',
|
|
64
|
+
'.config/microsoft-edge-dev',
|
|
65
|
+
'.config/opera',
|
|
66
|
+
'.config/opera-beta',
|
|
67
|
+
'.config/thorium',
|
|
68
|
+
'.bash_history',
|
|
69
|
+
'.zsh_history',
|
|
70
|
+
'.histfile',
|
|
71
|
+
'.local/share/fish/fish_history',
|
|
72
|
+
'.local/share/atuin',
|
|
73
|
+
'.python_history',
|
|
74
|
+
'.node_repl_history',
|
|
75
|
+
'.psql_history',
|
|
76
|
+
'.mysql_history',
|
|
77
|
+
'.sqlite_history',
|
|
78
|
+
'.config/botons',
|
|
79
|
+
// Settings folders under deprecated names stay on disk after the upgrade, and may hold secrets.
|
|
80
|
+
...CONFIG_DIRS.map((d) => `.config/${d}`),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** BOTONS_PRIVATE_PATHS: separated like PATH, each absolute, "~/…", or relative to the home folder. */
|
|
84
|
+
export function parsePrivatePaths(value: string | undefined, userHome: string = homedir()): string[] {
|
|
85
|
+
return (value ?? '')
|
|
86
|
+
.split(delimiter)
|
|
87
|
+
.map((p) => p.trim())
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.map((p) => resolve(userHome, p.replace(/^~(?=\/|$)/, '.')));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type AgentCli = 'claude' | 'codex';
|
|
93
|
+
|
|
94
|
+
// What the running CLI's sandboxed commands still need in its own folder; the rest of it, and all of the other CLI's, is private.
|
|
95
|
+
const CLAUDE_KEEP = new Set(['projects', 'shell-snapshots', 'session-env']);
|
|
96
|
+
const CODEX_KEEP = new Set(['tmp', 'shell_snapshots']);
|
|
97
|
+
|
|
98
|
+
/** Claude Code's folder for a working directory's sessions, under ~/.claude/projects. */
|
|
99
|
+
export function claudeProjectDir(cwd: string): string {
|
|
100
|
+
return resolve(cwd).replace(/[^a-zA-Z0-9]/g, '-');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isLink = (p: string) => {
|
|
104
|
+
try {
|
|
105
|
+
return lstatSync(p).isSymbolicLink();
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The data folder's private storage that is made only when first needed (the first backup, the first file deleted to
|
|
113
|
+
* the trash, ...). A Workspace turn's CLI takes its private paths once, as it starts, so a folder made later would not
|
|
114
|
+
* be on them: each is made before the list is taken, empty and private, and is on every list from then on.
|
|
115
|
+
*/
|
|
116
|
+
const RESERVED_HOME_DIRS: readonly string[] = ['backups', 'trash', 'run', 'browser', 'api-sessions', 'templates', 'skill-packages'];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Makes each of RESERVED_HOME_DIRS that is missing, and returns where each really is (a folder of the owner's linked
|
|
120
|
+
* there, say, is listed by the link and by its folder). Throws when one can't be made or isn't a folder: the turn
|
|
121
|
+
* then doesn't start, rather than start without it on its list.
|
|
122
|
+
*/
|
|
123
|
+
function reserveHomeDirs(botonsHome: string): string[] {
|
|
124
|
+
if (!existsSync(botonsHome)) return [];
|
|
125
|
+
const out: string[] = [];
|
|
126
|
+
for (const name of RESERVED_HOME_DIRS) {
|
|
127
|
+
const p = join(botonsHome, name);
|
|
128
|
+
try {
|
|
129
|
+
mkdirSync(p, { mode: 0o700 });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw new Error(`Botons could not make ${p}, which a Workspace turn must have on its private paths before it starts (${(err as NodeJS.ErrnoException).code ?? (err as Error).message})`);
|
|
132
|
+
}
|
|
133
|
+
let real: string;
|
|
134
|
+
try {
|
|
135
|
+
real = realpathSync(p);
|
|
136
|
+
if (!statSync(real).isDirectory()) throw new Error('not a folder');
|
|
137
|
+
} catch {
|
|
138
|
+
throw new Error(`${p} is not a folder, so a Workspace turn can't have it on its private paths; move it aside and Botons makes it again`);
|
|
139
|
+
}
|
|
140
|
+
if (real !== p) out.push(real);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Paths a Workspace Boton must not read: home secrets, BOTONS_PRIVATE_PATHS (`extra`), the CLIs' logins and other
|
|
147
|
+
* sessions, everything in BOTONS_HOME (RESERVED_HOME_DIRS made first) and other workspaces. Only paths that exist, and
|
|
148
|
+
* never one holding the Boton's own workspace or a shared folder.
|
|
149
|
+
*/
|
|
150
|
+
export function privatePaths(input: {
|
|
151
|
+
workspace: string;
|
|
152
|
+
cli: AgentCli | null;
|
|
153
|
+
botonsHome: string;
|
|
154
|
+
workspacesDir: string;
|
|
155
|
+
userHome?: string;
|
|
156
|
+
env?: NodeJS.ProcessEnv;
|
|
157
|
+
extra?: string[];
|
|
158
|
+
shared?: string[];
|
|
159
|
+
}): string[] {
|
|
160
|
+
const home = input.userHome ?? homedir();
|
|
161
|
+
const env = input.env ?? process.env;
|
|
162
|
+
const own = resolve(input.workspace);
|
|
163
|
+
const found = new Set<string>();
|
|
164
|
+
const entries = (dir: string) => {
|
|
165
|
+
try {
|
|
166
|
+
return readdirSync(dir);
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const rel of HOME_SECRETS) found.add(join(home, rel));
|
|
173
|
+
for (const p of input.extra ?? []) found.add(resolve(p));
|
|
174
|
+
|
|
175
|
+
for (const kind of ['codex', 'claude']) {
|
|
176
|
+
const personal = join(home, `.${kind}`);
|
|
177
|
+
if (resolve(env[kind === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR'] || personal) !== personal) found.add(personal);
|
|
178
|
+
}
|
|
179
|
+
const claudeDir = resolve(env.CLAUDE_CONFIG_DIR || join(home, '.claude'));
|
|
180
|
+
if (input.cli === 'claude') {
|
|
181
|
+
for (const e of entries(claudeDir)) if (!CLAUDE_KEEP.has(e)) found.add(join(claudeDir, e));
|
|
182
|
+
const ownProject = claudeProjectDir(own);
|
|
183
|
+
for (const e of entries(join(claudeDir, 'projects'))) if (e !== ownProject) found.add(join(claudeDir, 'projects', e));
|
|
184
|
+
} else found.add(claudeDir);
|
|
185
|
+
|
|
186
|
+
const codexDir = resolve(env.CODEX_HOME || join(home, '.codex'));
|
|
187
|
+
if (input.cli === 'codex') {
|
|
188
|
+
for (const e of entries(codexDir)) if (!CODEX_KEEP.has(e)) found.add(join(codexDir, e));
|
|
189
|
+
} else found.add(codexDir);
|
|
190
|
+
|
|
191
|
+
const botonsHome = resolve(input.botonsHome);
|
|
192
|
+
const workspacesDir = resolve(input.workspacesDir);
|
|
193
|
+
for (const p of reserveHomeDirs(botonsHome)) found.add(p);
|
|
194
|
+
for (const e of entries(botonsHome)) {
|
|
195
|
+
if (e !== 'cli') found.add(join(botonsHome, e));
|
|
196
|
+
else for (const kind of entries(join(botonsHome, e))) {
|
|
197
|
+
const dir = join(botonsHome, e, kind);
|
|
198
|
+
if (dir !== codexDir && dir !== claudeDir) found.add(dir);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const e of entries(workspacesDir)) found.add(join(workspacesDir, e));
|
|
202
|
+
|
|
203
|
+
const open = [own, ...(input.shared ?? []).map((p) => resolve(p))];
|
|
204
|
+
const out = [...found].filter((p) => !open.some((o) => p === o || o.startsWith(p + sep)) && existsSync(p));
|
|
205
|
+
// The data folder by its deprecated default names too (links to it the upgrade leaves), so a path through a
|
|
206
|
+
// link names each private path as well.
|
|
207
|
+
const real = (p: string) => {
|
|
208
|
+
try {
|
|
209
|
+
return realpathSync(p);
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const aliases = DATA_DIRS
|
|
215
|
+
.map((n) => join(home, n))
|
|
216
|
+
.filter((a) => a !== botonsHome && isLink(a) && real(a) !== null && real(a) === real(botonsHome));
|
|
217
|
+
for (const a of aliases) for (const p of [...out]) if (p.startsWith(botonsHome + sep)) out.push(a + p.slice(botonsHome.length));
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Codex masks each non-runtime entry in its config directory separately. Bubblewrap cannot mount a
|
|
223
|
+
* file mask onto a symlink. Inspect metadata only: do not read instructions, skills or credentials,
|
|
224
|
+
* resolve their targets into a different policy, or replace anything in the owner's configuration.
|
|
225
|
+
*/
|
|
226
|
+
export function codexConfigSymlinks(env: NodeJS.ProcessEnv = process.env, userHome = homedir()): string[] {
|
|
227
|
+
const dir = resolve(env.CODEX_HOME || join(userHome, '.codex'));
|
|
228
|
+
if (isLink(dir)) return ['CODEX_HOME'];
|
|
229
|
+
try {
|
|
230
|
+
return readdirSync(dir).filter((name) => !CODEX_KEEP.has(name) && isLink(join(dir, name))).map((name) => `CODEX_HOME/${name}`).sort();
|
|
231
|
+
} catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|