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.
- package/.env.example +81 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/anyagent-bridge.js +127 -0
- package/client/index.html +525 -0
- package/config.example.json +69 -0
- package/docs/INSTALL.md +138 -0
- package/docs/ROADMAP.md +168 -0
- package/docs/SECURITY.md +85 -0
- package/docs/WALKTHROUGH.md +82 -0
- package/docs/screenshots/.gitkeep +3 -0
- package/docs/screenshots/01-startup-banner.png +0 -0
- package/docs/screenshots/02-terminal-view.png +0 -0
- package/docs/screenshots/03-agent-running.png +0 -0
- package/docs/screenshots/04-mobile.png +0 -0
- package/package.json +57 -0
- package/server/auth/index.js +20 -0
- package/server/auth/manager.js +448 -0
- package/server/auth/oauth.js +154 -0
- package/server/auth/providers/github.js +59 -0
- package/server/auth/providers/google.js +44 -0
- package/server/auth/sessions.js +160 -0
- package/server/auth/store.js +135 -0
- package/server/auth/totp.js +140 -0
- package/server/index.js +1779 -0
- package/server/safety/audit.js +139 -0
- package/server/safety/clientip.js +73 -0
- package/server/safety/index.js +17 -0
- package/server/safety/manager.js +507 -0
- package/server/safety/redact.js +153 -0
- package/server/safety/sandbox.js +130 -0
- package/server/tunnel/adapters/cloudflare-quick.js +40 -0
- package/server/tunnel/adapters/cloudflared-named.js +49 -0
- package/server/tunnel/adapters/devtunnel.js +54 -0
- package/server/tunnel/adapters/tailscale.js +42 -0
- package/server/tunnel/base-adapter.js +185 -0
- package/server/tunnel/detect.js +65 -0
- package/server/tunnel/index.js +15 -0
- package/server/tunnel/manager.js +321 -0
- package/server/tunnel/registry.js +31 -0
- package/test/stage4-boot.js +98 -0
- package/test/stage4-smoke.js +267 -0
package/server/index.js
ADDED
|
@@ -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
|
+
});
|