| import express from 'express'; |
| import { spawn, execSync } from 'child_process'; |
| import crypto from 'crypto'; |
| import fs from 'fs'; |
| import os from 'os'; |
| import path from 'path'; |
| import { fileURLToPath } from 'url'; |
|
|
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
|
|
| const app = express(); |
| const PORT = process.env.PORT || 7860; |
| const SECRET_KEY = process.env.SECRET_KEY; |
|
|
| if (!SECRET_KEY) { |
| console.error('[ERROR] SECRET_KEY environment variable is not set. Exiting.'); |
| process.exit(1); |
| } |
|
|
| app.use(express.json()); |
| app.use(express.static(path.join(__dirname, 'public'))); |
|
|
| |
| const sessions = new Set(); |
|
|
| |
| function stripAnsi(str) { |
| |
| return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]/g, ''); |
| } |
|
|
| |
| function seedShellularConfig() { |
| const hostId = process.env.SHELLULAR_HOST_ID; |
| const keyB64 = process.env.SHELLULAR_KEY; |
| const machineId = process.env.SHELLULAR_MACHINE_ID; |
|
|
| if (!hostId || !keyB64 || !machineId) return; |
|
|
| const shellularDir = path.join(os.homedir(), '.shellular'); |
| const configFile = path.join(shellularDir, 'config.json'); |
| const keyFile = path.join(shellularDir, `shellular-${machineId}.e2ee`); |
|
|
| try { |
| fs.mkdirSync(shellularDir, { recursive: true }); |
| if (!fs.existsSync(configFile)) { |
| fs.writeFileSync(configFile, JSON.stringify({ hostId, machineId }), 'utf-8'); |
| console.log(`[shellular] seeded config: hostId=${hostId}`); |
| } |
| if (!fs.existsSync(keyFile)) { |
| fs.writeFileSync(keyFile, Buffer.from(keyB64, 'base64'), { mode: 0o600 }); |
| console.log(`[shellular] seeded key: ${keyFile}`); |
| } |
| } catch (err) { |
| console.error('[shellular] failed to seed config:', err.message); |
| } |
| } |
|
|
| seedShellularConfig(); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function findShellularRoot() { |
| try { |
| const globalRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim(); |
| const p = path.join(globalRoot, 'shellular'); |
| if (fs.existsSync(p)) return p; |
| } catch {} |
| |
| return '/home/node/.npm-global/lib/node_modules/shellular'; |
| } |
| const PATCH_FILE = path.join(findShellularRoot(), 'fetch-patch.mjs'); |
|
|
| function writeFetchPatch() { |
| const code = ` |
| import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'; |
| |
| const _orig = getGlobalDispatcher(); |
| |
| // ββ Handler wrapper β buffers JSON responses and patches the success field βββ |
| class SuccessPatchingHandler { |
| constructor(inner) { |
| this.inner = inner; |
| this.isJson = false; |
| this.chunks = []; |
| } |
| onConnect(abort) { return this.inner.onConnect(abort); } |
| onError(err) { return this.inner.onError(err); } |
| onUpgrade(...a) { return this.inner.onUpgrade?.(...a); } |
| onBodySent(chunk) { return this.inner.onBodySent?.(chunk); } |
| |
| onHeaders(statusCode, headers, resume, statusMessage) { |
| // headers is a flat Buffer array: [name, value, name, value β¦] |
| for (let i = 0; i < headers.length; i += 2) { |
| if ( |
| headers[i].toString().toLowerCase() === 'content-type' && |
| headers[i + 1].toString().includes('application/json') |
| ) { |
| this.isJson = true; |
| } |
| } |
| return this.inner.onHeaders(statusCode, headers, resume, statusMessage); |
| } |
| |
| onData(chunk) { |
| if (this.isJson) { |
| this.chunks.push(chunk); |
| return true; // signal undici to keep reading |
| } |
| return this.inner.onData(chunk); |
| } |
| |
| onComplete(trailers) { |
| if (this.isJson && this.chunks.length) { |
| const raw = Buffer.concat(this.chunks).toString('utf-8'); |
| try { |
| const json = JSON.parse(raw); |
| if ('success' in json && typeof json.success !== 'boolean') { |
| const s = json.success; |
| const before = s; |
| // Coerce: anything truthy except the strings "0" / "false" β true |
| json.success = ( |
| s === true || s === 1 || |
| (typeof s === 'string' && s.length > 0 && s !== '0' && s !== 'false') |
| ); |
| console.log('[patch] coerced success field:', JSON.stringify(before), 'β', json.success); |
| this.inner.onData(Buffer.from(JSON.stringify(json))); |
| } else { |
| // Nothing to patch β pass through as-is |
| this.inner.onData(Buffer.concat(this.chunks)); |
| } |
| } catch (e) { |
| // Not valid JSON β pass through unchanged |
| this.inner.onData(Buffer.concat(this.chunks)); |
| } |
| } |
| return this.inner.onComplete(trailers); |
| } |
| } |
| |
| // ββ Dispatcher wrapper β routes every request through the patching handler βββ |
| class SuccessPatchingDispatcher { |
| dispatch(opts, handler) { |
| return _orig.dispatch(opts, new SuccessPatchingHandler(handler)); |
| } |
| close() { return _orig.close?.(); } |
| destroy(err, cb) { return _orig.destroy?.(err, cb); } |
| } |
| |
| setGlobalDispatcher(new SuccessPatchingDispatcher()); |
| console.log('[patch] undici dispatcher patched β success field will be coerced to boolean'); |
| `; |
| fs.writeFileSync(PATCH_FILE, code, 'utf-8'); |
| console.log('[patch] undici dispatcher patch written to', PATCH_FILE); |
| } |
|
|
| writeFetchPatch(); |
|
|
| |
| app.post('/api/login', (req, res) => { |
| const { key } = req.body; |
| if (!key || key !== SECRET_KEY) { |
| return res.status(401).json({ error: 'Invalid key' }); |
| } |
| const token = crypto.randomUUID(); |
| sessions.add(token); |
| res.json({ token }); |
| }); |
|
|
| app.post('/api/logout', (req, res) => { |
| const token = req.headers.authorization?.split(' ')[1]; |
| sessions.delete(token); |
| res.json({ ok: true }); |
| }); |
|
|
| |
| function requireAuth(req, res, next) { |
| const token = req.headers.authorization?.split(' ')[1]; |
| if (!token || !sessions.has(token)) { |
| return res.status(401).json({ error: 'Unauthorized' }); |
| } |
| next(); |
| } |
|
|
| |
| let shellularProc = null; |
| let outputBuffer = ''; |
| const sseClients = new Set(); |
|
|
| function send(res, payload) { |
| res.write(`data: ${JSON.stringify(payload)}\n\n`); |
| } |
|
|
| function broadcast(payload) { |
| const frame = `data: ${JSON.stringify(payload)}\n\n`; |
| for (const client of sseClients) { |
| client.write(frame); |
| } |
| } |
|
|
| let retryTimer = null; |
|
|
| function startShellular() { |
| if (shellularProc || retryTimer) return; |
|
|
| broadcast({ type: 'status', status: 'starting' }); |
|
|
| shellularProc = spawn('shellular', ['--unknown-clients', 'always-allow'], { |
| env: { |
| ...process.env, |
| FORCE_COLOR: '0', |
| |
| |
| |
| NODE_OPTIONS: `--import file://${PATCH_FILE}`, |
| }, |
| stdio: ['ignore', 'pipe', 'pipe'], |
| }); |
|
|
| let procOutput = ''; |
|
|
| const handleData = (chunk) => { |
| const text = stripAnsi(chunk.toString()); |
| procOutput += text; |
| outputBuffer += text; |
| broadcast({ type: 'output', text }); |
| }; |
|
|
| shellularProc.stdout.on('data', handleData); |
| shellularProc.stderr.on('data', handleData); |
|
|
| shellularProc.on('error', (err) => { |
| |
| |
| |
| |
| |
| const text = [ |
| '', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| 'β DIAGNOSTIC: spawn-error β', |
| 'β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£', |
| `β Cannot start the shellular binary: ${err.message}`, |
| 'β Likely cause: shellular not installed or PATH wrong. β', |
| 'β Fix: rebuild the Space (Dockerfile installs shellular). β', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| '', |
| ].join('\n'); |
| outputBuffer += text; |
| broadcast({ type: 'output', text }); |
| shellularProc = null; |
| broadcast({ type: 'status', status: 'error' }); |
| }); |
|
|
| shellularProc.on('exit', (code, signal) => { |
| shellularProc = null; |
|
|
| |
| const isSchemaError = code === 1 && !signal && |
| procOutput.includes('invalid_union'); |
|
|
| const isPatchBypassed = code === 1 && !signal && |
| procOutput.includes('invalid_union') && |
| !procOutput.includes('[patch] coerced success field'); |
| |
| |
|
|
| const isRateLimit = code === 1 && !signal && !isSchemaError && |
| (procOutput.includes('Too many requests') || procOutput.includes('rate')); |
|
|
| const isAuthError = code === 1 && !signal && |
| (procOutput.includes('401') || procOutput.includes('Unauthorized') || |
| procOutput.includes('authentication')); |
|
|
| const isNetworkError = code === 1 && !signal && |
| (procOutput.includes('ECONNREFUSED') || procOutput.includes('ENOTFOUND') || |
| procOutput.includes('ETIMEDOUT') || procOutput.includes('fetch failed')); |
|
|
| if (isSchemaError) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const patchRan = procOutput.includes('[patch] coerced success field'); |
| const msg = [ |
| '', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| 'β DIAGNOSTIC: schema-mismatch (invalid_union) β', |
| 'β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£', |
| `β Undici patch loaded: ${patchRan ? 'YES β
' : 'NO β β patch never ran!'}`, |
| 'β', |
| patchRan |
| ? 'β The API changed a DIFFERENT field than "success".' |
| : 'β NODE_OPTIONS may have been stripped. Check spawn env.', |
| 'β', |
| 'β What to do:', |
| 'β 1. Copy the full output above and share it.', |
| 'β 2. Look for the "path" field in the [error] JSON β', |
| 'β it tells you exactly which field Zod rejected.', |
| 'β 3. Rebuild the Space to pull shellular@latest.', |
| 'β 4. Workaround: set SHELLULAR_HOST_ID + SHELLULAR_KEY +', |
| 'β SHELLULAR_MACHINE_ID as HF Secrets (skips registration).', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| ' Retrying in 5 minutesβ¦', |
| '', |
| ].join('\n'); |
| outputBuffer += msg; |
| broadcast({ type: 'output', text: msg }); |
| broadcast({ type: 'status', status: 'retrying' }); |
|
|
| retryTimer = setTimeout(() => { |
| retryTimer = null; |
| const m = '\n[Retrying after schema errorβ¦]\n'; |
| outputBuffer += m; |
| broadcast({ type: 'output', text: m }); |
| startShellular(); |
| }, 5 * 60 * 1000); |
|
|
| } else if (isRateLimit) { |
| |
| |
| |
| |
| const WAIT = 30; |
| const msg = [ |
| '', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| 'β DIAGNOSTIC: rate-limited β', |
| 'β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£', |
| 'β api.shellular.dev refused registration (too many calls). β', |
| 'β Fix: save SHELLULAR_HOST_ID + SHELLULAR_KEY + β', |
| 'β SHELLULAR_MACHINE_ID as HF Secrets. β', |
| `β Retrying in ${WAIT}sβ¦ β`, |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| '', |
| ].join('\n'); |
| outputBuffer += msg; |
| broadcast({ type: 'output', text: msg }); |
| broadcast({ type: 'status', status: 'retrying' }); |
|
|
| retryTimer = setTimeout(() => { |
| retryTimer = null; |
| const m = '\n[Retrying registrationβ¦]\n'; |
| outputBuffer += m; |
| broadcast({ type: 'output', text: m }); |
| startShellular(); |
| }, WAIT * 1000); |
|
|
| } else if (isNetworkError) { |
| |
| |
| |
| const msg = [ |
| '', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| 'β DIAGNOSTIC: network-error β', |
| 'β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£', |
| 'β shellular could not reach api.shellular.dev. β', |
| 'β Possible causes: β', |
| 'β - Outbound network blocked in this HF Space tier. β', |
| 'β - api.shellular.dev is down. β', |
| 'β - DNS resolution failing inside the container. β', |
| 'β Retrying in 30sβ¦ β', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| '', |
| ].join('\n'); |
| outputBuffer += msg; |
| broadcast({ type: 'output', text: msg }); |
| broadcast({ type: 'status', status: 'retrying' }); |
|
|
| retryTimer = setTimeout(() => { |
| retryTimer = null; |
| startShellular(); |
| }, 30 * 1000); |
|
|
| } else if (isAuthError) { |
| |
| |
| |
| |
| |
| const msg = [ |
| '', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| 'β DIAGNOSTIC: auth-error β', |
| 'β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£', |
| 'β Saved SHELLULAR_* secrets were rejected by the API. β', |
| 'β Fix: delete SHELLULAR_HOST_ID + SHELLULAR_KEY + β', |
| 'β SHELLULAR_MACHINE_ID from HF Secrets, then restart β', |
| 'β to re-register and get fresh values. β', |
| 'ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ', |
| '', |
| ].join('\n'); |
| outputBuffer += msg; |
| broadcast({ type: 'output', text: msg }); |
| broadcast({ type: 'status', status: 'error' }); |
|
|
| } else { |
| |
| |
| |
| |
| const text = code !== 0 |
| ? `\n[shellular exited β code=${code ?? '?'}, signal=${signal ?? 'none'}]\n` |
| : '\n[shellular disconnected]\n'; |
| outputBuffer += text; |
| broadcast({ type: 'output', text }); |
| broadcast({ type: 'status', status: 'stopped' }); |
| } |
| }); |
|
|
| broadcast({ type: 'status', status: 'running' }); |
| } |
|
|
| function stopShellular() { |
| if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } |
| if (!shellularProc) return; |
| shellularProc.kill('SIGTERM'); |
| shellularProc = null; |
| } |
|
|
| |
| app.get('/api/stream', requireAuth, (req, res) => { |
| res.setHeader('Content-Type', 'text/event-stream'); |
| res.setHeader('Cache-Control', 'no-cache'); |
| res.setHeader('Connection', 'keep-alive'); |
| res.setHeader('X-Accel-Buffering', 'no'); |
| res.flushHeaders(); |
|
|
| send(res, { type: 'status', status: shellularProc ? 'running' : 'stopped' }); |
|
|
| if (outputBuffer) { |
| send(res, { type: 'output', text: outputBuffer }); |
| } |
|
|
| sseClients.add(res); |
| req.on('close', () => sseClients.delete(res)); |
| }); |
|
|
| |
| app.post('/api/shellular/start', requireAuth, (_req, res) => { |
| startShellular(); |
| res.json({ ok: true, running: !!shellularProc }); |
| }); |
|
|
| app.post('/api/shellular/stop', requireAuth, (_req, res) => { |
| stopShellular(); |
| outputBuffer = ''; |
| broadcast({ type: 'output', text: '' }); |
| res.json({ ok: true }); |
| }); |
|
|
| app.post('/api/shellular/restart', requireAuth, (_req, res) => { |
| stopShellular(); |
| outputBuffer = ''; |
| broadcast({ type: 'clear' }); |
| setTimeout(startShellular, 600); |
| res.json({ ok: true }); |
| }); |
|
|
| app.get('/api/status', requireAuth, (_req, res) => { |
| res.json({ running: !!shellularProc }); |
| }); |
|
|
| app.get('/api/setup-status', requireAuth, (_req, res) => { |
| const seeded = !!( |
| process.env.SHELLULAR_HOST_ID && |
| process.env.SHELLULAR_KEY && |
| process.env.SHELLULAR_MACHINE_ID |
| ); |
| res.json({ seeded }); |
| }); |
|
|
| app.get('/api/shellular/credentials', requireAuth, (_req, res) => { |
| try { |
| const shellularDir = path.join(os.homedir(), '.shellular'); |
| const configRaw = fs.readFileSync(path.join(shellularDir, 'config.json'), 'utf-8'); |
| const { hostId, machineId } = JSON.parse(configRaw); |
| const keyFile = path.join(shellularDir, `shellular-${machineId}.e2ee`); |
| const keyB64 = fs.readFileSync(keyFile).toString('base64'); |
| res.json({ hostId, machineId, keyB64 }); |
| } catch { |
| res.status(404).json({ error: 'Not registered yet.' }); |
| } |
| }); |
|
|
| app.get('/api/shellular/qr-data', requireAuth, (_req, res) => { |
| try { |
| const shellularDir = path.join(os.homedir(), '.shellular'); |
| const configRaw = fs.readFileSync(path.join(shellularDir, 'config.json'), 'utf-8'); |
| const { hostId, machineId } = JSON.parse(configRaw); |
| const keyFile = path.join(shellularDir, `shellular-${machineId}.e2ee`); |
| const keyB64 = fs.readFileSync(keyFile).toString('base64'); |
| res.json({ qrData: `${hostId}:${keyB64}` }); |
| } catch { |
| res.status(404).json({ error: 'Config not seeded yet.' }); |
| } |
| }); |
|
|
| |
| app.listen(PORT, '0.0.0.0', () => { |
| console.log(`Shellular Web UI β http://0.0.0.0:${PORT}`); |
| }); |