anyagent-bridge 0.5.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.
Files changed (42) hide show
  1. package/.env.example +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/bin/anyagent-bridge.js +127 -0
  5. package/client/index.html +525 -0
  6. package/config.example.json +69 -0
  7. package/docs/INSTALL.md +138 -0
  8. package/docs/ROADMAP.md +168 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/WALKTHROUGH.md +82 -0
  11. package/docs/screenshots/.gitkeep +3 -0
  12. package/docs/screenshots/01-startup-banner.png +0 -0
  13. package/docs/screenshots/02-terminal-view.png +0 -0
  14. package/docs/screenshots/03-agent-running.png +0 -0
  15. package/docs/screenshots/04-mobile.png +0 -0
  16. package/package.json +57 -0
  17. package/server/auth/index.js +20 -0
  18. package/server/auth/manager.js +448 -0
  19. package/server/auth/oauth.js +154 -0
  20. package/server/auth/providers/github.js +59 -0
  21. package/server/auth/providers/google.js +44 -0
  22. package/server/auth/sessions.js +160 -0
  23. package/server/auth/store.js +135 -0
  24. package/server/auth/totp.js +140 -0
  25. package/server/index.js +1779 -0
  26. package/server/safety/audit.js +139 -0
  27. package/server/safety/clientip.js +73 -0
  28. package/server/safety/index.js +17 -0
  29. package/server/safety/manager.js +507 -0
  30. package/server/safety/redact.js +153 -0
  31. package/server/safety/sandbox.js +130 -0
  32. package/server/tunnel/adapters/cloudflare-quick.js +40 -0
  33. package/server/tunnel/adapters/cloudflared-named.js +49 -0
  34. package/server/tunnel/adapters/devtunnel.js +54 -0
  35. package/server/tunnel/adapters/tailscale.js +42 -0
  36. package/server/tunnel/base-adapter.js +185 -0
  37. package/server/tunnel/detect.js +65 -0
  38. package/server/tunnel/index.js +15 -0
  39. package/server/tunnel/manager.js +321 -0
  40. package/server/tunnel/registry.js +31 -0
  41. package/test/stage4-boot.js +98 -0
  42. package/test/stage4-smoke.js +267 -0
