obol-ai 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/README.md +364 -0
- package/bin/obol.js +64 -0
- package/docs/DEPLOY.md +277 -0
- package/docs/obol-banner.png +0 -0
- package/package.json +29 -0
- package/src/background.js +188 -0
- package/src/backup.js +66 -0
- package/src/claude.js +443 -0
- package/src/clean.js +168 -0
- package/src/cli/backup.js +20 -0
- package/src/cli/init.js +381 -0
- package/src/cli/logs.js +12 -0
- package/src/cli/start.js +47 -0
- package/src/cli/status.js +44 -0
- package/src/cli/stop.js +12 -0
- package/src/config.js +57 -0
- package/src/db/migrate.js +134 -0
- package/src/evolve.js +668 -0
- package/src/first-run.js +110 -0
- package/src/heartbeat.js +16 -0
- package/src/index.js +55 -0
- package/src/memory.js +164 -0
- package/src/messages.js +140 -0
- package/src/personality.js +27 -0
- package/src/post-setup.js +410 -0
- package/src/telegram.js +377 -0
- package/src/test-utils.js +111 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { OBOL_DIR, loadConfig, saveConfig } = require('./config');
|
|
5
|
+
|
|
6
|
+
const POST_SETUP_FLAG = path.join(OBOL_DIR, '.post-setup-complete');
|
|
7
|
+
|
|
8
|
+
function isPostSetupDone() {
|
|
9
|
+
return fs.existsSync(POST_SETUP_FLAG);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function markPostSetupDone() {
|
|
13
|
+
fs.writeFileSync(POST_SETUP_FLAG, JSON.stringify({
|
|
14
|
+
completedAt: new Date().toISOString(),
|
|
15
|
+
tasks: SETUP_TASKS.map(t => t.name),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── SETUP TASKS ───
|
|
20
|
+
// These run in order after first-run conversation completes.
|
|
21
|
+
// Each task is a self-contained function that returns { success, message }.
|
|
22
|
+
// Add new tasks here — they'll run automatically on next boot.
|
|
23
|
+
|
|
24
|
+
const SETUP_TASKS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'install-pass',
|
|
27
|
+
description: 'Install GPG and pass for encrypted secret storage',
|
|
28
|
+
run: async (config) => {
|
|
29
|
+
try {
|
|
30
|
+
// Check if already installed
|
|
31
|
+
try {
|
|
32
|
+
execSync('which pass', { stdio: 'pipe' });
|
|
33
|
+
execSync('pass ls', { stdio: 'pipe' });
|
|
34
|
+
return { success: true, message: 'pass already configured' };
|
|
35
|
+
} catch {}
|
|
36
|
+
|
|
37
|
+
// Install gpg + pass
|
|
38
|
+
const os = execSync('cat /etc/os-release 2>/dev/null || echo "unknown"', { encoding: 'utf-8' });
|
|
39
|
+
if (os.includes('Ubuntu') || os.includes('Debian')) {
|
|
40
|
+
execSync('apt-get update -qq && apt-get install -y -qq gnupg pass', { stdio: 'pipe' });
|
|
41
|
+
} else if (os.includes('Alpine')) {
|
|
42
|
+
execSync('apk add --quiet gnupg pass', { stdio: 'pipe' });
|
|
43
|
+
} else {
|
|
44
|
+
return { success: false, message: 'Unknown OS — install gpg and pass manually' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Generate GPG key (non-interactive)
|
|
48
|
+
const botName = config.bot?.name || 'OBOL';
|
|
49
|
+
const gpgBatch = `
|
|
50
|
+
%no-protection
|
|
51
|
+
Key-Type: RSA
|
|
52
|
+
Key-Length: 2048
|
|
53
|
+
Subkey-Type: RSA
|
|
54
|
+
Subkey-Length: 2048
|
|
55
|
+
Name-Real: ${botName}
|
|
56
|
+
Name-Email: obol@local
|
|
57
|
+
Expire-Date: 0
|
|
58
|
+
%commit
|
|
59
|
+
`;
|
|
60
|
+
const batchFile = path.join(OBOL_DIR, '.gpg-batch');
|
|
61
|
+
fs.writeFileSync(batchFile, gpgBatch);
|
|
62
|
+
execSync(`gpg --batch --gen-key ${batchFile}`, { stdio: 'pipe' });
|
|
63
|
+
fs.unlinkSync(batchFile);
|
|
64
|
+
|
|
65
|
+
// Get the key fingerprint
|
|
66
|
+
const keys = execSync('gpg --list-keys --with-colons obol@local', { encoding: 'utf-8' });
|
|
67
|
+
const fprLine = keys.split('\n').find(l => l.startsWith('fpr:'));
|
|
68
|
+
const fingerprint = fprLine?.split(':')[9];
|
|
69
|
+
|
|
70
|
+
if (!fingerprint) {
|
|
71
|
+
return { success: false, message: 'GPG key generated but could not extract fingerprint' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Init pass store
|
|
75
|
+
execSync(`pass init ${fingerprint}`, { stdio: 'pipe' });
|
|
76
|
+
|
|
77
|
+
return { success: true, message: `GPG key + pass store initialized (${fingerprint.slice(-8)})` };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { success: false, message: `Failed: ${e.message}` };
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
name: 'migrate-secrets',
|
|
86
|
+
description: 'Move plaintext secrets from config.json to pass',
|
|
87
|
+
run: async (config) => {
|
|
88
|
+
try {
|
|
89
|
+
// Verify pass is working
|
|
90
|
+
execSync('pass ls', { stdio: 'pipe' });
|
|
91
|
+
|
|
92
|
+
const secrets = {
|
|
93
|
+
'obol/anthropic-key': config.anthropic?.apiKey,
|
|
94
|
+
'obol/telegram-token': config.telegram?.token,
|
|
95
|
+
'obol/supabase-url': config.supabase?.url,
|
|
96
|
+
'obol/supabase-key': config.supabase?.serviceKey,
|
|
97
|
+
'obol/supabase-access-token': config.supabase?.accessToken,
|
|
98
|
+
'obol/github-token': config.github?.token,
|
|
99
|
+
'obol/vercel-token': config.vercel?.token,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let migrated = 0;
|
|
103
|
+
for (const [passPath, value] of Object.entries(secrets)) {
|
|
104
|
+
if (!value) continue;
|
|
105
|
+
execSync(`echo "${value}" | pass insert -m ${passPath}`, { stdio: 'pipe' });
|
|
106
|
+
migrated++;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Rewrite config.json without plaintext secrets
|
|
110
|
+
const cleanConfig = {
|
|
111
|
+
...config,
|
|
112
|
+
anthropic: { apiKey: 'pass:obol/anthropic-key' },
|
|
113
|
+
telegram: { ...config.telegram, token: 'pass:obol/telegram-token' },
|
|
114
|
+
supabase: config.supabase ? {
|
|
115
|
+
url: 'pass:obol/supabase-url',
|
|
116
|
+
serviceKey: 'pass:obol/supabase-key',
|
|
117
|
+
...(config.supabase.accessToken ? { accessToken: 'pass:obol/supabase-access-token' } : {}),
|
|
118
|
+
...(config.supabase.anonKey ? { anonKey: config.supabase.anonKey } : {}),
|
|
119
|
+
} : null,
|
|
120
|
+
github: config.github ? {
|
|
121
|
+
...config.github,
|
|
122
|
+
token: 'pass:obol/github-token',
|
|
123
|
+
} : null,
|
|
124
|
+
vercel: config.vercel ? { token: 'pass:obol/vercel-token' } : null,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
saveConfig(cleanConfig);
|
|
128
|
+
|
|
129
|
+
return { success: true, message: `Migrated ${migrated} secrets to pass. Config cleaned.` };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { success: false, message: `Failed: ${e.message}` };
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
name: 'install-pm2',
|
|
138
|
+
description: 'Install pm2 process manager and configure auto-start on boot',
|
|
139
|
+
run: async () => {
|
|
140
|
+
try {
|
|
141
|
+
try {
|
|
142
|
+
execSync('which pm2', { stdio: 'pipe' });
|
|
143
|
+
return { success: true, message: 'pm2 already installed' };
|
|
144
|
+
} catch {}
|
|
145
|
+
|
|
146
|
+
execSync('npm install -g pm2', { stdio: 'pipe' });
|
|
147
|
+
execSync('pm2 startup -u root --hp /root 2>/dev/null || pm2 startup', { stdio: 'pipe' });
|
|
148
|
+
|
|
149
|
+
return { success: true, message: 'pm2 installed + startup configured' };
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { success: false, message: `pm2 setup failed: ${e.message}` };
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
name: 'setup-swap',
|
|
158
|
+
description: 'Add swap if RAM is low (embedding model needs ~200MB)',
|
|
159
|
+
run: async () => {
|
|
160
|
+
try {
|
|
161
|
+
const memInfo = execSync('free -m', { encoding: 'utf-8' });
|
|
162
|
+
const totalMatch = memInfo.match(/Mem:\s+(\d+)/);
|
|
163
|
+
const totalMB = totalMatch ? parseInt(totalMatch[1]) : 0;
|
|
164
|
+
|
|
165
|
+
if (totalMB >= 2048) {
|
|
166
|
+
return { success: true, message: `${totalMB}MB RAM — swap not needed` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check if swap already exists
|
|
170
|
+
const swapInfo = execSync('swapon --show', { encoding: 'utf-8' });
|
|
171
|
+
if (swapInfo.trim()) {
|
|
172
|
+
return { success: true, message: 'Swap already configured' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Create 2GB swap
|
|
176
|
+
execSync('fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile', { stdio: 'pipe' });
|
|
177
|
+
|
|
178
|
+
// Make persistent
|
|
179
|
+
const fstab = fs.readFileSync('/etc/fstab', 'utf-8');
|
|
180
|
+
if (!fstab.includes('/swapfile')) {
|
|
181
|
+
fs.appendFileSync('/etc/fstab', '\n/swapfile none swap sw 0 0\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { success: true, message: `2GB swap created (${totalMB}MB RAM detected)` };
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return { success: false, message: `Swap setup failed: ${e.message}` };
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
name: 'harden-ssh',
|
|
193
|
+
description: 'Harden SSH — key-only auth, no root password, rate limiting',
|
|
194
|
+
run: async () => {
|
|
195
|
+
try {
|
|
196
|
+
const sshdConfig = '/etc/ssh/sshd_config';
|
|
197
|
+
if (!fs.existsSync(sshdConfig)) {
|
|
198
|
+
return { success: true, message: 'No sshd_config found — not an SSH server' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let config = fs.readFileSync(sshdConfig, 'utf-8');
|
|
202
|
+
let changed = false;
|
|
203
|
+
const changes = [];
|
|
204
|
+
|
|
205
|
+
const settings = {
|
|
206
|
+
'Port': '2222',
|
|
207
|
+
'PasswordAuthentication': 'no',
|
|
208
|
+
'PermitRootLogin': 'prohibit-password',
|
|
209
|
+
'PubkeyAuthentication': 'yes',
|
|
210
|
+
'MaxAuthTries': '3',
|
|
211
|
+
'LoginGraceTime': '20',
|
|
212
|
+
'X11Forwarding': 'no',
|
|
213
|
+
'PermitEmptyPasswords': 'no',
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
217
|
+
const regex = new RegExp(`^#?\\s*${key}\\s+.*$`, 'm');
|
|
218
|
+
const target = `${key} ${value}`;
|
|
219
|
+
if (config.match(new RegExp(`^${key}\\s+${value}$`, 'm'))) continue;
|
|
220
|
+
|
|
221
|
+
if (regex.test(config)) {
|
|
222
|
+
config = config.replace(regex, target);
|
|
223
|
+
} else {
|
|
224
|
+
config += `\n${target}`;
|
|
225
|
+
}
|
|
226
|
+
changes.push(`${key}=${value}`);
|
|
227
|
+
changed = true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (changed) {
|
|
231
|
+
// Backup original
|
|
232
|
+
execSync(`cp ${sshdConfig} ${sshdConfig}.bak.obol`, { stdio: 'pipe' });
|
|
233
|
+
fs.writeFileSync(sshdConfig, config);
|
|
234
|
+
// Test config before restarting
|
|
235
|
+
try {
|
|
236
|
+
execSync('sshd -t', { stdio: 'pipe' });
|
|
237
|
+
execSync('systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null', { stdio: 'pipe' });
|
|
238
|
+
return { success: true, message: `Hardened: ${changes.join(', ')}` };
|
|
239
|
+
} catch (e) {
|
|
240
|
+
// Rollback on bad config
|
|
241
|
+
execSync(`cp ${sshdConfig}.bak.obol ${sshdConfig}`, { stdio: 'pipe' });
|
|
242
|
+
return { success: false, message: `Config test failed, rolled back: ${e.message}` };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { success: true, message: 'SSH already hardened' };
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return { success: false, message: `SSH hardening failed: ${e.message}` };
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
name: 'install-fail2ban',
|
|
255
|
+
description: 'Install and configure fail2ban to block brute-force attacks',
|
|
256
|
+
run: async () => {
|
|
257
|
+
try {
|
|
258
|
+
// Check if already running
|
|
259
|
+
try {
|
|
260
|
+
const status = execSync('systemctl is-active fail2ban', { encoding: 'utf-8' }).trim();
|
|
261
|
+
if (status === 'active') {
|
|
262
|
+
return { success: true, message: 'fail2ban already active' };
|
|
263
|
+
}
|
|
264
|
+
} catch {}
|
|
265
|
+
|
|
266
|
+
// Install
|
|
267
|
+
execSync('apt-get update -qq && apt-get install -y -qq fail2ban', { stdio: 'pipe' });
|
|
268
|
+
|
|
269
|
+
// Write jail config — port 2222 (hardened by obol)
|
|
270
|
+
const jailLocal = `[sshd]
|
|
271
|
+
enabled = true
|
|
272
|
+
port = 2222
|
|
273
|
+
filter = sshd
|
|
274
|
+
logpath = /var/log/auth.log
|
|
275
|
+
maxretry = 3
|
|
276
|
+
bantime = 3600
|
|
277
|
+
findtime = 600
|
|
278
|
+
`;
|
|
279
|
+
fs.writeFileSync('/etc/fail2ban/jail.local', jailLocal);
|
|
280
|
+
|
|
281
|
+
execSync('systemctl enable fail2ban && systemctl restart fail2ban', { stdio: 'pipe' });
|
|
282
|
+
|
|
283
|
+
return { success: true, message: 'fail2ban active (SSH port 2222, max 3 retries, 1h ban)' };
|
|
284
|
+
} catch (e) {
|
|
285
|
+
return { success: false, message: `fail2ban setup failed: ${e.message}` };
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
{
|
|
291
|
+
name: 'setup-firewall',
|
|
292
|
+
description: 'Enable UFW firewall — allow SSH (port 2222) only',
|
|
293
|
+
run: async () => {
|
|
294
|
+
try {
|
|
295
|
+
// Install ufw if not present
|
|
296
|
+
try { execSync('which ufw', { stdio: 'pipe' }); } catch {
|
|
297
|
+
execSync('apt-get update -qq && apt-get install -y -qq ufw', { stdio: 'pipe' });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const status = execSync('ufw status', { encoding: 'utf-8' });
|
|
301
|
+
if (status.includes('Status: active')) {
|
|
302
|
+
return { success: true, message: 'Firewall already active' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Default deny inbound, allow outbound
|
|
306
|
+
execSync('ufw default deny incoming', { stdio: 'pipe' });
|
|
307
|
+
execSync('ufw default allow outgoing', { stdio: 'pipe' });
|
|
308
|
+
execSync('ufw allow 2222/tcp', { stdio: 'pipe' });
|
|
309
|
+
execSync('echo "y" | ufw enable', { stdio: 'pipe' });
|
|
310
|
+
|
|
311
|
+
return { success: true, message: 'Firewall enabled (SSH port 2222 only, deny all inbound)' };
|
|
312
|
+
} catch (e) {
|
|
313
|
+
return { success: false, message: `Firewall setup failed: ${e.message}` };
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
{
|
|
319
|
+
name: 'setup-auto-updates',
|
|
320
|
+
description: 'Enable automatic security updates',
|
|
321
|
+
run: async () => {
|
|
322
|
+
try {
|
|
323
|
+
// Check if already configured
|
|
324
|
+
try {
|
|
325
|
+
const conf = fs.readFileSync('/etc/apt/apt.conf.d/20auto-upgrades', 'utf-8');
|
|
326
|
+
if (conf.includes('Unattended-Upgrade "1"')) {
|
|
327
|
+
return { success: true, message: 'Unattended upgrades already enabled' };
|
|
328
|
+
}
|
|
329
|
+
} catch {}
|
|
330
|
+
|
|
331
|
+
execSync('apt-get update -qq && apt-get install -y -qq unattended-upgrades', { stdio: 'pipe' });
|
|
332
|
+
|
|
333
|
+
const autoConf = `APT::Periodic::Update-Package-Lists "1";
|
|
334
|
+
APT::Periodic::Unattended-Upgrade "1";
|
|
335
|
+
APT::Periodic::AutocleanInterval "7";
|
|
336
|
+
`;
|
|
337
|
+
fs.writeFileSync('/etc/apt/apt.conf.d/20auto-upgrades', autoConf);
|
|
338
|
+
|
|
339
|
+
execSync('systemctl enable unattended-upgrades', { stdio: 'pipe' });
|
|
340
|
+
|
|
341
|
+
return { success: true, message: 'Automatic security updates enabled (daily check, weekly cleanup)' };
|
|
342
|
+
} catch (e) {
|
|
343
|
+
return { success: false, message: `Auto-updates setup failed: ${e.message}` };
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
{
|
|
349
|
+
name: 'kernel-hardening',
|
|
350
|
+
description: 'Apply kernel network security settings',
|
|
351
|
+
run: async () => {
|
|
352
|
+
try {
|
|
353
|
+
const sysctlConf = `/etc/sysctl.d/99-obol-hardening.conf`;
|
|
354
|
+
|
|
355
|
+
const settings = {
|
|
356
|
+
// SYN flood protection
|
|
357
|
+
'net.ipv4.tcp_syncookies': '1',
|
|
358
|
+
// Reverse path filtering
|
|
359
|
+
'net.ipv4.conf.all.rp_filter': '1',
|
|
360
|
+
'net.ipv4.conf.default.rp_filter': '1',
|
|
361
|
+
// Ignore ICMP redirects
|
|
362
|
+
'net.ipv4.conf.all.accept_redirects': '0',
|
|
363
|
+
'net.ipv4.conf.default.accept_redirects': '0',
|
|
364
|
+
'net.ipv6.conf.all.accept_redirects': '0',
|
|
365
|
+
// Don't send ICMP redirects
|
|
366
|
+
'net.ipv4.conf.all.send_redirects': '0',
|
|
367
|
+
// Ignore broadcast pings
|
|
368
|
+
'net.ipv4.icmp_echo_ignore_broadcasts': '1',
|
|
369
|
+
// Log martian packets
|
|
370
|
+
'net.ipv4.conf.all.log_martians': '1',
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const content = '# OBOL security hardening\n' +
|
|
374
|
+
Object.entries(settings).map(([k, v]) => `${k} = ${v}`).join('\n') + '\n';
|
|
375
|
+
|
|
376
|
+
fs.writeFileSync(sysctlConf, content);
|
|
377
|
+
execSync('sysctl --system 2>/dev/null', { stdio: 'pipe' });
|
|
378
|
+
|
|
379
|
+
return { success: true, message: 'Kernel hardening applied (syncookies, rp_filter, no redirects)' };
|
|
380
|
+
} catch (e) {
|
|
381
|
+
return { success: false, message: `Kernel hardening failed: ${e.message}` };
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
// ─── RUNNER ───
|
|
388
|
+
|
|
389
|
+
async function runPostSetup(config, reportFn) {
|
|
390
|
+
if (isPostSetupDone()) return;
|
|
391
|
+
|
|
392
|
+
reportFn?.('🪙 Running post-setup tasks...\n');
|
|
393
|
+
|
|
394
|
+
const results = [];
|
|
395
|
+
for (const task of SETUP_TASKS) {
|
|
396
|
+
reportFn?.(`⚙️ ${task.description}...`);
|
|
397
|
+
const result = await task.run(config);
|
|
398
|
+
results.push({ name: task.name, ...result });
|
|
399
|
+
reportFn?.(` ${result.success ? '✅' : '⚠️'} ${result.message}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
markPostSetupDone();
|
|
403
|
+
|
|
404
|
+
const summary = results.map(r => `${r.success ? '✅' : '⚠️'} ${r.name}: ${r.message}`).join('\n');
|
|
405
|
+
reportFn?.(`\n🪙 Post-setup complete!\n${summary}`);
|
|
406
|
+
|
|
407
|
+
return results;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
module.exports = { isPostSetupDone, runPostSetup, SETUP_TASKS };
|