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,382 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, closeSync, lstatSync, constants, copyFileSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import type { BackupStatus, BackupSummary } from '../../shared/types.ts';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { DB_FILE } from '../../shared/legacy-names.mjs';
|
|
7
|
+
|
|
8
|
+
export const KEEP_BACKUPS = 14;
|
|
9
|
+
|
|
10
|
+
/** The timer backs up daily, so a newest backup older than two days means it stopped. */
|
|
11
|
+
export const BACKUP_STALE_MS = 2 * 86_400_000;
|
|
12
|
+
|
|
13
|
+
/** How many of the newest backups the owner's status lists. */
|
|
14
|
+
export const BACKUPS_LISTED = KEEP_BACKUPS;
|
|
15
|
+
|
|
16
|
+
/** The folders of BOTONS_HOME archived besides the database (not run/, nor backups/). browser/ holds each Boton's browser profile. */
|
|
17
|
+
export const ARCHIVED_FOLDERS: readonly string[] = ['workspaces', 'shared', 'templates', 'browser'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Left out of a browser profile: Chromium's lock links (a restore refuses links, and they name the old process) and
|
|
21
|
+
* its caches and its own home folder, which it rebuilds. Cookies and site storage stay. An owner's handover profile
|
|
22
|
+
* (browser/<boton>/handover) is never archived: it holds what the owner typed, and is deleted as the handover ends.
|
|
23
|
+
*/
|
|
24
|
+
const BROWSER_EXCLUDES = ['browser/*/handover', 'browser/*/home', 'browser/*/profile/Singleton*', 'browser/*/profile/*Cache*', 'browser/*/profile/Default/*Cache*', 'browser/*/profile/Default/Service Worker', 'browser/*/profile/Crashpad'];
|
|
25
|
+
|
|
26
|
+
/** A backup folder's name is its UTC time, so names sort oldest first. */
|
|
27
|
+
const BACKUP_NAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
|
28
|
+
|
|
29
|
+
/** What a backup that died part-way leaves behind; group 1 is when it started. */
|
|
30
|
+
const PARTIAL_NAME = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.partial$/;
|
|
31
|
+
|
|
32
|
+
export const stamp = (d: Date = new Date()): string => d.toISOString().replace(/[:.]/g, '-');
|
|
33
|
+
|
|
34
|
+
/** Backups hold chat history and webhook keys. */
|
|
35
|
+
function privateDir(dir: string): string {
|
|
36
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
chmodSync(dir, 0o700);
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface BackupManifest {
|
|
42
|
+
createdAt: string;
|
|
43
|
+
schemaVersion: number;
|
|
44
|
+
counts: Record<string, number>;
|
|
45
|
+
workspaces: boolean;
|
|
46
|
+
folders?: string[];
|
|
47
|
+
tarWarning?: string;
|
|
48
|
+
workspacesError?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BackupResult {
|
|
52
|
+
dir: string;
|
|
53
|
+
manifest: BackupManifest;
|
|
54
|
+
removed: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The database file in a data folder or a backup: botons.db, or its deprecated name until the upgrade renames it (shared/legacy-names.mjs). */
|
|
58
|
+
export function dbFileIn(dir: string): string {
|
|
59
|
+
const now = join(dir, 'botons.db');
|
|
60
|
+
if (existsSync(now)) return now;
|
|
61
|
+
const old = join(dir, DB_FILE);
|
|
62
|
+
return existsSync(old) ? old : now;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lstatOrNull = (p: string) => {
|
|
66
|
+
try {
|
|
67
|
+
return lstatSync(p);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Back up `home` into backups/<time>/ (named only once complete), then keep the newest KEEP_BACKUPS.
|
|
75
|
+
* If tar fails the database copy is still kept, and nothing is pruned so older backups with workspaces stay.
|
|
76
|
+
*/
|
|
77
|
+
export function backup(opts: { home: string; now?: Date }): BackupResult {
|
|
78
|
+
const now = opts.now ?? new Date();
|
|
79
|
+
const dbPath = dbFileIn(opts.home);
|
|
80
|
+
if (!existsSync(dbPath)) throw new Error(`No database at ${dbPath}`);
|
|
81
|
+
const backupsDir = privateDir(join(opts.home, 'backups'));
|
|
82
|
+
const dir = join(backupsDir, stamp(now));
|
|
83
|
+
if (existsSync(dir)) throw new Error(`${dir} already exists`);
|
|
84
|
+
const partial = `${dir}.partial`;
|
|
85
|
+
rmSync(partial, { recursive: true, force: true });
|
|
86
|
+
privateDir(partial);
|
|
87
|
+
|
|
88
|
+
let manifest: BackupManifest;
|
|
89
|
+
try {
|
|
90
|
+
const snapshot = join(partial, 'botons.db');
|
|
91
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
92
|
+
try {
|
|
93
|
+
db.exec('pragma busy_timeout = 5000');
|
|
94
|
+
// A consistent single-file copy (VACUUM INTO reads one snapshot, WAL included), safe while the server writes.
|
|
95
|
+
db.prepare('vacuum into ?').run(snapshot);
|
|
96
|
+
} finally {
|
|
97
|
+
db.close();
|
|
98
|
+
}
|
|
99
|
+
chmodSync(snapshot, 0o600);
|
|
100
|
+
manifest = { createdAt: now.toISOString(), ...inspectSnapshot(snapshot), workspaces: false };
|
|
101
|
+
|
|
102
|
+
const folders = ARCHIVED_FOLDERS.filter((f) => existsSync(join(opts.home, f)));
|
|
103
|
+
if (folders.length) {
|
|
104
|
+
const archive = join(partial, 'workspaces.tar.gz');
|
|
105
|
+
// --ignore-failed-read: skip what this user cannot read (a Boton's chmod 000, a root-owned folder a container left).
|
|
106
|
+
const excludes = folders.includes('browser') ? BROWSER_EXCLUDES.map((e) => `--exclude=${e}`) : [];
|
|
107
|
+
const tar = spawnSync('tar', ['--ignore-failed-read', ...excludes, '-czf', archive, '-C', opts.home, ...folders], { encoding: 'utf8' });
|
|
108
|
+
const stderr = (tar.stderr ?? '').trim();
|
|
109
|
+
// GNU tar exits 1 when a file changed while it was read; the archive is still good.
|
|
110
|
+
if (!tar.error && (tar.status === 0 || tar.status === 1) && existsSync(archive)) {
|
|
111
|
+
chmodSync(archive, 0o600);
|
|
112
|
+
manifest.workspaces = true;
|
|
113
|
+
manifest.folders = folders;
|
|
114
|
+
if (stderr) manifest.tarWarning = stderr;
|
|
115
|
+
} else {
|
|
116
|
+
rmSync(archive, { force: true });
|
|
117
|
+
manifest.workspacesError = tar.error
|
|
118
|
+
? `Could not run tar: ${tar.error.message}`
|
|
119
|
+
: `tar failed (${tar.status === null ? `signal ${tar.signal}` : `exit ${tar.status}`})${stderr ? `: ${stderr}` : ''}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
writeFileSync(join(partial, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
124
|
+
renameSync(partial, dir);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
rmSync(partial, { recursive: true, force: true });
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
if (manifest.workspacesError) return { dir, manifest, removed: [] };
|
|
130
|
+
|
|
131
|
+
// Only names this code makes are deleted: other files in backups/ always stay.
|
|
132
|
+
const all = listBackups(backupsDir);
|
|
133
|
+
const removed = all.slice(0, Math.max(0, all.length - KEEP_BACKUPS));
|
|
134
|
+
for (const n of removed) rmSync(join(backupsDir, n), { recursive: true, force: true });
|
|
135
|
+
// A younger partial backup may still be running.
|
|
136
|
+
const dayAgo = stamp(new Date(now.getTime() - 86_400_000));
|
|
137
|
+
for (const n of readdirSync(backupsDir)) {
|
|
138
|
+
const started = PARTIAL_NAME.exec(n)?.[1];
|
|
139
|
+
if (started && started < dayAgo) rmSync(join(backupsDir, n), { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
return { dir, manifest, removed };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function inspectSnapshot(file: string): { schemaVersion: number; counts: Record<string, number> } {
|
|
145
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
146
|
+
try {
|
|
147
|
+
const problems = db.prepare('pragma integrity_check').all().map((r) => String(r.integrity_check));
|
|
148
|
+
if (problems.join() !== 'ok') throw new Error(`The snapshot failed integrity_check: ${problems.slice(0, 5).join('; ')}`);
|
|
149
|
+
const tables = db.prepare("select name from sqlite_master where type = 'table' and name not like 'sqlite_%' order by name").all();
|
|
150
|
+
const counts: Record<string, number> = {};
|
|
151
|
+
for (const { name } of tables) counts[String(name)] = Number(db.prepare(`select count(*) as n from "${name}"`).get()!.n);
|
|
152
|
+
return { schemaVersion: Number(db.prepare('pragma user_version').get()!.user_version), counts };
|
|
153
|
+
} finally {
|
|
154
|
+
db.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** When a backup folder's name says it was made, or NaN when the name is not a real UTC time (2026-99-99T…). */
|
|
159
|
+
export function backupTime(name: string): number {
|
|
160
|
+
if (!BACKUP_NAME.test(name)) return Number.NaN;
|
|
161
|
+
const at = Date.parse(name.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, 'T$1:$2:$3.$4Z'));
|
|
162
|
+
// Date.parse rolls some impossible dates over (02-30 → 03-02); a real stamp prints back to the same name.
|
|
163
|
+
return Number.isFinite(at) && stamp(new Date(at)) === name ? at : Number.NaN;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The largest manifest.json the owner's view reads; the backup command writes a few KiB. */
|
|
167
|
+
export const MANIFEST_MAX_BYTES = 256 * 1024;
|
|
168
|
+
|
|
169
|
+
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A backup folder's manifest, or null when it is missing, not a regular file (a FIFO, a device, a symlink), larger
|
|
173
|
+
* than MANIFEST_MAX_BYTES or not JSON. Opens without following a link and without blocking, then checks the open
|
|
174
|
+
* file itself, so a swap between the check and the read cannot make it block or read something else.
|
|
175
|
+
*/
|
|
176
|
+
export function readManifest(file: string): Partial<BackupManifest> | null {
|
|
177
|
+
let fd: number;
|
|
178
|
+
try {
|
|
179
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const st = fstatSync(fd);
|
|
185
|
+
if (!st.isFile() || st.size > MANIFEST_MAX_BYTES) return null;
|
|
186
|
+
// Read at most one byte past the limit: the file may grow after fstat.
|
|
187
|
+
const buf = Buffer.alloc(MANIFEST_MAX_BYTES + 1);
|
|
188
|
+
let n = 0;
|
|
189
|
+
while (n < buf.length) {
|
|
190
|
+
const got = readSync(fd, buf, n, buf.length - n, n);
|
|
191
|
+
if (got === 0) break;
|
|
192
|
+
n += got;
|
|
193
|
+
}
|
|
194
|
+
if (n > MANIFEST_MAX_BYTES) return null;
|
|
195
|
+
const parsed: unknown = JSON.parse(buf.subarray(0, n).toString('utf8'));
|
|
196
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Partial<BackupManifest>) : null;
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
} finally {
|
|
200
|
+
closeSync(fd);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The manifest's createdAt when it is a canonical UTC time, else the folder name's: never the manifest's own text. */
|
|
205
|
+
function createdAt(manifest: Partial<BackupManifest>, name: string): string {
|
|
206
|
+
const m = manifest.createdAt;
|
|
207
|
+
if (typeof m === 'string' && ISO_TIME.test(m)) {
|
|
208
|
+
const at = Date.parse(m);
|
|
209
|
+
if (Number.isFinite(at) && new Date(at).toISOString() === m) return m;
|
|
210
|
+
}
|
|
211
|
+
return new Date(backupTime(name)).toISOString();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* What the owner sees of the backups: names, times, schema versions and whether workspaces were archived. Reads
|
|
216
|
+
* only manifests, and never returns a path or a manifest's error text (tar's messages name files). Never throws:
|
|
217
|
+
* when the backups folder cannot be listed (not a folder, no permission, removed while read) it says only that.
|
|
218
|
+
*/
|
|
219
|
+
export function backupStatus(opts: { backupsDir: string; dbExists: boolean; now?: number }): BackupStatus {
|
|
220
|
+
const now = opts.now ?? Date.now();
|
|
221
|
+
const base = { keep: KEEP_BACKUPS, staleAfterHours: BACKUP_STALE_MS / 3_600_000 };
|
|
222
|
+
let entries: { name: string; dir: boolean }[];
|
|
223
|
+
try {
|
|
224
|
+
entries = readdirSync(opts.backupsDir, { withFileTypes: true }).map((e) => ({ name: e.name, dir: e.isDirectory() }));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
227
|
+
return { ...base, backups: [], total: 0, stale: true, partial: 0, unreadable: true };
|
|
228
|
+
}
|
|
229
|
+
entries = [];
|
|
230
|
+
}
|
|
231
|
+
// Real folders only: a symlink or file named like a backup is not one this code made.
|
|
232
|
+
const names = entries
|
|
233
|
+
.filter((e) => e.dir && Number.isFinite(backupTime(e.name)))
|
|
234
|
+
.map((e) => e.name)
|
|
235
|
+
.sort();
|
|
236
|
+
const backups = names
|
|
237
|
+
.slice(-BACKUPS_LISTED)
|
|
238
|
+
.reverse()
|
|
239
|
+
.map((name): BackupSummary => {
|
|
240
|
+
const manifest = readManifest(join(opts.backupsDir, name, 'manifest.json')) ?? {};
|
|
241
|
+
return {
|
|
242
|
+
name,
|
|
243
|
+
createdAt: createdAt(manifest, name),
|
|
244
|
+
schemaVersion: typeof manifest.schemaVersion === 'number' && Number.isSafeInteger(manifest.schemaVersion) ? manifest.schemaVersion : null,
|
|
245
|
+
workspaces: manifest.workspaces === true,
|
|
246
|
+
workspacesFailed: typeof manifest.workspacesError === 'string',
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
const newest = names.at(-1);
|
|
250
|
+
return {
|
|
251
|
+
...base,
|
|
252
|
+
backups,
|
|
253
|
+
total: names.length,
|
|
254
|
+
stale: newest ? now - backupTime(newest) > BACKUP_STALE_MS : opts.dbExists,
|
|
255
|
+
partial: entries.filter((e) => PARTIAL_NAME.test(e.name)).length,
|
|
256
|
+
unreadable: false,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function listBackups(backupsDir: string): string[] {
|
|
261
|
+
if (!existsSync(backupsDir)) return [];
|
|
262
|
+
return readdirSync(backupsDir)
|
|
263
|
+
.filter((n) => Number.isFinite(backupTime(n)))
|
|
264
|
+
.sort();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** What a restore puts back besides botons.db: the folders a backup archives, and the database's own side files. */
|
|
268
|
+
const RESTORED = ['botons.db', 'botons.db-wal', 'botons.db-shm', ...ARCHIVED_FOLDERS];
|
|
269
|
+
/** Set aside too: the database under its deprecated name (shared/legacy-names.mjs), so only the restored one is left. */
|
|
270
|
+
const SET_ASIDE = [...RESTORED, DB_FILE, `${DB_FILE}-wal`, `${DB_FILE}-shm`];
|
|
271
|
+
|
|
272
|
+
export interface RestorePlan {
|
|
273
|
+
/** The backup folder. */
|
|
274
|
+
from: string;
|
|
275
|
+
schemaVersion: number;
|
|
276
|
+
workspaces: boolean;
|
|
277
|
+
/** The backup's database file (botons.db, or its deprecated name in a backup made before the rename). */
|
|
278
|
+
db: string;
|
|
279
|
+
/** What is in `home` now and gets moved to `setAside`. */
|
|
280
|
+
moves: string[];
|
|
281
|
+
setAside: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether a restore by this code takes a backup at `version`: its own schema, or the one before (upgraded at the next
|
|
286
|
+
* start). The restore command, the rehearsal and the owner's view all decide it here.
|
|
287
|
+
*/
|
|
288
|
+
export function restorable(version: number, maxSchema: number): 'ok' | 'newer' | 'older' {
|
|
289
|
+
if (version > maxSchema) return 'newer';
|
|
290
|
+
return version >= maxSchema - 1 && version > 0 ? 'ok' : 'older';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The schema versions a restore by this code takes, oldest first. */
|
|
294
|
+
export const restorableSchemas = (maxSchema: number): number[] => [maxSchema - 1, maxSchema].filter((v) => v > 0);
|
|
295
|
+
|
|
296
|
+
/** A backup by folder name (in <home>/backups), "latest", or a path. Throws if it is not a usable backup. */
|
|
297
|
+
export function planRestore(opts: { home: string; backup: string; now?: Date; maxSchema: number }): RestorePlan {
|
|
298
|
+
const backupsDir = join(opts.home, 'backups');
|
|
299
|
+
let from: string;
|
|
300
|
+
if (opts.backup === 'latest') {
|
|
301
|
+
const newest = listBackups(backupsDir).at(-1);
|
|
302
|
+
if (!newest) throw new Error(`There is no backup in ${backupsDir}.`);
|
|
303
|
+
from = join(backupsDir, newest);
|
|
304
|
+
} else from = BACKUP_NAME.test(opts.backup) ? join(backupsDir, opts.backup) : resolve(opts.backup);
|
|
305
|
+
const db = dbFileIn(from);
|
|
306
|
+
if (!existsSync(db)) throw new Error(`${from} is not a backup: it has no botons.db.`);
|
|
307
|
+
const { schemaVersion } = inspectSnapshot(db);
|
|
308
|
+
const compat = restorable(schemaVersion, opts.maxSchema);
|
|
309
|
+
if (compat === 'newer') {
|
|
310
|
+
throw new Error(`The backup's database is at schema version ${schemaVersion}, newer than this code's ${opts.maxSchema}. Restore it with the release that made it.`);
|
|
311
|
+
}
|
|
312
|
+
if (compat === 'older') throw new Error(`Schema version ${schemaVersion} cannot be upgraded by this code; use a compatible release first.`);
|
|
313
|
+
const archive = join(from, 'workspaces.tar.gz');
|
|
314
|
+
const workspaces = existsSync(archive);
|
|
315
|
+
if (workspaces) {
|
|
316
|
+
// Read through once before anything moves, and unpack nothing but the folders a backup archives.
|
|
317
|
+
const list = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
318
|
+
if (list.error || list.status !== 0) throw new Error(`${archive} cannot be read: ${list.error?.message ?? (list.stderr || `exit ${list.status}`).trim()}`);
|
|
319
|
+
// Reject links and special files: their targets can cross isolation boundaries even when member names do not.
|
|
320
|
+
const types = spawnSync('tar', ['-tvzf', archive], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } });
|
|
321
|
+
if (types.error || types.status !== 0 || types.stdout.split('\n').some((line) => line && !['-', 'd'].includes(line[0]))) {
|
|
322
|
+
throw new Error(`${archive} contains links or special files; restore only regular files and directories.`);
|
|
323
|
+
}
|
|
324
|
+
const stray = list.stdout.split('\n').find((m) => m && !ARCHIVED_FOLDERS.some((f) => m === f || m.startsWith(`${f}/`)));
|
|
325
|
+
if (stray !== undefined || /(^|\/)\.\.(\/|$)/m.test(list.stdout)) throw new Error(`${archive} holds ${stray ?? 'a ".." path'}, which a Botons backup never does.`);
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
from,
|
|
329
|
+
schemaVersion,
|
|
330
|
+
workspaces,
|
|
331
|
+
db,
|
|
332
|
+
moves: SET_ASIDE.filter((f) => existsSync(join(opts.home, f))),
|
|
333
|
+
setAside: join(opts.home, `before-restore-${stamp(opts.now)}`),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Restore a backup into `home`, which no server may be using (the caller holds its lock). Nothing is deleted: what is
|
|
339
|
+
* there now moves to plan.setAside first. If the restore fails part-way, what it unpacked is removed and the set-aside
|
|
340
|
+
* state is moved back.
|
|
341
|
+
*/
|
|
342
|
+
export function restore(home: string, plan: RestorePlan): void {
|
|
343
|
+
if (existsSync(plan.setAside)) throw new Error(`${plan.setAside} already exists`);
|
|
344
|
+
// Prepare everything before moving originals. Restrictive archive permissions or failed extraction cannot
|
|
345
|
+
// strand the active database, and installed paths can be rolled back with renames rather than recursive removal.
|
|
346
|
+
const staging = mkdtempSync(join(home, '.restore-'));
|
|
347
|
+
const present = SET_ASIDE.filter((f) => lstatOrNull(join(home, f)));
|
|
348
|
+
const moved: string[] = [];
|
|
349
|
+
const installed: string[] = [];
|
|
350
|
+
let recoveryIncomplete = false;
|
|
351
|
+
try {
|
|
352
|
+
copyFileSync(plan.db, join(staging, 'botons.db'));
|
|
353
|
+
chmodSync(join(staging, 'botons.db'), 0o600);
|
|
354
|
+
if (plan.workspaces) {
|
|
355
|
+
const tar = spawnSync('tar', ['-xzf', join(plan.from, 'workspaces.tar.gz'), '-C', staging, '--no-same-owner'], { encoding: 'utf8' });
|
|
356
|
+
if (tar.error || tar.status !== 0) throw new Error(`Unpacking workspaces.tar.gz failed: ${tar.error?.message ?? (tar.stderr || `exit ${tar.status}`).trim()}`);
|
|
357
|
+
}
|
|
358
|
+
// A data folder with nothing in it yet (a new machine) has nothing to set aside.
|
|
359
|
+
if (present.length) privateDir(plan.setAside);
|
|
360
|
+
for (const f of present) {
|
|
361
|
+
renameSync(join(home, f), join(plan.setAside, f));
|
|
362
|
+
moved.push(f);
|
|
363
|
+
}
|
|
364
|
+
for (const f of RESTORED) if (existsSync(join(staging, f))) {
|
|
365
|
+
renameSync(join(staging, f), join(home, f));
|
|
366
|
+
installed.push(f);
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
try {
|
|
370
|
+
for (const f of installed) renameSync(join(home, f), join(staging, f));
|
|
371
|
+
for (const f of moved) renameSync(join(plan.setAside, f), join(home, f));
|
|
372
|
+
if (existsSync(plan.setAside)) rmSync(plan.setAside, { recursive: true });
|
|
373
|
+
} catch (rollback) {
|
|
374
|
+
recoveryIncomplete = true;
|
|
375
|
+
throw new Error(`Restore failed: ${(err as Error).message}. Recovery incomplete: ${(rollback as Error).message}. Preserve ${plan.setAside} and ${staging} for manual recovery; do not start the server.`);
|
|
376
|
+
}
|
|
377
|
+
throw new Error(`Restore failed; original state preserved. ${(err as Error).message}`);
|
|
378
|
+
} finally {
|
|
379
|
+
// A hostile mode on an extracted directory can prevent cleanup. Keep the private staging folder for inspection.
|
|
380
|
+
try { if (!recoveryIncomplete) rmSync(staging, { recursive: true, force: true }); } catch {}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cliHomesEnv, prepareCliHomes } from './cli-homes.ts';
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { agentEnv } from './providers/process.ts';
|
|
4
|
+
import { HttpError } from './store.ts';
|
|
5
|
+
|
|
6
|
+
export interface BrainLoginState { state: 'idle' | 'waiting' | 'finished' | 'cancelled' | 'timed-out' | 'fallback'; url: string | null; code: string | null }
|
|
7
|
+
/** Only the fixed device verification address and code leave this class. Raw CLI output is never logged. */
|
|
8
|
+
export class BrainLogin {
|
|
9
|
+
#child: ChildProcess | null = null;
|
|
10
|
+
#timer: ReturnType<typeof setTimeout> | null = null;
|
|
11
|
+
#view: BrainLoginState = { state: 'idle', url: null, code: null };
|
|
12
|
+
view(): BrainLoginState { return { ...this.#view }; }
|
|
13
|
+
private bin: string;
|
|
14
|
+
private readonly timeoutMs: number;
|
|
15
|
+
private readonly schedule: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
|
|
16
|
+
constructor(bin = 'codex', timeoutMs = 10 * 60_000, schedule: (callback: () => void, delay: number) => ReturnType<typeof setTimeout> = setTimeout) { this.bin = bin; this.timeoutMs = timeoutMs; this.schedule = schedule; }
|
|
17
|
+
setBin(bin: string): void {
|
|
18
|
+
if (this.#child) throw new HttpError(409, 'Cancel sign-in before updating.');
|
|
19
|
+
this.bin = bin;
|
|
20
|
+
}
|
|
21
|
+
start(): BrainLoginState {
|
|
22
|
+
if (this.#child) throw new HttpError(409, 'A sign-in is already running. Cancel it first.');
|
|
23
|
+
this.#view = { state: 'waiting', url: null, code: null };
|
|
24
|
+
prepareCliHomes();
|
|
25
|
+
const child = spawn(this.bin, ['login', '--device-auth'], { env: { ...agentEnv({ NO_COLOR: '1' }), ...cliHomesEnv(process.env, true) }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
26
|
+
this.#child = child;
|
|
27
|
+
let pending = '';
|
|
28
|
+
const read = (chunk: Buffer) => {
|
|
29
|
+
if (this.#child !== child || this.#view.state !== 'waiting') return;
|
|
30
|
+
// Bounded transient parsing buffer, discarded after each line; never returned in errors.
|
|
31
|
+
pending = (pending + chunk.toString()).replace(/\x1b\[[0-9;]*m/g, '').slice(-2048);
|
|
32
|
+
const lines = pending.split(/\r?\n/);
|
|
33
|
+
pending = lines.pop() ?? '';
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (/https:\/\/auth\.openai\.com\/codex\/device(?:\s|$)/.test(line)) this.#view.url = 'https://auth.openai.com/codex/device';
|
|
36
|
+
const code = line.trim().match(/^([A-Z0-9]{4}-[A-Z0-9]{5}|[A-Z0-9]{4}-[A-Z0-9]{4})$/);
|
|
37
|
+
if (code) this.#view.code = code[1];
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
child.stdout!.on('data', read);
|
|
41
|
+
child.stderr!.on('data', read);
|
|
42
|
+
child.once('error', () => finish('fallback'));
|
|
43
|
+
child.once('close', (code) => finish(code === 0 ? 'finished' : 'fallback'));
|
|
44
|
+
const finish = (state: BrainLoginState['state']) => {
|
|
45
|
+
if (this.#child !== child) return;
|
|
46
|
+
this.#child = null;
|
|
47
|
+
this.#killGroup(child);
|
|
48
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
49
|
+
this.#timer = null;
|
|
50
|
+
child.stdout?.removeListener('data', read);
|
|
51
|
+
child.stderr?.removeListener('data', read);
|
|
52
|
+
pending = '';
|
|
53
|
+
this.#view = { state, url: null, code: null };
|
|
54
|
+
};
|
|
55
|
+
this.#timer = this.schedule(() => { this.cancel(); this.#view.state = 'timed-out'; }, this.timeoutMs);
|
|
56
|
+
this.#timer.unref();
|
|
57
|
+
return this.view();
|
|
58
|
+
}
|
|
59
|
+
#killGroup(child: ChildProcess): void {
|
|
60
|
+
// A configured launcher may spawn the actual CLI. Neither may finish login after cancellation.
|
|
61
|
+
if (!child.pid) return;
|
|
62
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* The group may already have exited. */ }
|
|
63
|
+
}
|
|
64
|
+
cancel(): BrainLoginState {
|
|
65
|
+
const child = this.#child;
|
|
66
|
+
this.#child = null;
|
|
67
|
+
child?.stdout?.removeAllListeners('data');
|
|
68
|
+
child?.stderr?.removeAllListeners('data');
|
|
69
|
+
if (child) this.#killGroup(child);
|
|
70
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
71
|
+
this.#timer = null;
|
|
72
|
+
this.#view = { state: 'cancelled', url: null, code: null };
|
|
73
|
+
return this.view();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The certificate the owner's handover browser is shown for every HTTPS site: made here, with a new P-256 key, for
|
|
5
|
+
* one handover. That Chromium is told to trust exactly this key (--ignore-certificate-errors-spki-list) and no other
|
|
6
|
+
* certificate changes for it, so its proxy (browser-proxy.ts, `intercept`) can end each HTTPS tunnel itself, see
|
|
7
|
+
* every request inside (its method above all) and decide on it, then send it on to the site over TLS that the proxy
|
|
8
|
+
* verifies against the system's roots. The key never leaves this process; nothing else trusts it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const der = (tag: number, body: Buffer): Buffer => {
|
|
12
|
+
const n = body.length;
|
|
13
|
+
const len = n < 0x80 ? Buffer.from([n]) : n < 0x100 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 0xff]);
|
|
14
|
+
return Buffer.concat([Buffer.from([tag]), len, body]);
|
|
15
|
+
};
|
|
16
|
+
const seq = (...parts: Buffer[]) => der(0x30, Buffer.concat(parts));
|
|
17
|
+
const oid = (dotted: string): Buffer => {
|
|
18
|
+
const [a, b, ...rest] = dotted.split('.').map(Number);
|
|
19
|
+
const out = [a * 40 + b];
|
|
20
|
+
for (const v of rest) {
|
|
21
|
+
const bytes = [v & 0x7f];
|
|
22
|
+
for (let x = v >> 7; x > 0; x >>= 7) bytes.unshift((x & 0x7f) | 0x80);
|
|
23
|
+
out.push(...bytes);
|
|
24
|
+
}
|
|
25
|
+
return der(0x06, Buffer.from(out));
|
|
26
|
+
};
|
|
27
|
+
const utcTime = (d: Date) => der(0x17, Buffer.from(`${d.toISOString().slice(2, 19).replace(/[-:T]/g, '')}Z`, 'ascii'));
|
|
28
|
+
const name = (cn: string) => seq(der(0x31, seq(oid('2.5.4.3'), der(0x0c, Buffer.from(cn, 'utf8')))));
|
|
29
|
+
const ECDSA_SHA256 = seq(oid('1.2.840.10045.4.3.2'));
|
|
30
|
+
|
|
31
|
+
export interface InterceptCert {
|
|
32
|
+
key: string;
|
|
33
|
+
cert: string;
|
|
34
|
+
/** SHA-256 of the key's SubjectPublicKeyInfo, base64: what Chromium is told to trust. */
|
|
35
|
+
spki: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A new key and a self-signed certificate for it, valid from a day ago for two days. */
|
|
39
|
+
export function interceptCert(now = new Date()): InterceptCert {
|
|
40
|
+
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
41
|
+
const spkiDer = publicKey.export({ type: 'spki', format: 'der' });
|
|
42
|
+
const randomSerial = randomBytes(16);
|
|
43
|
+
// DER integers need minimal signed encoding; keep this positive and nonzero.
|
|
44
|
+
let first = 0;
|
|
45
|
+
while (first < randomSerial.length - 1 && randomSerial[first] === 0) first++;
|
|
46
|
+
let serial = randomSerial.subarray(first);
|
|
47
|
+
if (serial.length === 1 && serial[0] === 0) serial = Buffer.from([1]);
|
|
48
|
+
else if (serial[0] & 0x80) serial = Buffer.concat([Buffer.from([0]), serial]);
|
|
49
|
+
const subject = name('Botons handover');
|
|
50
|
+
const tbs = seq(
|
|
51
|
+
der(0xa0, der(0x02, Buffer.from([2]))),
|
|
52
|
+
der(0x02, serial),
|
|
53
|
+
ECDSA_SHA256,
|
|
54
|
+
subject,
|
|
55
|
+
seq(utcTime(new Date(now.getTime() - 86_400_000)), utcTime(new Date(now.getTime() + 86_400_000))),
|
|
56
|
+
subject,
|
|
57
|
+
spkiDer,
|
|
58
|
+
);
|
|
59
|
+
const signature = sign('sha256', tbs, { key: privateKey, dsaEncoding: 'der' });
|
|
60
|
+
const certDer = seq(tbs, ECDSA_SHA256, der(0x03, Buffer.concat([Buffer.from([0]), signature])));
|
|
61
|
+
const pem = `-----BEGIN CERTIFICATE-----\n${certDer.toString('base64').replace(/.{64}/g, '$&\n').replace(/\n?$/, '\n')}-----END CERTIFICATE-----\n`;
|
|
62
|
+
return {
|
|
63
|
+
key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
|
|
64
|
+
cert: pem,
|
|
65
|
+
spki: createHash('sha256').update(spkiDer).digest('base64'),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type BrowserHandoff, type HandoffEvent, handBackNote, handoffOpen, handoffReason, handoffStep, handoffWaiting, requestHandoff } from '../../shared/handoff.ts';
|
|
2
|
+
import type { Message } from '../../shared/types.ts';
|
|
3
|
+
import { log } from './log.ts';
|
|
4
|
+
import type { PushRequest } from './notify.ts';
|
|
5
|
+
import { HttpError, type Store } from './store.ts';
|
|
6
|
+
import { nowIso } from './util.ts';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* The handoff cards (shared/handoff.ts) as the server keeps them: a `browser.handoff` chat event whose meta holds the
|
|
10
|
+
* record, changed only through Store.setHandoff (from one state to the next), so the chat updates live. browser.ts
|
|
11
|
+
* says when the owner takes the browser over and when that ends; this says what the card and the Boton make of it.
|
|
12
|
+
* Nothing here opens or drives a browser, and nothing here sees what the owner does in it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface HandoffDeps {
|
|
16
|
+
store: Store;
|
|
17
|
+
/** A push for the owner (Notifier.push), best effort. */
|
|
18
|
+
push?: (req: PushRequest) => Promise<unknown>;
|
|
19
|
+
/** Starts the Boton's turn on its pending messages. */
|
|
20
|
+
wake: (botId: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const handoffOf = (m: Message): BrowserHandoff | null => (m.meta?.kind === 'browser.handoff' ? (m.meta.browserHandoff ?? null) : null);
|
|
24
|
+
|
|
25
|
+
export class BrowserHandoffs {
|
|
26
|
+
#deps: HandoffDeps;
|
|
27
|
+
|
|
28
|
+
constructor(deps: HandoffDeps) {
|
|
29
|
+
this.#deps = deps;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The step `e` on card `m`, if it can happen from where it stands now (another change got there first: null). */
|
|
33
|
+
#step(m: Message, e: HandoffEvent): Message | null {
|
|
34
|
+
const h = handoffOf(m);
|
|
35
|
+
if (!h) return null;
|
|
36
|
+
const next = handoffStep(h, e);
|
|
37
|
+
return next ? this.#deps.store.setHandoff(m.id, h.state, next) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The Boton's open handoff (requested and waiting, or taken), if any. Requests past their expiry found on the way are closed. */
|
|
41
|
+
open(botId: string): Message | null {
|
|
42
|
+
const now = nowIso();
|
|
43
|
+
let found: Message | null = null;
|
|
44
|
+
for (const m of this.#deps.store.openHandoffs(botId)) {
|
|
45
|
+
const h = handoffOf(m)!;
|
|
46
|
+
if (!handoffOpen(h, now)) this.#step(m, { type: 'close', at: now, why: 'nobody took it in time' });
|
|
47
|
+
else found ??= m;
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The Boton asks (browser_handoff, from its running turn). `url` is where its browser is (origin and path only);
|
|
54
|
+
* `takenBy` names the device when the owner already has the browser, so the card starts taken. A second ask while
|
|
55
|
+
* one waits replaces it (the newer reason is the one that counts); one already taken is answered with that one.
|
|
56
|
+
*/
|
|
57
|
+
request(botId: string, rawReason: unknown, url: string | null, takenBy: string | null): { message: Message; already: 'taken' | null } {
|
|
58
|
+
const reason = handoffReason(rawReason);
|
|
59
|
+
if (typeof reason !== 'string') throw new HttpError(400, reason.why);
|
|
60
|
+
const now = nowIso();
|
|
61
|
+
const current = this.open(botId);
|
|
62
|
+
if (current && handoffOf(current)!.state === 'taken') return { message: current, already: 'taken' };
|
|
63
|
+
if (current) this.#step(current, { type: 'close', at: now, why: 'the Boton asked again' });
|
|
64
|
+
const store = this.#deps.store;
|
|
65
|
+
const bot = store.getBot(botId);
|
|
66
|
+
let message = store.addMessage(botId, 'event', `Your turn in ${bot.name}'s browser: ${reason}`, { kind: 'browser.handoff', browserHandoff: requestHandoff(reason, url, new Date()) });
|
|
67
|
+
if (takenBy) message = this.#step(message, { type: 'take', at: nowIso(), by: takenBy }) ?? message;
|
|
68
|
+
log.info('browser handoff requested', { botId, handoffId: message.id, taken: !!takenBy || undefined });
|
|
69
|
+
if (!takenBy) {
|
|
70
|
+
void this.#deps
|
|
71
|
+
.push?.({ bot: { id: bot.id, name: bot.name }, reason: 'approval', detail: `Your turn in ${bot.name}'s browser: ${reason}`, generic: `Your turn in ${bot.name}'s browser.`, priority: 'high' })
|
|
72
|
+
?.catch((err) => log.warn('could not push a browser handoff', { botId, err }));
|
|
73
|
+
}
|
|
74
|
+
return { message, already: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The owner took the browser over (from the card, the chat header or Settings): a waiting request is taken. */
|
|
78
|
+
taken(botId: string, by: string): void {
|
|
79
|
+
const m = this.open(botId);
|
|
80
|
+
if (m && handoffWaiting(handoffOf(m)!, nowIso())) this.#step(m, { type: 'take', at: nowIso(), by });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The handover ended (`how`: `finished` when the owner handed it back): a taken handoff is handed back, with where the
|
|
85
|
+
* page is (origin and path only), and the Boton is woken with a note saying so. False if there was none.
|
|
86
|
+
*/
|
|
87
|
+
handedBack(botId: string, page: string | null, how: string): boolean {
|
|
88
|
+
const m = this.open(botId);
|
|
89
|
+
if (!m || handoffOf(m)!.state !== 'taken') return false;
|
|
90
|
+
const done = this.#step(m, { type: 'hand-back', at: nowIso(), page, how });
|
|
91
|
+
if (!done) return false;
|
|
92
|
+
const store = this.#deps.store;
|
|
93
|
+
if (!store.hasBot(botId)) return true;
|
|
94
|
+
const note = handBackNote(store.getSettings().ownerName, handoffOf(done)!);
|
|
95
|
+
store.addMessage(botId, 'user', note, { kind: 'browser.handed_back', handoffId: m.id }, true);
|
|
96
|
+
log.info('browser handoff handed back', { botId, handoffId: m.id, how });
|
|
97
|
+
this.#deps.wake(botId);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The Boton's browser was turned off (or the Boton is going): a request still waiting can't be taken any more. */
|
|
102
|
+
closeWaiting(botId: string, why: string): void {
|
|
103
|
+
const m = this.open(botId);
|
|
104
|
+
if (m && handoffOf(m)!.state === 'requested') this.#step(m, { type: 'close', at: nowIso(), why });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* At start: a handover never outlives the server, so a handoff still taken was handed back by the stop (each Boton is
|
|
109
|
+
* told), and requests past their expiry are closed.
|
|
110
|
+
*/
|
|
111
|
+
recover(): void {
|
|
112
|
+
const now = nowIso();
|
|
113
|
+
for (const m of this.#deps.store.openHandoffs(null)) {
|
|
114
|
+
const h = handoffOf(m)!;
|
|
115
|
+
if (h.state === 'taken') this.handedBack(m.botId, null, 'the server restarted');
|
|
116
|
+
else if (!handoffWaiting(h, now)) this.#step(m, { type: 'close', at: now, why: 'nobody took it in time' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|