@@ -0,0 +1,1779 @@
1
+ /**
2
+ * AnyAgent Bridge — Server (Stage 1: core)
3
+ *
4
+ * Control your local terminal and any CLI AI coding agent (Claude Code, Codex,
5
+ * aider, ...) from a web browser. Cross-platform (macOS / Windows / Linux).
6
+ *
7
+ * Stage 1 scope: terminal PTY bridge over WebSocket, persistent sessions,
8
+ * file-management API with a path whitelist, token auth. No tunnel (Stage 2),
9
+ * no OAuth (Stage 3), no sandbox (Stage 4), no packaging (Stage 5) — only clean
10
+ * extension seams are left in place.
11
+ */
12
+
13
+ require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
14
+
15
+ const WebSocket = require('ws');
16
+ const pty = require('node-pty');
17
+ const express = require('express');
18
+ const cors = require('cors');
19
+ const path = require('path');
20
+ const http = require('http');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const multer = require('multer');
24
+ const crypto = require('crypto');
25
+ const { createTunnelManager } = require('./tunnel');
26
+ const { createAuthManager } = require('./auth');
27
+ const { createSafetyManager, resolveClientIP } = require('./safety');
28
+
29
+ const ROOT = path.join(__dirname, '..');
30
+ const HOME = os.homedir();
31
+
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+ // Configuration
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+ //
36
+ // Load config.json (runtime, gitignored). If absent, fall back to
37
+ // config.example.json, then to built-in defaults — so the very first boot works
38
+ // with no config file present.
39
+
40
+ const DEFAULT_CONFIG = {
41
+ host: '127.0.0.1',
42
+ port: 3001,
43
+ shell: null,
44
+ auth: {
45
+ token: null,
46
+ sessionTtlHours: 12,
47
+ sessionSecret: null,
48
+ requireLogin: false,
49
+ totp: { enabled: true, issuer: 'AnyAgent Bridge', label: 'operator' },
50
+ oauth: {
51
+ enabled: false,
52
+ callbackBaseUrl: null,
53
+ claimFirstUser: true,
54
+ google: { clientId: null, clientSecret: null, allowedEmails: [] },
55
+ github: { clientId: null, clientSecret: null, allowedLogins: [] }
56
+ }
57
+ },
58
+ agents: [
59
+ { id: 'claude', name: 'Claude Code', command: 'claude' },
60
+ { id: 'codex', name: 'Codex', command: 'codex' }
61
+ ],
62
+ projects: [],
63
+ allowedPaths: [],
64
+ sessionTimeoutDays: 7,
65
+ tunnel: { enabled: false, provider: 'devtunnel' },
66
+ // Stage 4 (safety): all opt-in, all default-off. When safety.enabled is false the
67
+ // server is byte-identical to Stage 3.
68
+ safety: {
69
+ enabled: false,
70
+ trustProxy: false,
71
+ sandbox: {
72
+ enabled: false, image: null, network: 'bridge', mountMode: 'rw', workdir: '/workspace',
73
+ shell: null, memory: '2g', cpus: '2', pidsLimit: 512, noNewPrivileges: true,
74
+ readOnlyRootfs: false, dropAllCaps: false, runAsHostUser: false,
75
+ envPassthrough: ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'],
76
+ onDockerMissing: 'host', onMissingProject: 'host', extraArgs: []
77
+ },
78
+ killSwitch: { enabled: true, lockOnPanic: true, stopTunnelOnPanic: true, persistLock: true },
79
+ audit: { enabled: false, dir: null, includeReads: false, maxFileBytes: 10485760, retentionDays: 30 },
80
+ redaction: { liveStream: false, auditAlways: true, maxHoldBytes: 8192 }
81
+ }
82
+ };
83
+
84
+ function loadConfig() {
85
+ const candidates = [
86
+ path.join(ROOT, 'config.json'),
87
+ path.join(ROOT, 'config.example.json')
88
+ ];
89
+ for (const file of candidates) {
90
+ try {
91
+ if (fs.existsSync(file)) {
92
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
93
+ console.log(`[Config] Loaded ${path.basename(file)}`);
94
+ const pa = parsed.auth || {};
95
+ const po = pa.oauth || {};
96
+ return {
97
+ ...DEFAULT_CONFIG, ...parsed,
98
+ auth: {
99
+ ...DEFAULT_CONFIG.auth, ...pa,
100
+ totp: { ...DEFAULT_CONFIG.auth.totp, ...(pa.totp || {}) },
101
+ oauth: {
102
+ ...DEFAULT_CONFIG.auth.oauth, ...po,
103
+ google: { ...DEFAULT_CONFIG.auth.oauth.google, ...(po.google || {}) },
104
+ github: { ...DEFAULT_CONFIG.auth.oauth.github, ...(po.github || {}) }
105
+ }
106
+ },
107
+ tunnel: { ...DEFAULT_CONFIG.tunnel, ...(parsed.tunnel || {}) },
108
+ safety: (() => {
109
+ const ps = parsed.safety || {};
110
+ return {
111
+ ...DEFAULT_CONFIG.safety, ...ps,
112
+ sandbox: { ...DEFAULT_CONFIG.safety.sandbox, ...(ps.sandbox || {}) },
113
+ killSwitch: { ...DEFAULT_CONFIG.safety.killSwitch, ...(ps.killSwitch || {}) },
114
+ audit: { ...DEFAULT_CONFIG.safety.audit, ...(ps.audit || {}) },
115
+ redaction: { ...DEFAULT_CONFIG.safety.redaction, ...(ps.redaction || {}) }
116
+ };
117
+ })()
118
+ };
119
+ }
120
+ } catch (e) {
121
+ console.error(`[Config] Failed to parse ${path.basename(file)}: ${e.message}`);
122
+ }
123
+ }
124
+ console.log('[Config] No config file found — using built-in defaults');
125
+ return { ...DEFAULT_CONFIG };
126
+ }
127
+
128
+ const config = loadConfig();
129
+
130
+ // Cross-platform shell auto-detection.
131
+ // config.shell wins; otherwise: win32 → COMSPEC || powershell.exe, else SHELL || /bin/bash.
132
+ function resolveShell() {
133
+ if (config.shell) return config.shell;
134
+ if (process.platform === 'win32') {
135
+ return process.env.COMSPEC || 'powershell.exe';
136
+ }
137
+ return process.env.SHELL || '/bin/bash';
138
+ }
139
+
140
+ // Data directory (runtime state, gitignored).
141
+ const DATA_DIR = path.join(ROOT, '.data');
142
+ try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (e) { /* exists */ }
143
+
144
+ const CONFIG = {
145
+ HOST: process.env.HOST || config.host || '127.0.0.1',
146
+ PORT: parseInt(process.env.PORT, 10) || config.port || 3001,
147
+ SHELL: resolveShell(),
148
+ AGENTS: Array.isArray(config.agents) ? config.agents : [],
149
+ PROJECTS: Array.isArray(config.projects) ? config.projects : [],
150
+ SESSION_TIMEOUT: (config.sessionTimeoutDays || 7) * 24 * 60 * 60 * 1000,
151
+ SCROLLBACK_LIMIT: 10000,
152
+ SESSION_SAVE_PATH: path.join(ROOT, 'sessions.json'),
153
+ AUTH_FILE: path.join(DATA_DIR, 'auth.json'),
154
+ TUNNEL: (() => {
155
+ const t = { ...config.tunnel };
156
+ t.enabled = process.env.BRIDGE_TUNNEL_ENABLED === undefined
157
+ ? !!config.tunnel.enabled
158
+ : /^(1|true)$/i.test(process.env.BRIDGE_TUNNEL_ENABLED);
159
+ t.provider = process.env.BRIDGE_TUNNEL_PROVIDER || config.tunnel.provider || 'devtunnel';
160
+ if (process.env.BRIDGE_TUNNEL_HOSTNAME) {
161
+ t['cloudflared-named'] = { ...(t['cloudflared-named'] || {}), hostname: process.env.BRIDGE_TUNNEL_HOSTNAME };
162
+ }
163
+ return t;
164
+ })(),
165
+ AUTH: (() => {
166
+ const a = JSON.parse(JSON.stringify(config.auth || {})); // deep copy; never mutate loaded config
167
+ const envBool = (v) => /^(1|true)$/i.test(String(v));
168
+ if (process.env.BRIDGE_REQUIRE_LOGIN !== undefined) a.requireLogin = envBool(process.env.BRIDGE_REQUIRE_LOGIN);
169
+ if (process.env.BRIDGE_SESSION_SECRET) a.sessionSecret = process.env.BRIDGE_SESSION_SECRET;
170
+ if (process.env.BRIDGE_SESSION_TTL_HOURS) a.sessionTtlHours = parseInt(process.env.BRIDGE_SESSION_TTL_HOURS, 10) || a.sessionTtlHours;
171
+ a.totp = a.totp || {};
172
+ if (process.env.BRIDGE_TOTP_ENABLED !== undefined) a.totp.enabled = envBool(process.env.BRIDGE_TOTP_ENABLED);
173
+ a.oauth = a.oauth || {};
174
+ a.oauth.google = a.oauth.google || {};
175
+ a.oauth.github = a.oauth.github || {};
176
+ if (process.env.BRIDGE_OAUTH_ENABLED !== undefined) a.oauth.enabled = envBool(process.env.BRIDGE_OAUTH_ENABLED);
177
+ if (process.env.BRIDGE_OAUTH_CALLBACK_URL) a.oauth.callbackBaseUrl = process.env.BRIDGE_OAUTH_CALLBACK_URL;
178
+ if (process.env.BRIDGE_GOOGLE_CLIENT_ID) a.oauth.google.clientId = process.env.BRIDGE_GOOGLE_CLIENT_ID;
179
+ if (process.env.BRIDGE_GOOGLE_CLIENT_SECRET) a.oauth.google.clientSecret = process.env.BRIDGE_GOOGLE_CLIENT_SECRET;
180
+ if (process.env.BRIDGE_GITHUB_CLIENT_ID) a.oauth.github.clientId = process.env.BRIDGE_GITHUB_CLIENT_ID;
181
+ if (process.env.BRIDGE_GITHUB_CLIENT_SECRET) a.oauth.github.clientSecret = process.env.BRIDGE_GITHUB_CLIENT_SECRET;
182
+ return a;
183
+ })(),
184
+ // Stage 4 (safety): config.safety + BRIDGE_* env overrides. Defensive IIFE — a
185
+ // malformed override defaults rather than crashing boot.
186
+ SAFETY: (() => {
187
+ try {
188
+ const s = JSON.parse(JSON.stringify(config.safety || {}));
189
+ const envBool = (v) => /^(1|true)$/i.test(String(v));
190
+ if (process.env.BRIDGE_SAFETY_ENABLED !== undefined) s.enabled = envBool(process.env.BRIDGE_SAFETY_ENABLED);
191
+ s.sandbox = s.sandbox || {};
192
+ if (process.env.BRIDGE_SANDBOX_ENABLED !== undefined) s.sandbox.enabled = envBool(process.env.BRIDGE_SANDBOX_ENABLED);
193
+ if (process.env.BRIDGE_SANDBOX_IMAGE) s.sandbox.image = process.env.BRIDGE_SANDBOX_IMAGE;
194
+ if (process.env.BRIDGE_SANDBOX_NETWORK) s.sandbox.network = process.env.BRIDGE_SANDBOX_NETWORK;
195
+ if (process.env.BRIDGE_SANDBOX_ON_DOCKER_MISSING) s.sandbox.onDockerMissing = process.env.BRIDGE_SANDBOX_ON_DOCKER_MISSING;
196
+ s.audit = s.audit || {};
197
+ if (process.env.BRIDGE_AUDIT_ENABLED !== undefined) s.audit.enabled = envBool(process.env.BRIDGE_AUDIT_ENABLED);
198
+ if (process.env.BRIDGE_AUDIT_DIR) s.audit.dir = process.env.BRIDGE_AUDIT_DIR;
199
+ s.redaction = s.redaction || {};
200
+ if (process.env.BRIDGE_REDACT_LIVE !== undefined) s.redaction.liveStream = envBool(process.env.BRIDGE_REDACT_LIVE);
201
+ if (process.env.BRIDGE_TRUST_PROXY !== undefined) {
202
+ const v = String(process.env.BRIDGE_TRUST_PROXY).trim();
203
+ s.trustProxy = /^(false|0|off|no)$/i.test(v) ? false
204
+ : /^(true|1|on|yes)$/i.test(v) ? true
205
+ : (parseInt(v, 10) > 0 ? parseInt(v, 10) : false);
206
+ }
207
+ return s;
208
+ } catch (e) {
209
+ console.error(`[Config] safety override parse failed (${e.message}) — using defaults`);
210
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG.safety));
211
+ }
212
+ })()
213
+ };
214
+
215
+ // trustProxy is consumed by getClientIP (a server-level concern). It takes effect
216
+ // ONLY when the operator has opted into the safety subsystem (safety.enabled) or set
217
+ // BRIDGE_TRUST_PROXY — so with no safety config, getClientIP keeps the exact Stage-3
218
+ // behavior (byte-identical rule).
219
+ CONFIG.TRUST_PROXY = (CONFIG.SAFETY && CONFIG.SAFETY.trustProxy !== undefined) ? CONFIG.SAFETY.trustProxy : false;
220
+ CONFIG.TRUST_PROXY_SET = !!(CONFIG.SAFETY && CONFIG.SAFETY.enabled) || process.env.BRIDGE_TRUST_PROXY !== undefined;
221
+
222
+ // ═══════════════════════════════════════════════════════════════════════════
223
+ // Auth token
224
+ // ═══════════════════════════════════════════════════════════════════════════
225
+ //
226
+ // Token resolution order: env BRIDGE_AUTH_TOKEN → config.auth.token →
227
+ // persisted .data/auth.json → freshly generated (32 random bytes, hex) and
228
+ // persisted. There is never a default/blank token.
229
+
230
+ function loadOrCreateAuthToken() {
231
+ const fromEnv = process.env.BRIDGE_AUTH_TOKEN;
232
+ if (fromEnv) return { token: fromEnv, source: 'env' };
233
+
234
+ if (config.auth && config.auth.token) {
235
+ return { token: config.auth.token, source: 'config' };
236
+ }
237
+
238
+ try {
239
+ if (fs.existsSync(CONFIG.AUTH_FILE)) {
240
+ const data = JSON.parse(fs.readFileSync(CONFIG.AUTH_FILE, 'utf8'));
241
+ if (data && data.token) return { token: data.token, source: 'file' };
242
+ }
243
+ } catch (e) {
244
+ console.error(`[Auth] Failed to read ${CONFIG.AUTH_FILE}: ${e.message}`);
245
+ }
246
+
247
+ const token = crypto.randomBytes(32).toString('hex');
248
+ try {
249
+ fs.writeFileSync(CONFIG.AUTH_FILE, JSON.stringify({ token, createdAt: Date.now() }, null, 2), { mode: 0o600 });
250
+ } catch (e) {
251
+ console.error(`[Auth] Failed to persist token to ${CONFIG.AUTH_FILE}: ${e.message}`);
252
+ }
253
+ return { token, source: 'generated' };
254
+ }
255
+
256
+ const { token: AUTH_TOKEN, source: AUTH_TOKEN_SOURCE } = loadOrCreateAuthToken();
257
+
258
+ // Stage 2: tunnel manager (created idle; started after server.listen if enabled).
259
+ const tunnel = createTunnelManager(CONFIG.TUNNEL, console);
260
+
261
+ // ═══════════════════════════════════════════════════════════════════════════
262
+ // Security: path whitelist + rate limiting helpers
263
+ // ═══════════════════════════════════════════════════════════════════════════
264
+
265
+ // Allowed base paths for the file API. config.allowedPaths, or [HOME] by default.
266
+ const ALLOWED_BASE_PATHS = (Array.isArray(config.allowedPaths) && config.allowedPaths.length > 0)
267
+ ? config.allowedPaths.map(p => path.resolve(p.replace(/^~(?=$|[/\\])/, HOME)))
268
+ : [HOME];
269
+
270
+ // Sensitive directories denied even inside allowed bases (home-relative) plus the
271
+ // app's own .data dir (holds the auth token). Additive defense only.
272
+ const DENIED_PATHS = [
273
+ '.ssh', '.aws', '.gnupg', '.kube', path.join('.config', 'gcloud'),
274
+ // Common secret-bearing dotfiles: deny even though an authenticated user has a
275
+ // shell anyway — keeps the file API from being a quieter path to credentials.
276
+ '.env', '.npmrc', '.netrc', '.git-credentials',
277
+ path.join('.docker', 'config.json'), path.join('.config', 'gh'), path.join('.config', 'configstore')
278
+ ].map(seg => path.resolve(HOME, seg));
279
+ DENIED_PATHS.push(path.resolve(DATA_DIR));
280
+
281
+ function isPathDenied(normalizedPath) {
282
+ return DENIED_PATHS.some(denied =>
283
+ normalizedPath === denied || normalizedPath.startsWith(denied + path.sep));
284
+ }
285
+
286
+ function isPathAllowed(targetPath) {
287
+ if (!targetPath) return false;
288
+ const normalizedPath = path.resolve(targetPath);
289
+ if (isPathDenied(normalizedPath)) return false;
290
+ // path.sep suffix prevents sibling-prefix escapes (e.g. /home/userEVIL).
291
+ return ALLOWED_BASE_PATHS.some(basePath => {
292
+ const normalizedBase = path.resolve(basePath);
293
+ return normalizedPath === normalizedBase || normalizedPath.startsWith(normalizedBase + path.sep);
294
+ });
295
+ }
296
+
297
+ // Rate limiting (structure preserved for Stage 3 OAuth/login hardening).
298
+ const SECURITY = {
299
+ MAX_LOGIN_ATTEMPTS: 5,
300
+ LOGIN_LOCKOUT_TIME: 15 * 60 * 1000,
301
+ GLOBAL_MAX_LOGIN_FAILS: 20
302
+ };
303
+
304
+ const rateLimiter = {
305
+ loginAttempts: new Map(), // ip -> { count, lastAttempt }
306
+ globalFails: { count: 0, windowStart: 0 }
307
+ };
308
+
309
+ function getClientIP(req) {
310
+ // Stage 4: when the operator opts in (trustProxy configured), resolve the IP under
311
+ // an explicit proxy-trust policy — this closes the Stage-3 residual where a remote
312
+ // client could spoof X-Forwarded-For to dodge the per-IP login rate limit and forge
313
+ // the audit IP. With no opt-in, the original Stage-3 expression is kept verbatim.
314
+ if (CONFIG.TRUST_PROXY_SET) {
315
+ return resolveClientIP(req, CONFIG.TRUST_PROXY);
316
+ }
317
+ return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
318
+ req.socket?.remoteAddress ||
319
+ 'unknown';
320
+ }
321
+
322
+ function checkLoginRateLimit(ip) {
323
+ const g = rateLimiter.globalFails;
324
+ if (Date.now() - g.windowStart > SECURITY.LOGIN_LOCKOUT_TIME) {
325
+ g.count = 0;
326
+ g.windowStart = Date.now();
327
+ }
328
+ if (g.count >= SECURITY.GLOBAL_MAX_LOGIN_FAILS) return false;
329
+
330
+ const record = rateLimiter.loginAttempts.get(ip);
331
+ if (!record) return true;
332
+ if (Date.now() - record.lastAttempt > SECURITY.LOGIN_LOCKOUT_TIME) {
333
+ rateLimiter.loginAttempts.delete(ip);
334
+ return true;
335
+ }
336
+ return record.count < SECURITY.MAX_LOGIN_ATTEMPTS;
337
+ }
338
+
339
+ function recordLoginAttempt(ip, success) {
340
+ if (success) {
341
+ rateLimiter.loginAttempts.delete(ip);
342
+ return;
343
+ }
344
+ const g = rateLimiter.globalFails;
345
+ if (Date.now() - g.windowStart > SECURITY.LOGIN_LOCKOUT_TIME) {
346
+ g.count = 0;
347
+ g.windowStart = Date.now();
348
+ }
349
+ g.count++;
350
+
351
+ const record = rateLimiter.loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
352
+ record.count++;
353
+ record.lastAttempt = Date.now();
354
+ rateLimiter.loginAttempts.set(ip, record);
355
+ }
356
+
357
+ /** Constant-time string comparison (timing side-channel safe). */
358
+ function safeEqual(a, b) {
359
+ const ba = Buffer.from(a == null ? '' : String(a), 'utf8');
360
+ const bb = Buffer.from(b == null ? '' : String(b), 'utf8');
361
+ if (ba.length !== bb.length) {
362
+ crypto.timingSafeEqual(bb, bb); // flatten branch timing
363
+ return false;
364
+ }
365
+ return crypto.timingSafeEqual(ba, bb);
366
+ }
367
+
368
+ // Stage 3: auth manager (signed sessions + TOTP 2FA + Google/GitHub OAuth),
369
+ // layered on top of the Stage 1 static token. When OAuth is off, no TOTP is
370
+ // enrolled, and requireLogin is false, this is a no-op and the static token
371
+ // works everywhere exactly as in Stage 2.
372
+ const auth = createAuthManager(CONFIG.AUTH, {
373
+ logger: console,
374
+ dataDir: DATA_DIR,
375
+ staticToken: AUTH_TOKEN,
376
+ safeEqual,
377
+ getClientIP,
378
+ rateLimit: { check: checkLoginRateLimit, record: recordLoginAttempt }
379
+ });
380
+
381
+ // Stage 4: safety manager (Docker sandbox + kill-switch + audit log + secret
382
+ // redaction). Inert when safety.enabled is false (the default) → byte-identical to
383
+ // Stage 3. Reuses auth._isOperator for the operator gate; never reaches into auth
384
+ // internals beyond that.
385
+ const safety = createSafetyManager(CONFIG.SAFETY, {
386
+ logger: console,
387
+ dataDir: DATA_DIR,
388
+ baseShell: CONFIG.SHELL,
389
+ blockedDirs: [HOME, ...ALLOWED_BASE_PATHS],
390
+ secrets: { authToken: AUTH_TOKEN, sessionSecret: CONFIG.AUTH && CONFIG.AUTH.sessionSecret },
391
+ isOperator: (p) => auth._isOperator(p),
392
+ getClientIP
393
+ });
394
+
395
+ /**
396
+ * Auth middleware. Accepts the static token (when direct access is allowed) OR a
397
+ * valid signed session presented via cookie / Bearer / X-Session-Token / ?token /
398
+ * ?session. On success sets req.principal = {type:'token'} | {type:'session',...}.
399
+ */
400
+ function requireAuth(req, res, next) {
401
+ const principal = auth.resolvePrincipal(req);
402
+ if (principal) { req.principal = principal; return next(); }
403
+ return res.status(401).json({ error: 'Authentication required' });
404
+ }
405
+
406
+ // ═══════════════════════════════════════════════════════════════════════════
407
+ // Express app
408
+ // ═══════════════════════════════════════════════════════════════════════════
409
+
410
+ const app = express();
411
+ const server = http.createServer(app);
412
+
413
+ // CORS: allow same-origin / no-origin (curl, mobile) and localhost variants by
414
+ // default. Public hosts must rely on the token; CORS is config-driven later.
415
+ const corsAllowed = new Set([
416
+ `http://localhost:${CONFIG.PORT}`,
417
+ `http://127.0.0.1:${CONFIG.PORT}`,
418
+ process.env.ALLOWED_ORIGIN
419
+ ].filter(Boolean));
420
+
421
+ app.use(cors({
422
+ origin: (origin, callback) => {
423
+ if (!origin) return callback(null, true);
424
+ if (corsAllowed.has(origin)) return callback(null, true);
425
+ // Silent reject (never throw — avoids crashing the request pipeline).
426
+ return callback(null, false);
427
+ },
428
+ credentials: true
429
+ }));
430
+ app.use(express.json());
431
+
432
+ // Stage 3: parse the Cookie header into req.cookies (no dependency) so the auth
433
+ // middleware can read the session cookie.
434
+ app.use((req, res, next) => { req.cookies = auth.parseCookies(req.headers.cookie); next(); });
435
+
436
+ // Stage 3: CSRF defense-in-depth. The session cookie is SameSite=Lax, which
437
+ // already blocks it from riding cross-site writes; this additionally rejects any
438
+ // state-changing request that carries the session cookie with a cross-origin
439
+ // Origin header. Token/Bearer clients (curl, the default-mode UI) send no cookie
440
+ // and are unaffected — a bearer credential is not CSRF-able.
441
+ app.use((req, res, next) => {
442
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
443
+ if (!(req.cookies && req.cookies['aab_session'])) return next();
444
+ const origin = req.headers.origin;
445
+ if (!origin) return next(); // non-browser client, or same-origin without Origin
446
+ try {
447
+ const reqHost = req.headers['x-forwarded-host'] || req.headers.host;
448
+ if (new URL(origin).host !== reqHost) {
449
+ return res.status(403).json({ error: 'Cross-origin request blocked' });
450
+ }
451
+ } catch (e) {
452
+ return res.status(403).json({ error: 'Invalid Origin header' });
453
+ }
454
+ next();
455
+ });
456
+
457
+ // Stage 4: audit middleware. Mounted before the route definitions so it observes
458
+ // every /api route's completion; no-op (nothing added to the stack) when audit is
459
+ // off → byte-identical request pipeline.
460
+ safety.installAuditMiddleware(app);
461
+
462
+ // Mount /api/auth/* routes (login, OAuth, TOTP, sessions).
463
+ auth.registerRoutes(app, { requireAuth });
464
+
465
+ // Static client (no caching so updates are picked up immediately).
466
+ app.use(express.static(path.join(ROOT, 'client'), {
467
+ setHeaders: (res) => {
468
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
469
+ res.setHeader('Pragma', 'no-cache');
470
+ res.setHeader('Expires', '0');
471
+ }
472
+ }));
473
+
474
+ // Uploads.
475
+ const uploadsDir = path.join(ROOT, 'uploads');
476
+ if (!fs.existsSync(uploadsDir)) {
477
+ fs.mkdirSync(uploadsDir, { recursive: true });
478
+ }
479
+
480
+ const storage = multer.diskStorage({
481
+ destination: (req, file, cb) => cb(null, uploadsDir),
482
+ filename: (req, file, cb) => {
483
+ const uniqueSuffix = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
484
+ cb(null, `${uniqueSuffix}-${file.originalname}`);
485
+ }
486
+ });
487
+
488
+ const upload = multer({
489
+ storage,
490
+ limits: { fileSize: 10 * 1024 * 1024 },
491
+ fileFilter: (req, file, cb) => {
492
+ const allowedTypes = /jpeg|jpg|png|gif|webp/;
493
+ const ok = allowedTypes.test(file.mimetype) &&
494
+ allowedTypes.test(path.extname(file.originalname).toLowerCase());
495
+ if (ok) return cb(null, true);
496
+ cb(new Error('Only image files are allowed'));
497
+ }
498
+ });
499
+
500
+ // File-explorer upload: any file type.
501
+ const uploadAny = multer({
502
+ storage,
503
+ limits: { fileSize: 50 * 1024 * 1024 }
504
+ });
505
+
506
+ // ═══════════════════════════════════════════════════════════════════════════
507
+ // Session management
508
+ // ═══════════════════════════════════════════════════════════════════════════
509
+
510
+ const sessions = new Map();
511
+ let sessionIdCounter = 1;
512
+
513
+ class TerminalSession {
514
+ constructor(sessionId, projectPath = null, options = {}) {
515
+ this.sessionId = sessionId;
516
+ this.projectPath = projectPath;
517
+ this.displayName = options.displayName || options.projectName || this.getDefaultName();
518
+ this.color = options.color || 'default';
519
+ this.ptyProcess = null;
520
+ this.clients = new Set(); // multi-viewer: many browsers on one session
521
+ this.lastActivity = Date.now();
522
+ this.createdAt = options.createdAt || Date.now();
523
+ this.output = [];
524
+ this.activeAgentId = null;
525
+ this.containerName = null; // Stage 4: set when this session's PTY runs in a Docker sandbox
526
+ this._redactor = null; // Stage 4: per-PTY live-stream redactor (null unless redaction.liveStream)
527
+
528
+ this.init();
529
+ }
530
+
531
+ /**
532
+ * Stage 4 seam: resolve how to spawn this session's PTY. When the sandbox is off
533
+ * (the default) this returns the original host-shell spawn, byte-identical to
534
+ * earlier stages. When on it returns a `docker run` spec, or a 'refuse' marker.
535
+ */
536
+ _ptySpawnSpec(cwd) {
537
+ const baseEnv = this._spawnEnv();
538
+ const spec = safety.spawnSpecFor(this, cwd, baseEnv); // null when sandbox is off / not applicable
539
+ if (spec) return spec;
540
+ return { kind: 'host', file: CONFIG.SHELL, args: [], env: baseEnv, cwd };
541
+ }
542
+
543
+ getDefaultName() {
544
+ if (this.projectPath) {
545
+ const base = path.basename(this.projectPath);
546
+ return base || `Session ${this.sessionId}`;
547
+ }
548
+ return `Session ${this.sessionId}`;
549
+ }
550
+
551
+ setDisplayName(name) {
552
+ this.displayName = name || this.getDefaultName();
553
+ this.lastActivity = Date.now();
554
+ }
555
+
556
+ setColor(color) {
557
+ this.color = color || 'default';
558
+ this.lastActivity = Date.now();
559
+ }
560
+
561
+ _spawnEnv() {
562
+ // Remove nested-agent guard vars so a re-launched CLI agent inside the PTY
563
+ // doesn't think it's running inside another agent session.
564
+ const env = { ...process.env };
565
+ delete env.CLAUDECODE;
566
+ delete env.CLAUDE_CODE_ENTRYPOINT;
567
+ return env;
568
+ }
569
+
570
+ _resolveCwd() {
571
+ let cwd = this.projectPath || HOME;
572
+ try { if (!fs.existsSync(cwd)) cwd = HOME; } catch (e) { cwd = HOME; }
573
+ return cwd;
574
+ }
575
+
576
+ _wirePty() {
577
+ // Stage 4: a fresh live-stream redactor for this PTY, or null when
578
+ // redaction.liveStream is off (the default) → the onData path below is then
579
+ // byte-identical to earlier stages (no allocation, same `data` reference).
580
+ this._redactor = safety.newLiveStream();
581
+
582
+ this.ptyProcess.onData((data) => {
583
+ if (this._redactor) {
584
+ const clean = this._redactor.push(data);
585
+ this.lastActivity = Date.now();
586
+ if (!clean) return; // fully held back this tick; emitted on a later chunk/flush
587
+ data = clean;
588
+ }
589
+ this.output.push(data);
590
+ if (this.output.length > CONFIG.SCROLLBACK_LIMIT) {
591
+ this.output.shift();
592
+ }
593
+ this._broadcast({ type: 'output', data });
594
+ this.lastActivity = Date.now();
595
+ });
596
+
597
+ this.ptyProcess.onExit(({ exitCode }) => {
598
+ console.log(`[Session ${this.sessionId}] PTY exited (code: ${exitCode})`);
599
+ // Flush any redactor carry so trailing output is not swallowed.
600
+ if (this._redactor) {
601
+ try {
602
+ const tail = this._redactor.flush();
603
+ if (tail) { this.output.push(tail); this._broadcast({ type: 'output', data: tail }); }
604
+ } catch (e) { /* ignore */ }
605
+ this._redactor = null;
606
+ }
607
+ safety.noteSandboxExit(this); // Stage 4: auto-degrade if a sandbox keeps dying fast
608
+ this.ptyProcess = null; // mark dead
609
+ this.activeAgentId = null;
610
+ if (this.clients.size > 0) {
611
+ this._broadcast({ type: 'exit' });
612
+ this._respawnPty();
613
+ }
614
+ });
615
+ }
616
+
617
+ init() {
618
+ const cwd = this._resolveCwd();
619
+ const spec = this._ptySpawnSpec(cwd);
620
+ if (spec.kind === 'refuse') {
621
+ this.ptyProcess = null;
622
+ this.containerName = null;
623
+ this._broadcast({ type: 'output', data: spec.message });
624
+ console.warn(`[Session ${this.sessionId}] Spawn refused (sandbox):${spec.message.replace(/\r?\n/g, ' ')}`);
625
+ return;
626
+ }
627
+ this.containerName = spec.containerName || null;
628
+ this.ptyProcess = pty.spawn(spec.file, spec.args, {
629
+ name: 'xterm-256color',
630
+ cols: 80,
631
+ rows: 24,
632
+ cwd: spec.cwd,
633
+ env: spec.env
634
+ });
635
+ this._wirePty();
636
+ console.log(`[Session ${this.sessionId}] Created (cwd: ${cwd}${spec.sandboxed ? `, sandboxed: ${spec.containerName}` : ''})`);
637
+ }
638
+
639
+ /** Respawn a dead PTY so the session stays alive. Backoff against fork storms. */
640
+ _respawnPty() {
641
+ if (this.ptyProcess) return;
642
+
643
+ const nowTs = Date.now();
644
+ if (!this._respawnWindowStart || (nowTs - this._respawnWindowStart) > 10000) {
645
+ this._respawnWindowStart = nowTs;
646
+ this._respawnCount = 0;
647
+ }
648
+ this._respawnCount = (this._respawnCount || 0) + 1;
649
+ if (this._respawnCount > 5) {
650
+ console.error(`[Session ${this.sessionId}] PTY respawn storm (${this._respawnCount}/10s) — backing off 5s`);
651
+ setTimeout(() => { try { this._respawnPty(); } catch (e) {} }, 5000);
652
+ return;
653
+ }
654
+
655
+ const cwd = this._resolveCwd();
656
+ const spec = this._ptySpawnSpec(cwd);
657
+ if (spec.kind === 'refuse') {
658
+ this.ptyProcess = null;
659
+ this.containerName = null;
660
+ this._broadcast({ type: 'output', data: spec.message });
661
+ return;
662
+ }
663
+ try {
664
+ this.containerName = spec.containerName || null;
665
+ this.ptyProcess = pty.spawn(spec.file, spec.args, {
666
+ name: 'xterm-256color',
667
+ cols: 80,
668
+ rows: 24,
669
+ cwd: spec.cwd,
670
+ env: spec.env
671
+ });
672
+ this._wirePty();
673
+ console.log(`[Session ${this.sessionId}] PTY respawned (cwd: ${cwd})`);
674
+ this._broadcast({ type: 'output', data: '\r\n[Terminal respawned]\r\n' });
675
+ } catch (err) {
676
+ console.error(`[Session ${this.sessionId}] Failed to respawn PTY:`, err.message);
677
+ }
678
+ }
679
+
680
+ // Broadcast one message to every open viewer socket; self-heal dead sockets.
681
+ _broadcast(obj) {
682
+ const msg = JSON.stringify(obj);
683
+ for (const ws of this.clients) {
684
+ if (ws.readyState === WebSocket.OPEN) {
685
+ try { ws.send(msg); } catch (e) { this.clients.delete(ws); }
686
+ } else {
687
+ this.clients.delete(ws);
688
+ }
689
+ }
690
+ }
691
+
692
+ attach(ws) {
693
+ this.clients.add(ws);
694
+ this.lastActivity = Date.now();
695
+
696
+ if (!this.ptyProcess) {
697
+ this._respawnPty();
698
+ }
699
+
700
+ // Send scrollback only to the newly attached socket. Clean-reset the screen
701
+ // first so a truncated escape / misaligned grid doesn't render broken.
702
+ const scrollback = this.output.join('');
703
+ if (scrollback) {
704
+ ws.send(JSON.stringify({ type: 'output', data: '\x1b[H\x1b[2J\x1b[3J' + scrollback }));
705
+ }
706
+
707
+ console.log(`[Session ${this.sessionId}] Client attached (viewers: ${this.clients.size}, pty: ${this.ptyProcess ? 'alive' : 'dead'})`);
708
+ }
709
+
710
+ // With ws: detach just that socket. Without: detach all.
711
+ detach(ws) {
712
+ if (ws) {
713
+ this.clients.delete(ws);
714
+ } else {
715
+ this.clients.clear();
716
+ }
717
+ console.log(`[Session ${this.sessionId}] Client detached (viewers remaining: ${this.clients.size})`);
718
+ }
719
+
720
+ write(data) {
721
+ if (this.ptyProcess) {
722
+ this.ptyProcess.write(data);
723
+ this.lastActivity = Date.now();
724
+ } else {
725
+ console.warn(`[Session ${this.sessionId}] Write to dead PTY, respawning...`);
726
+ this._respawnPty();
727
+ if (this.ptyProcess) {
728
+ setTimeout(() => {
729
+ if (this.ptyProcess) this.ptyProcess.write(data);
730
+ }, 500);
731
+ }
732
+ }
733
+ }
734
+
735
+ resize(cols, rows) {
736
+ if (this.ptyProcess) {
737
+ this.ptyProcess.resize(cols, rows);
738
+ }
739
+ }
740
+
741
+ /** Launch a registered agent by writing its command into the PTY. */
742
+ startAgent(agent) {
743
+ if (!agent || !agent.command) {
744
+ console.warn(`[Session ${this.sessionId}] startAgent: invalid agent`);
745
+ return;
746
+ }
747
+ this.write(`${agent.command}\n`);
748
+ this.activeAgentId = agent.id;
749
+ console.log(`[Session ${this.sessionId}] Started agent '${agent.id}' (${agent.command})`);
750
+ }
751
+
752
+ /** Send a line of text to whatever is currently running in the PTY. */
753
+ sendToAgent(text) {
754
+ if (text == null) return;
755
+ this.write(String(text) + '\n');
756
+ }
757
+
758
+ destroy() {
759
+ if (this.ptyProcess) {
760
+ this.ptyProcess.kill();
761
+ this.ptyProcess = null;
762
+ }
763
+ // Stage 4: reap the sandbox container too — killing the PTY (the docker client)
764
+ // does not reliably stop the container, so the graceful delete path must not leak
765
+ // it. Best-effort; a no-op when this session was never sandboxed.
766
+ if (this.containerName) {
767
+ try { safety.reapContainer(this.containerName); } catch (e) { /* best-effort */ }
768
+ this.containerName = null;
769
+ }
770
+ for (const ws of this.clients) {
771
+ try { ws.close(); } catch (e) { /* already closed */ }
772
+ }
773
+ this.clients.clear();
774
+ console.log(`[Session ${this.sessionId}] Destroyed`);
775
+ }
776
+
777
+ isInactive() {
778
+ return Date.now() - this.lastActivity > CONFIG.SESSION_TIMEOUT;
779
+ }
780
+
781
+ toJSON() {
782
+ return {
783
+ sessionId: this.sessionId,
784
+ id: this.sessionId,
785
+ projectPath: this.projectPath,
786
+ cwd: this.projectPath || HOME,
787
+ projectName: this.getDefaultName(),
788
+ displayName: this.displayName,
789
+ color: this.color,
790
+ lastActivity: this.lastActivity,
791
+ createdAt: this.createdAt,
792
+ activeAgentId: this.activeAgentId,
793
+ outputLength: this.output.length
794
+ };
795
+ }
796
+ }
797
+
798
+ function loadSessions() {
799
+ try {
800
+ if (fs.existsSync(CONFIG.SESSION_SAVE_PATH)) {
801
+ const data = JSON.parse(fs.readFileSync(CONFIG.SESSION_SAVE_PATH, 'utf8'));
802
+ const list = Array.isArray(data.sessions) ? data.sessions : [];
803
+ console.log(`[Sessions] Loaded ${list.length} saved sessions`);
804
+
805
+ list.forEach(saved => {
806
+ const session = new TerminalSession(saved.sessionId, saved.projectPath, {
807
+ displayName: saved.displayName,
808
+ color: saved.color,
809
+ createdAt: saved.createdAt || saved.lastActivity
810
+ });
811
+ session.lastActivity = saved.lastActivity || Date.now();
812
+ sessions.set(saved.sessionId, session);
813
+ if (saved.sessionId >= sessionIdCounter) {
814
+ sessionIdCounter = saved.sessionId + 1;
815
+ }
816
+ });
817
+ }
818
+ } catch (err) {
819
+ console.error('[Sessions] Failed to load:', err.message);
820
+ }
821
+ }
822
+
823
+ function saveSessions() {
824
+ try {
825
+ const data = {
826
+ sessions: Array.from(sessions.values()).map(s => s.toJSON()),
827
+ lastSaved: new Date().toISOString()
828
+ };
829
+ fs.writeFileSync(CONFIG.SESSION_SAVE_PATH, JSON.stringify(data, null, 2));
830
+ } catch (err) {
831
+ console.error('[Sessions] Failed to save:', err.message);
832
+ }
833
+ }
834
+
835
+ function cleanupSessions() {
836
+ let cleaned = 0;
837
+ for (const [sessionId, session] of sessions.entries()) {
838
+ if (session.isInactive()) {
839
+ session.destroy();
840
+ sessions.delete(sessionId);
841
+ cleaned++;
842
+ }
843
+ }
844
+ if (cleaned > 0) {
845
+ console.log(`[Sessions] Cleaned up ${cleaned} inactive sessions`);
846
+ saveSessions();
847
+ }
848
+ }
849
+
850
+ // ═══════════════════════════════════════════════════════════════════════════
851
+ // REST API
852
+ // ═══════════════════════════════════════════════════════════════════════════
853
+
854
+ app.get('/health', (req, res) => {
855
+ res.json({
856
+ status: 'ok',
857
+ uptime: process.uptime(),
858
+ sessions: sessions.size,
859
+ platform: process.platform,
860
+ shell: CONFIG.SHELL
861
+ });
862
+ });
863
+
864
+ // Registered agents — used by the client to populate the agent dropdown.
865
+ // Public (no token) so the launcher UI can render before auth completes.
866
+ app.get('/api/agents', (req, res) => {
867
+ res.json({
868
+ agents: CONFIG.AGENTS.map(a => ({ id: a.id, name: a.name, command: a.command })),
869
+ count: CONFIG.AGENTS.length
870
+ });
871
+ });
872
+
873
+ // Persist projects back to config.json (runtime file).
874
+ function saveProjectsToConfig() {
875
+ const configFile = path.join(ROOT, 'config.json');
876
+ let current = {};
877
+ try {
878
+ if (fs.existsSync(configFile)) current = JSON.parse(fs.readFileSync(configFile, 'utf8'));
879
+ } catch (e) { current = {}; }
880
+ // Seed from in-memory config if no runtime file existed yet.
881
+ const merged = { ...DEFAULT_CONFIG, ...config, ...current };
882
+ merged.projects = CONFIG.PROJECTS;
883
+ try {
884
+ fs.writeFileSync(configFile, JSON.stringify(merged, null, 2), 'utf8');
885
+ } catch (e) {
886
+ console.error('[Config] Failed to save projects:', e.message);
887
+ }
888
+ }
889
+
890
+ app.get('/api/projects', requireAuth, (req, res) => {
891
+ res.json({ projects: CONFIG.PROJECTS, count: CONFIG.PROJECTS.length });
892
+ });
893
+
894
+ app.post('/api/projects', requireAuth, (req, res) => {
895
+ const { name, path: projectPath } = req.body;
896
+ if (!name || !projectPath) {
897
+ return res.status(400).json({ error: 'Name and path are required' });
898
+ }
899
+ if (CONFIG.PROJECTS.find(p => p.name === name || p.path === projectPath)) {
900
+ return res.status(400).json({ error: 'Project already exists' });
901
+ }
902
+ if (!fs.existsSync(projectPath)) {
903
+ return res.status(400).json({ error: 'Path does not exist' });
904
+ }
905
+ CONFIG.PROJECTS.push({ name, path: projectPath });
906
+ saveProjectsToConfig();
907
+ res.json({ success: true, project: { name, path: projectPath }, projects: CONFIG.PROJECTS });
908
+ });
909
+
910
+ app.delete('/api/projects/:name', requireAuth, (req, res) => {
911
+ const { name } = req.params;
912
+ const index = CONFIG.PROJECTS.findIndex(p => p.name === name);
913
+ if (index === -1) {
914
+ return res.status(404).json({ error: 'Project not found' });
915
+ }
916
+ CONFIG.PROJECTS.splice(index, 1);
917
+ saveProjectsToConfig();
918
+ res.json({ success: true, projects: CONFIG.PROJECTS });
919
+ });
920
+
921
+ app.put('/api/projects/:index', requireAuth, (req, res) => {
922
+ const index = parseInt(req.params.index, 10);
923
+ const { name, path: projectPath } = req.body;
924
+ if (!name || !projectPath) {
925
+ return res.status(400).json({ error: 'Name and path are required' });
926
+ }
927
+ if (index < 0 || index >= CONFIG.PROJECTS.length) {
928
+ return res.status(404).json({ error: 'Project not found' });
929
+ }
930
+ if (!fs.existsSync(projectPath)) {
931
+ return res.status(400).json({ error: 'Path does not exist' });
932
+ }
933
+ if (CONFIG.PROJECTS.find((p, i) => i !== index && (p.name === name || p.path === projectPath))) {
934
+ return res.status(400).json({ error: 'Project with this name or path already exists' });
935
+ }
936
+ CONFIG.PROJECTS[index] = { name, path: projectPath };
937
+ saveProjectsToConfig();
938
+ res.json({ success: true, project: { name, path: projectPath }, projects: CONFIG.PROJECTS });
939
+ });
940
+
941
+ app.get('/api/sessions', requireAuth, (req, res) => {
942
+ const sessionList = Array.from(sessions.values()).map(s => {
943
+ const json = s.toJSON();
944
+ if (json.projectPath) {
945
+ const matched = CONFIG.PROJECTS.find(p => p.path === json.projectPath);
946
+ if (matched && json.displayName === json.projectName) {
947
+ json.displayName = matched.name;
948
+ }
949
+ }
950
+ return json;
951
+ });
952
+ res.json({ sessions: sessionList, count: sessionList.length });
953
+ });
954
+
955
+ app.patch('/api/sessions/:sessionId/name', requireAuth, (req, res) => {
956
+ const sessionId = parseInt(req.params.sessionId, 10);
957
+ const session = sessions.get(sessionId);
958
+ if (!session) return res.status(404).json({ error: 'Session not found' });
959
+ session.setDisplayName(req.body.name);
960
+ saveSessions();
961
+ res.json({ success: true, session: session.toJSON() });
962
+ });
963
+
964
+ app.patch('/api/sessions/:sessionId/color', requireAuth, (req, res) => {
965
+ const sessionId = parseInt(req.params.sessionId, 10);
966
+ const session = sessions.get(sessionId);
967
+ if (!session) return res.status(404).json({ error: 'Session not found' });
968
+ session.setColor(req.body.color);
969
+ saveSessions();
970
+ res.json({ success: true, session: session.toJSON() });
971
+ });
972
+
973
+ app.delete('/api/sessions/:sessionId', requireAuth, (req, res) => {
974
+ const sessionId = parseInt(req.params.sessionId, 10);
975
+ const session = sessions.get(sessionId);
976
+ if (!session) return res.status(404).json({ error: 'Session not found' });
977
+ session.destroy();
978
+ sessions.delete(sessionId);
979
+ saveSessions();
980
+ console.log(`[Session ${sessionId}] Deleted by user request`);
981
+ res.json({ success: true, message: `Session ${sessionId} deleted` });
982
+ });
983
+
984
+ // System status. Stage 1 has no tunnel — reflected as tunnel: null.
985
+ app.get('/api/system/status', requireAuth, (req, res) => {
986
+ const body = {
987
+ server: {
988
+ host: CONFIG.HOST,
989
+ port: CONFIG.PORT,
990
+ uptime: process.uptime(),
991
+ sessions: sessions.size
992
+ },
993
+ tunnel: tunnel.getStatus(), // null when idle/disabled → Stage-1 shape preserved
994
+ auth: auth.getStatus(), // Stage 3: login policy + active session count (no secrets)
995
+ system: {
996
+ platform: process.platform,
997
+ shell: CONFIG.SHELL,
998
+ home: HOME
999
+ }
1000
+ };
1001
+ // Stage 4: append `safety` ONLY when the subsystem is on (getStatus() non-null),
1002
+ // as the last key, so the off-path JSON is byte-identical to Stage 3.
1003
+ const safetyStatus = safety.getStatus();
1004
+ if (safetyStatus) body.safety = safetyStatus;
1005
+ res.json(body);
1006
+ });
1007
+
1008
+ // ═══════════════════════════════════════════════════════════════════════════
1009
+ // Tunnel control (Stage 2). All behind requireAuth; never throw to the client.
1010
+ // ═══════════════════════════════════════════════════════════════════════════
1011
+
1012
+ app.get('/api/tunnel/status', requireAuth, (req, res) => {
1013
+ res.json(tunnel.getStatus() || { state: 'idle', provider: null, url: null });
1014
+ });
1015
+
1016
+ app.post('/api/tunnel/start', requireAuth, (req, res) => {
1017
+ tunnel.start(CONFIG.PORT); // idempotent; no-op if disabled or already running
1018
+ res.json(tunnel.getStatus() || { state: 'idle', provider: null, url: null });
1019
+ });
1020
+
1021
+ app.post('/api/tunnel/stop', requireAuth, async (req, res) => {
1022
+ await tunnel.stop(); // idempotent; safe if never started
1023
+ res.json(tunnel.getStatus() || { state: 'stopped', provider: null, url: null });
1024
+ });
1025
+
1026
+ app.post('/api/tunnel/restart', requireAuth, (req, res) => {
1027
+ tunnel.restart();
1028
+ res.json(tunnel.getStatus() || { state: 'idle', provider: null, url: null });
1029
+ });
1030
+
1031
+ // ═══════════════════════════════════════════════════════════════════════════
1032
+ // Safety control (Stage 4) — kill-switch + status. Registers nothing when
1033
+ // safety.enabled is false (no /api/safety/* routes exist) → byte-identical.
1034
+ // ═══════════════════════════════════════════════════════════════════════════
1035
+ safety.registerRoutes(app, { requireAuth, sessions, tunnel, saveSessions });
1036
+
1037
+ // Note: POST /api/auth/verify-local (token login) is now registered by the auth
1038
+ // manager (server/auth) alongside the rest of /api/auth/*, with 2FA enforcement
1039
+ // when a TOTP secret is enrolled. The response shape is unchanged when 2FA is off.
1040
+
1041
+ app.post('/api/upload-image', requireAuth, upload.single('image'), (req, res) => {
1042
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
1043
+ const fileUrl = `/uploads/${req.file.filename}`;
1044
+ const absolutePath = path.join(uploadsDir, req.file.filename);
1045
+ res.json({
1046
+ success: true,
1047
+ url: fileUrl,
1048
+ path: absolutePath,
1049
+ filename: req.file.filename,
1050
+ originalname: req.file.originalname,
1051
+ mimetype: req.file.mimetype,
1052
+ size: req.file.size,
1053
+ file: {
1054
+ filename: req.file.filename,
1055
+ originalname: req.file.originalname,
1056
+ mimetype: req.file.mimetype,
1057
+ size: req.file.size,
1058
+ url: fileUrl,
1059
+ absolutePath
1060
+ }
1061
+ });
1062
+ });
1063
+
1064
+ // Quick-access drives/roots for the folder picker (cross-platform).
1065
+ app.get('/api/drives', requireAuth, (req, res) => {
1066
+ const drives = [
1067
+ { name: 'Home', path: HOME },
1068
+ { name: 'Documents', path: path.join(HOME, 'Documents') },
1069
+ { name: 'Downloads', path: path.join(HOME, 'Downloads') },
1070
+ { name: 'Desktop', path: path.join(HOME, 'Desktop') }
1071
+ ];
1072
+ if (process.platform === 'win32') {
1073
+ drives.push({ name: 'C:\\', path: 'C:\\' });
1074
+ } else {
1075
+ drives.push({ name: 'Root', path: '/' });
1076
+ }
1077
+ res.json({ drives });
1078
+ });
1079
+
1080
+ // Folder-only browse (for project path selection).
1081
+ app.get('/api/browse', requireAuth, (req, res) => {
1082
+ const targetPath = req.query.path || HOME;
1083
+ try {
1084
+ if (!isPathAllowed(targetPath)) {
1085
+ console.warn(`[Browse] Blocked path: ${targetPath}`);
1086
+ return res.status(403).json({ error: 'Access denied: Path not allowed' });
1087
+ }
1088
+ if (!fs.existsSync(targetPath)) return res.json({ error: 'Path does not exist' });
1089
+ if (!fs.statSync(targetPath).isDirectory()) return res.json({ error: 'Not a directory' });
1090
+
1091
+ const folders = fs.readdirSync(targetPath, { withFileTypes: true })
1092
+ .filter(item => item.isDirectory() && !item.name.startsWith('.'))
1093
+ .map(item => ({ name: item.name, path: path.join(targetPath, item.name) }))
1094
+ .sort((a, b) => a.name.localeCompare(b.name));
1095
+
1096
+ const parent = path.dirname(targetPath) !== targetPath ? path.dirname(targetPath) : null;
1097
+ res.json({ current: targetPath, parent, folders });
1098
+ } catch (error) {
1099
+ console.error('[Browse] Error:', error.message);
1100
+ res.json({ error: error.message });
1101
+ }
1102
+ });
1103
+
1104
+ // File explorer tree (folders + files).
1105
+ app.get('/api/explorer/tree', requireAuth, (req, res) => {
1106
+ const targetPath = req.query.path || HOME;
1107
+ try {
1108
+ if (!isPathAllowed(targetPath)) {
1109
+ console.warn(`[Explorer] Blocked path: ${targetPath}`);
1110
+ return res.status(403).json({ error: 'Access denied: Path not allowed' });
1111
+ }
1112
+ if (!fs.existsSync(targetPath)) return res.json({ error: 'Path does not exist' });
1113
+ if (!fs.statSync(targetPath).isDirectory()) return res.json({ error: 'Not a directory' });
1114
+
1115
+ const items = fs.readdirSync(targetPath, { withFileTypes: true })
1116
+ .filter(item => !item.name.startsWith('.'))
1117
+ .map(item => {
1118
+ const itemPath = path.join(targetPath, item.name);
1119
+ const isDir = item.isDirectory();
1120
+ const ext = isDir ? null : path.extname(item.name).slice(1).toLowerCase();
1121
+ return {
1122
+ name: item.name,
1123
+ isDirectory: isDir,
1124
+ path: itemPath,
1125
+ extension: ext,
1126
+ type: ext === 'md' || ext === 'markdown' ? 'markdown' : (isDir ? 'folder' : 'file')
1127
+ };
1128
+ })
1129
+ .sort((a, b) => {
1130
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
1131
+ return a.name.localeCompare(b.name);
1132
+ });
1133
+
1134
+ const parent = path.dirname(targetPath) !== targetPath ? path.dirname(targetPath) : null;
1135
+ res.json({ path: targetPath, parent, items });
1136
+ } catch (error) {
1137
+ console.error('[Explorer] Error:', error.message);
1138
+ res.json({ error: error.message });
1139
+ }
1140
+ });
1141
+
1142
+ app.use('/uploads', express.static(uploadsDir));
1143
+
1144
+ // ═══════════════════════════════════════════════════════════════════════════
1145
+ // File management API (read / write / rename / move / delete / create)
1146
+ // ═══════════════════════════════════════════════════════════════════════════
1147
+
1148
+ app.get('/api/file', requireAuth, (req, res) => {
1149
+ const filePath = req.query.path;
1150
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1151
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied: Path not allowed' });
1152
+ try {
1153
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
1154
+ const stats = fs.statSync(filePath);
1155
+ if (stats.isDirectory()) return res.status(400).json({ error: 'Path is a directory, not a file' });
1156
+ if (stats.size > 5 * 1024 * 1024) return res.status(400).json({ error: 'File too large (max 5MB)' });
1157
+
1158
+ const content = fs.readFileSync(filePath, 'utf8');
1159
+ res.json({
1160
+ success: true,
1161
+ file: {
1162
+ path: filePath,
1163
+ name: path.basename(filePath),
1164
+ ext: path.extname(filePath).toLowerCase(),
1165
+ size: stats.size,
1166
+ modified: stats.mtime,
1167
+ content
1168
+ }
1169
+ });
1170
+ } catch (error) {
1171
+ console.error('[File Read] Error:', error.message);
1172
+ res.status(500).json({ error: error.message });
1173
+ }
1174
+ });
1175
+
1176
+ app.put('/api/file', requireAuth, (req, res) => {
1177
+ const { path: filePath, content } = req.body;
1178
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1179
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied: Path not allowed' });
1180
+ if (content === undefined) return res.status(400).json({ error: 'Content is required' });
1181
+ try {
1182
+ if (fs.existsSync(filePath)) {
1183
+ fs.copyFileSync(filePath, filePath + '.bak');
1184
+ }
1185
+ const dir = path.dirname(filePath);
1186
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1187
+ fs.writeFileSync(filePath, content, 'utf8');
1188
+ const stats = fs.statSync(filePath);
1189
+ console.log(`[File Write] ${filePath}`);
1190
+ res.json({ success: true, file: { path: filePath, name: path.basename(filePath), size: stats.size, modified: stats.mtime } });
1191
+ } catch (error) {
1192
+ console.error('[File Write] Error:', error.message);
1193
+ res.status(500).json({ error: error.message });
1194
+ }
1195
+ });
1196
+
1197
+ app.patch('/api/file/rename', requireAuth, (req, res) => {
1198
+ const { oldPath, newName } = req.body;
1199
+ if (!oldPath || !newName) return res.status(400).json({ error: 'oldPath and newName are required' });
1200
+ if (!isPathAllowed(oldPath)) return res.status(403).json({ error: 'Access denied: Path not allowed' });
1201
+ if (newName.includes('/') || newName.includes('\\')) {
1202
+ return res.status(400).json({ error: 'Invalid name: cannot contain path separators' });
1203
+ }
1204
+ try {
1205
+ if (!fs.existsSync(oldPath)) return res.status(404).json({ error: 'File or folder not found' });
1206
+ const newPath = path.join(path.dirname(oldPath), newName);
1207
+ if (!isPathAllowed(newPath)) return res.status(403).json({ error: 'Access denied: New path not allowed' });
1208
+ if (fs.existsSync(newPath)) return res.status(400).json({ error: 'A file or folder with this name already exists' });
1209
+ fs.renameSync(oldPath, newPath);
1210
+ console.log(`[File Rename] ${oldPath} -> ${newPath}`);
1211
+ res.json({ success: true, oldPath, newPath, newName });
1212
+ } catch (error) {
1213
+ console.error('[File Rename] Error:', error.message);
1214
+ res.status(500).json({ error: error.message });
1215
+ }
1216
+ });
1217
+
1218
+ app.patch('/api/file/move', requireAuth, (req, res) => {
1219
+ const { sourcePath, destinationDir } = req.body;
1220
+ if (!sourcePath || !destinationDir) return res.status(400).json({ error: 'sourcePath and destinationDir are required' });
1221
+ if (!isPathAllowed(sourcePath) || !isPathAllowed(destinationDir)) {
1222
+ return res.status(403).json({ error: 'Access denied: Path not allowed' });
1223
+ }
1224
+ try {
1225
+ if (!fs.existsSync(sourcePath)) return res.status(404).json({ error: 'Source file or folder not found' });
1226
+ if (!fs.existsSync(destinationDir)) return res.status(404).json({ error: 'Destination directory not found' });
1227
+ if (!fs.statSync(destinationDir).isDirectory()) return res.status(400).json({ error: 'Destination is not a directory' });
1228
+ const newPath = path.join(destinationDir, path.basename(sourcePath));
1229
+ if (fs.existsSync(newPath)) return res.status(400).json({ error: 'A file or folder with this name already exists in destination' });
1230
+ fs.renameSync(sourcePath, newPath);
1231
+ console.log(`[File Move] ${sourcePath} -> ${newPath}`);
1232
+ res.json({ success: true, sourcePath, newPath });
1233
+ } catch (error) {
1234
+ console.error('[File Move] Error:', error.message);
1235
+ res.status(500).json({ error: error.message });
1236
+ }
1237
+ });
1238
+
1239
+ app.delete('/api/file', requireAuth, (req, res) => {
1240
+ const filePath = req.query.path;
1241
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1242
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied: Path not allowed' });
1243
+ try {
1244
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File or folder not found' });
1245
+ if (fs.statSync(filePath).isDirectory()) {
1246
+ fs.rmSync(filePath, { recursive: true, force: true });
1247
+ } else {
1248
+ fs.unlinkSync(filePath);
1249
+ }
1250
+ console.log(`[File Delete] ${filePath}`);
1251
+ res.json({ success: true, deleted: filePath });
1252
+ } catch (error) {
1253
+ console.error('[File Delete] Error:', error.message);
1254
+ res.status(500).json({ error: error.message });
1255
+ }
1256
+ });
1257
+
1258
+ app.post('/api/folder', requireAuth, (req, res) => {
1259
+ const { path: folderPath } = req.body;
1260
+ if (!folderPath) return res.status(400).json({ error: 'Path is required' });
1261
+ if (!isPathAllowed(folderPath)) return res.status(403).json({ error: 'Access denied: Path not allowed' });
1262
+ try {
1263
+ if (fs.existsSync(folderPath)) return res.status(400).json({ error: 'Folder already exists' });
1264
+ fs.mkdirSync(folderPath, { recursive: true });
1265
+ console.log(`[Folder Create] ${folderPath}`);
1266
+ res.json({ success: true, path: folderPath });
1267
+ } catch (error) {
1268
+ console.error('[Folder Create] Error:', error.message);
1269
+ res.status(500).json({ error: error.message });
1270
+ }
1271
+ });
1272
+
1273
+ app.post('/api/file', requireAuth, (req, res) => {
1274
+ const { path: filePath, content = '' } = req.body;
1275
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1276
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied: Path not allowed' });
1277
+ try {
1278
+ if (fs.existsSync(filePath)) return res.status(400).json({ error: 'File already exists' });
1279
+ const dir = path.dirname(filePath);
1280
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1281
+ fs.writeFileSync(filePath, content, 'utf8');
1282
+ const stats = fs.statSync(filePath);
1283
+ console.log(`[File Create] ${filePath}`);
1284
+ res.json({ success: true, file: { path: filePath, name: path.basename(filePath), size: stats.size, modified: stats.mtime } });
1285
+ } catch (error) {
1286
+ console.error('[File Create] Error:', error.message);
1287
+ res.status(500).json({ error: error.message });
1288
+ }
1289
+ });
1290
+
1291
+ // ───────────────────────────────────────────────────────────────────────────
1292
+ // Explorer API aliases (client compatibility)
1293
+ // ───────────────────────────────────────────────────────────────────────────
1294
+
1295
+ app.get('/api/explorer/read', requireAuth, (req, res) => {
1296
+ const filePath = req.query.path;
1297
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1298
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied' });
1299
+ try {
1300
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
1301
+ const stats = fs.statSync(filePath);
1302
+ if (stats.isDirectory()) return res.status(400).json({ error: 'Path is a directory' });
1303
+ if (stats.size > 5 * 1024 * 1024) return res.status(400).json({ error: 'File too large (max 5MB)' });
1304
+ const content = fs.readFileSync(filePath, 'utf8');
1305
+ res.json({ success: true, content, path: filePath, name: path.basename(filePath) });
1306
+ } catch (error) {
1307
+ res.status(500).json({ error: error.message });
1308
+ }
1309
+ });
1310
+
1311
+ function handleFileWrite(req, res) {
1312
+ const { path: filePath, content } = req.body;
1313
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1314
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied' });
1315
+ try {
1316
+ const dir = path.dirname(filePath);
1317
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1318
+ fs.writeFileSync(filePath, content || '', 'utf8');
1319
+ res.json({ success: true, path: filePath });
1320
+ } catch (error) {
1321
+ res.status(500).json({ error: error.message });
1322
+ }
1323
+ }
1324
+ app.post('/api/explorer/write', requireAuth, handleFileWrite);
1325
+ app.put('/api/explorer/write', requireAuth, handleFileWrite);
1326
+
1327
+ app.post('/api/explorer/rename', requireAuth, (req, res) => {
1328
+ const { oldPath, newPath } = req.body;
1329
+ if (!oldPath || !newPath) return res.status(400).json({ error: 'Paths required' });
1330
+ if (!isPathAllowed(oldPath) || !isPathAllowed(newPath)) return res.status(403).json({ error: 'Access denied' });
1331
+ try {
1332
+ if (!fs.existsSync(oldPath)) return res.status(404).json({ error: 'Source not found' });
1333
+ fs.renameSync(oldPath, newPath);
1334
+ res.json({ success: true, oldPath, newPath });
1335
+ } catch (error) {
1336
+ res.status(500).json({ error: error.message });
1337
+ }
1338
+ });
1339
+
1340
+ function handleDelete(req, res) {
1341
+ const filePath = req.query.path || req.body?.path;
1342
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1343
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied' });
1344
+ try {
1345
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Not found' });
1346
+ let isDir = false;
1347
+ try { isDir = fs.statSync(filePath).isDirectory(); } catch (_) { isDir = false; }
1348
+ if (isDir) fs.rmSync(filePath, { recursive: true, force: true });
1349
+ else fs.unlinkSync(filePath);
1350
+ res.json({ success: true, path: filePath });
1351
+ } catch (error) {
1352
+ res.status(500).json({ error: error.message });
1353
+ }
1354
+ }
1355
+ app.delete('/api/explorer/delete', requireAuth, handleDelete);
1356
+ app.post('/api/explorer/delete', requireAuth, handleDelete);
1357
+
1358
+ app.post('/api/explorer/create-file', requireAuth, (req, res) => {
1359
+ let filePath, content = '';
1360
+ if (req.body.path) {
1361
+ filePath = req.body.path;
1362
+ content = req.body.content || '';
1363
+ } else if (req.body.parentPath && (req.body.name || req.body.fileName)) {
1364
+ filePath = path.join(req.body.parentPath, req.body.name || req.body.fileName);
1365
+ content = req.body.content || '';
1366
+ } else {
1367
+ return res.status(400).json({ error: 'Path or (parentPath + name/fileName) required' });
1368
+ }
1369
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied' });
1370
+ try {
1371
+ const dir = path.dirname(filePath);
1372
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1373
+ fs.writeFileSync(filePath, content, 'utf8');
1374
+ res.json({ success: true, path: filePath });
1375
+ } catch (error) {
1376
+ res.status(500).json({ error: error.message });
1377
+ }
1378
+ });
1379
+
1380
+ app.post('/api/explorer/create-folder', requireAuth, (req, res) => {
1381
+ let folderPath;
1382
+ if (req.body.path) {
1383
+ folderPath = req.body.path;
1384
+ } else if (req.body.parentPath && (req.body.name || req.body.folderName)) {
1385
+ folderPath = path.join(req.body.parentPath, req.body.name || req.body.folderName);
1386
+ } else {
1387
+ return res.status(400).json({ error: 'Path or (parentPath + name/folderName) required' });
1388
+ }
1389
+ if (!isPathAllowed(folderPath)) return res.status(403).json({ error: 'Access denied' });
1390
+ try {
1391
+ fs.mkdirSync(folderPath, { recursive: true });
1392
+ res.json({ success: true, path: folderPath });
1393
+ } catch (error) {
1394
+ res.status(500).json({ error: error.message });
1395
+ }
1396
+ });
1397
+
1398
+ app.post('/api/explorer/mkdir', requireAuth, (req, res) => {
1399
+ const { path: folderPath } = req.body;
1400
+ if (!folderPath) return res.status(400).json({ error: 'Path is required' });
1401
+ if (!isPathAllowed(folderPath)) return res.status(403).json({ error: 'Access denied' });
1402
+ try {
1403
+ fs.mkdirSync(folderPath, { recursive: true });
1404
+ res.json({ success: true, path: folderPath });
1405
+ } catch (error) {
1406
+ res.status(500).json({ error: error.message });
1407
+ }
1408
+ });
1409
+
1410
+ app.post('/api/explorer/move', requireAuth, (req, res) => {
1411
+ const { sourcePath, targetPath } = req.body;
1412
+ if (!sourcePath || !targetPath) return res.status(400).json({ error: 'Paths required' });
1413
+ if (!isPathAllowed(sourcePath) || !isPathAllowed(targetPath)) return res.status(403).json({ error: 'Access denied' });
1414
+ try {
1415
+ if (!fs.existsSync(sourcePath)) return res.status(404).json({ error: 'Source not found' });
1416
+ const destPath = path.join(targetPath, path.basename(sourcePath));
1417
+ fs.renameSync(sourcePath, destPath);
1418
+ res.json({ success: true, from: sourcePath, to: destPath });
1419
+ } catch (error) {
1420
+ res.status(500).json({ error: error.message });
1421
+ }
1422
+ });
1423
+
1424
+ app.get('/api/explorer/download', requireAuth, (req, res) => {
1425
+ const filePath = req.query.path;
1426
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1427
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied' });
1428
+ try {
1429
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
1430
+ res.download(filePath);
1431
+ } catch (error) {
1432
+ res.status(500).json({ error: error.message });
1433
+ }
1434
+ });
1435
+
1436
+ app.post('/api/explorer/upload', requireAuth, uploadAny.array('files', 20), (req, res) => {
1437
+ const targetPath = req.body.path || req.query.path;
1438
+ if (!targetPath) return res.status(400).json({ error: 'Target path required' });
1439
+ if (!isPathAllowed(targetPath)) return res.status(403).json({ error: 'Access denied' });
1440
+ try {
1441
+ const uploadedFiles = [];
1442
+ for (const file of req.files) {
1443
+ const destPath = path.join(targetPath, file.originalname);
1444
+ // Cross-device safe move: rename, fall back to copy+unlink on EXDEV.
1445
+ try {
1446
+ fs.renameSync(file.path, destPath);
1447
+ } catch (err) {
1448
+ if (err.code !== 'EXDEV') throw err;
1449
+ fs.copyFileSync(file.path, destPath);
1450
+ try { fs.unlinkSync(file.path); } catch (_) {}
1451
+ }
1452
+ uploadedFiles.push({ name: file.originalname, path: destPath });
1453
+ }
1454
+ res.json({ success: true, files: uploadedFiles });
1455
+ } catch (error) {
1456
+ res.status(500).json({ error: error.message });
1457
+ }
1458
+ });
1459
+
1460
+ app.get('/api/markdown/read', requireAuth, (req, res) => {
1461
+ const filePath = req.query.path;
1462
+ if (!filePath) return res.status(400).json({ error: 'Path is required' });
1463
+ if (!isPathAllowed(filePath)) return res.status(403).json({ error: 'Access denied' });
1464
+ try {
1465
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
1466
+ const content = fs.readFileSync(filePath, 'utf8');
1467
+ res.json({ success: true, content, path: filePath });
1468
+ } catch (error) {
1469
+ res.status(500).json({ error: error.message });
1470
+ }
1471
+ });
1472
+
1473
+ app.post('/api/upload-document', requireAuth, upload.single('document'), (req, res) => {
1474
+ if (!req.file) return res.status(400).json({ error: 'No document uploaded' });
1475
+ res.json({
1476
+ success: true,
1477
+ url: `/uploads/${req.file.filename}`,
1478
+ filename: req.file.filename,
1479
+ originalname: req.file.originalname,
1480
+ size: req.file.size
1481
+ });
1482
+ });
1483
+
1484
+ // ═══════════════════════════════════════════════════════════════════════════
1485
+ // WebSocket server
1486
+ // ═══════════════════════════════════════════════════════════════════════════
1487
+
1488
+ const wss = new WebSocket.Server({ server, path: '/ws' });
1489
+
1490
+ // Dead-connection detection — generous so quiet sessions behind pong-dropping
1491
+ // proxies are not killed.
1492
+ const WS_PING_INTERVAL = 30000;
1493
+ const WS_GRACE_PERIOD = 60000;
1494
+ const WS_MAX_MISSED_PINGS = 10;
1495
+
1496
+ const heartbeatChecker = setInterval(() => {
1497
+ const now = Date.now();
1498
+ wss.clients.forEach((ws) => {
1499
+ if (ws.connectedAt && (now - ws.connectedAt) < WS_GRACE_PERIOD) return;
1500
+ if (ws.missedPings === undefined) ws.missedPings = 0;
1501
+
1502
+ if (ws.isAlive === false) {
1503
+ if (ws._lastMessageAt && (now - ws._lastMessageAt) < 60000) {
1504
+ // Recent real traffic — treat as alive.
1505
+ } else {
1506
+ ws.missedPings++;
1507
+ if (ws.missedPings >= WS_MAX_MISSED_PINGS) {
1508
+ console.log(`[WebSocket] Terminating dead connection (${ws.missedPings} missed pings)`);
1509
+ return ws.terminate();
1510
+ }
1511
+ }
1512
+ } else {
1513
+ ws.missedPings = 0;
1514
+ }
1515
+ ws.isAlive = false;
1516
+ ws.ping();
1517
+ });
1518
+ }, WS_PING_INTERVAL);
1519
+
1520
+ wss.on('close', () => clearInterval(heartbeatChecker));
1521
+
1522
+ wss.on('connection', (ws, req) => {
1523
+ console.log('[WebSocket] New connection');
1524
+
1525
+ ws.isAlive = true;
1526
+ ws.connectedAt = Date.now();
1527
+ ws._lastMessageAt = Date.now();
1528
+ try { req.socket.setKeepAlive(true, 30000); req.socket.setNoDelay(true); } catch (e) {}
1529
+
1530
+ const clientIP = getClientIP(req);
1531
+
1532
+ // Stage 3: accept the static token (when direct access is allowed) OR a valid
1533
+ // session via ?session=, ?token=, or the session cookie sent on the upgrade.
1534
+ const principal = auth.verifyWs(req);
1535
+ if (!principal) {
1536
+ console.warn(`[WebSocket] Rejected unauthenticated connection from ${clientIP}`);
1537
+ try { ws.send(JSON.stringify({ type: 'error', message: 'Authentication required' })); } catch (e) {}
1538
+ ws.close(4001, 'Authentication required');
1539
+ return;
1540
+ }
1541
+
1542
+ console.log(`[WebSocket] Authenticated connection from ${clientIP} (${principal.type})`);
1543
+ ws.principal = principal; // Stage 4: used by the operator-gated kill/panic WS path
1544
+
1545
+ let currentSession = null;
1546
+ let heartbeatInterval = null;
1547
+
1548
+ ws.on('pong', () => { ws.isAlive = true; });
1549
+
1550
+ heartbeatInterval = setInterval(() => {
1551
+ if (ws.readyState === WebSocket.OPEN) {
1552
+ ws.send(JSON.stringify({ type: 'ping' }));
1553
+ }
1554
+ }, 30000);
1555
+
1556
+ ws.on('message', (message) => {
1557
+ try {
1558
+ ws._lastMessageAt = Date.now();
1559
+ const msg = JSON.parse(message);
1560
+
1561
+ // Stage 4: operator panic/kill over WebSocket. Consumed only when safety is on
1562
+ // AND the principal is an operator; returns false otherwise → the switch below
1563
+ // runs unchanged (byte-identical when off).
1564
+ if (safety.handleWsMessage(msg, { principal, sessions, tunnel, ws, saveSessions, clientIP })) return;
1565
+
1566
+ switch (msg.type) {
1567
+ case 'init': {
1568
+ const { sessionId, projectPath, projectName, cols, rows } = msg;
1569
+ const numericSessionId = sessionId ? parseInt(sessionId, 10) : null;
1570
+ let isReconnect = false;
1571
+
1572
+ if (numericSessionId && sessions.has(numericSessionId)) {
1573
+ currentSession = sessions.get(numericSessionId);
1574
+ currentSession.attach(ws);
1575
+ currentSession.resize(cols || 80, rows || 24);
1576
+ isReconnect = true;
1577
+ } else {
1578
+ const newSessionId = sessionIdCounter++;
1579
+ currentSession = new TerminalSession(newSessionId, projectPath, { projectName: projectName || null });
1580
+ sessions.set(newSessionId, currentSession);
1581
+ currentSession.attach(ws);
1582
+ currentSession.resize(cols || 80, rows || 24);
1583
+ saveSessions();
1584
+ }
1585
+
1586
+ ws.send(JSON.stringify({
1587
+ type: 'ready',
1588
+ sessionId: currentSession.sessionId,
1589
+ projectPath: currentSession.projectPath,
1590
+ isReconnect,
1591
+ persistent: true
1592
+ }));
1593
+ break;
1594
+ }
1595
+
1596
+ case 'input':
1597
+ if (currentSession) currentSession.write(msg.data);
1598
+ break;
1599
+
1600
+ case 'resize':
1601
+ if (currentSession) currentSession.resize(msg.cols, msg.rows);
1602
+ break;
1603
+
1604
+ case 'startAgent': {
1605
+ if (!currentSession) break;
1606
+ const agent = CONFIG.AGENTS.find(a => a.id === msg.agentId);
1607
+ if (!agent) {
1608
+ ws.send(JSON.stringify({ type: 'error', message: `Unknown agentId: ${msg.agentId}` }));
1609
+ break;
1610
+ }
1611
+ // Stage 4: refuse new agent launches while the bridge is panic-locked
1612
+ // (always allowed when safety/kill-switch is off).
1613
+ if (!safety.canLaunchAgent()) {
1614
+ ws.send(JSON.stringify({ type: 'error', message: 'Bridge is locked (panic) — unlock to launch agents.' }));
1615
+ break;
1616
+ }
1617
+ currentSession.startAgent(agent);
1618
+ safety.auditWs('agent.start', { principal, target: agent.id, termSessionId: currentSession.sessionId, clientIP });
1619
+ break;
1620
+ }
1621
+
1622
+ case 'sendToAgent':
1623
+ if (currentSession) {
1624
+ const text = msg.text ?? msg.message ?? msg.command;
1625
+ currentSession.sendToAgent(text);
1626
+ safety.auditWs('agent.send', { principal, target: text, termSessionId: currentSession.sessionId, clientIP });
1627
+ }
1628
+ break;
1629
+
1630
+ case 'detach':
1631
+ if (currentSession) {
1632
+ currentSession.detach(ws);
1633
+ saveSessions();
1634
+ ws.send(JSON.stringify({ type: 'detached', sessionId: currentSession.sessionId }));
1635
+ }
1636
+ break;
1637
+
1638
+ case 'ping':
1639
+ ws.send(JSON.stringify({ type: 'pong' }));
1640
+ break;
1641
+
1642
+ case 'pong':
1643
+ break;
1644
+
1645
+ default:
1646
+ console.warn('[WebSocket] Unknown message type:', msg.type);
1647
+ }
1648
+ } catch (err) {
1649
+ console.error('[WebSocket] Message error:', err.message);
1650
+ try { ws.send(JSON.stringify({ type: 'error', message: err.message })); } catch (e) {}
1651
+ }
1652
+ });
1653
+
1654
+ ws.on('close', () => {
1655
+ console.log('[WebSocket] Connection closed');
1656
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
1657
+ if (currentSession) {
1658
+ currentSession.detach(ws);
1659
+ saveSessions();
1660
+ }
1661
+ });
1662
+
1663
+ ws.on('error', (err) => {
1664
+ console.error('[WebSocket] Error:', err.message);
1665
+ if (currentSession) currentSession.detach(ws);
1666
+ });
1667
+ });
1668
+
1669
+ // ═══════════════════════════════════════════════════════════════════════════
1670
+ // Startup + lifecycle
1671
+ // ═══════════════════════════════════════════════════════════════════════════
1672
+
1673
+ loadSessions();
1674
+
1675
+ setInterval(saveSessions, 5 * 60 * 1000);
1676
+ setInterval(cleanupSessions, 60 * 60 * 1000);
1677
+
1678
+ // Crash prevention — keep the server alive through runtime exceptions, but exit
1679
+ // cleanly on fatal bind errors so a supervisor can restart.
1680
+ process.on('uncaughtException', (err) => {
1681
+ console.error('[CRITICAL] Uncaught Exception:', err.message);
1682
+ console.error(err.stack);
1683
+ try { saveSessions(); } catch (e) {}
1684
+ if (err && (err.code === 'EADDRINUSE' || err.code === 'EACCES')) {
1685
+ console.error(`[CRITICAL] Fatal bind error (${err.code}) — exiting`);
1686
+ try { safety.flushSync(); } catch (e) {} // Stage 4: don't lose the audit tail on fatal exit
1687
+ setTimeout(() => process.exit(1), 200);
1688
+ }
1689
+ });
1690
+
1691
+ process.on('unhandledRejection', (reason) => {
1692
+ console.error('[CRITICAL] Unhandled Rejection (server continues):', reason);
1693
+ try { saveSessions(); } catch (e) {}
1694
+ });
1695
+
1696
+ function shutdown(signal) {
1697
+ console.log(`\n[Server] Shutting down (${signal})...`);
1698
+ saveSessions();
1699
+ sessions.forEach(session => session.destroy());
1700
+ safety.flushSync(); // Stage 4: persist the audit tail before exit
1701
+ safety.sweepOnShutdown(); // Stage 4: best-effort reap of any stray sandbox containers
1702
+ // Stop the tunnel child too, but never let a hung CLI block exit.
1703
+ const hardExit = setTimeout(() => process.exit(0), 3000);
1704
+ if (hardExit.unref) hardExit.unref();
1705
+ Promise.resolve(tunnel.stop()).catch(() => {}).finally(() => {
1706
+ clearTimeout(hardExit);
1707
+ process.exit(0);
1708
+ });
1709
+ }
1710
+ process.on('SIGINT', () => shutdown('SIGINT'));
1711
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1712
+
1713
+ server.listen(CONFIG.PORT, CONFIG.HOST, () => {
1714
+ const displayHost = CONFIG.HOST === '0.0.0.0' ? '127.0.0.1' : CONFIG.HOST;
1715
+ const accessUrl = `http://${displayHost}:${CONFIG.PORT}`;
1716
+
1717
+ console.log('===============================================================');
1718
+ console.log(' AnyAgent Bridge — server running');
1719
+ console.log('===============================================================');
1720
+ console.log(` URL: ${accessUrl}?token=${AUTH_TOKEN}`);
1721
+ console.log(` WebSocket: ws://${displayHost}:${CONFIG.PORT}/ws`);
1722
+ console.log(` Host: ${CONFIG.HOST}`);
1723
+ console.log(` Shell: ${CONFIG.SHELL}`);
1724
+ console.log(` Agents: ${CONFIG.AGENTS.map(a => a.id).join(', ') || '(none)'}`);
1725
+ console.log(` Projects: ${CONFIG.PROJECTS.length}`);
1726
+ console.log(` Sessions: ${sessions.size}`);
1727
+ console.log('---------------------------------------------------------------');
1728
+ console.log(` Access token (${AUTH_TOKEN_SOURCE}): ${AUTH_TOKEN}`);
1729
+ if (CONFIG.HOST === '0.0.0.0') {
1730
+ console.log('---------------------------------------------------------------');
1731
+ console.log(' WARNING: bound to 0.0.0.0 — the server is reachable on your');
1732
+ console.log(' network/internet. The access token is the ONLY gate. Anyone');
1733
+ console.log(' with the token gets full terminal + file access. Stage 1 has');
1734
+ console.log(' no tunnel/TLS; do not expose this publicly without a proxy.');
1735
+ }
1736
+ // Stage 3: summarize active login policy. Silent (byte-identical to Stage 2)
1737
+ // when OAuth is off, no TOTP is enrolled, and requireLogin is false.
1738
+ if (auth.isEnhanced()) {
1739
+ const st = auth.getStatus();
1740
+ const methods = [];
1741
+ if (st.oauth.enabled) {
1742
+ const provs = Object.entries(st.oauth.providers).filter(([, on]) => on).map(([id]) => id);
1743
+ methods.push(`oauth(${provs.join(',') || 'none configured'})`);
1744
+ }
1745
+ if (st.totp.confirmed) methods.push('token+2FA');
1746
+ else methods.push('token');
1747
+ console.log('---------------------------------------------------------------');
1748
+ console.log(` Login: ${methods.join(', ')}`);
1749
+ console.log(` Token: ${st.tokenDirectAccess ? 'direct access enabled' : 'login-only (must exchange for a session)'}`);
1750
+ if (st.requireLogin) console.log(' requireLogin: on (static token cannot be used directly)');
1751
+ // Behind a tunnel the OAuth redirect_uri is derived from request headers
1752
+ // (Host/X-Forwarded-Host) unless pinned. Warn so logins don't silently break
1753
+ // or trust a spoofable host.
1754
+ if (st.oauth.enabled && !CONFIG.AUTH.oauth.callbackBaseUrl) {
1755
+ console.log(' WARNING: OAuth is on but auth.oauth.callbackBaseUrl is unset — the redirect URI');
1756
+ console.log(' will be derived from request headers. Set it to your public URL');
1757
+ console.log(' (e.g. your tunnel URL) for reliable, non-spoofable callbacks.');
1758
+ }
1759
+ }
1760
+ // Stage 4: safety subsystem summary. Returns zero lines when safety is off, so the
1761
+ // banner is byte-identical to Stage 3 in the default configuration.
1762
+ for (const line of safety.bootSummaryLines()) console.log(line);
1763
+ console.log('===============================================================');
1764
+
1765
+ // Stage 2: start the configured tunnel AFTER the local server is up. The URL
1766
+ // arrives asynchronously and never delays listen(). When disabled, the banner
1767
+ // above is byte-identical to Stage 1.
1768
+ if (CONFIG.TUNNEL && CONFIG.TUNNEL.enabled) {
1769
+ console.log(` Tunnel: ${CONFIG.TUNNEL.provider} (starting...)`);
1770
+ tunnel.once('ready', (s) => {
1771
+ if (s && s.url) console.log(` Tunnel: ${s.url} (${s.provider})`);
1772
+ else console.log(` Tunnel: ${s.provider} connected (no public URL to display)`);
1773
+ });
1774
+ tunnel.on('state', (s) => {
1775
+ if (s && s.state === 'error') console.warn(` Tunnel: error — ${s.lastError || 'unavailable'}`);
1776
+ });
1777
+ tunnel.start(CONFIG.PORT);
1778
+ }
1779
+ });