/** * mcp/system.ts — System operations via device-auth flow * * Destructive ops (restart) require a systemToken from system_request_access → user approves in /dev. * Read-only ops (logs, status) require no auth. */ import { createRequest, checkRequest, validateSystemToken } from '../system-access.ts'; import { getActiveMcpKey } from "./events.ts"; function requireToken(args: any): string { if (!args?.systemToken) throw new Error('systemToken required — call system_request_access first'); const entry = validateSystemToken(args.systemToken); if (!entry) throw new Error('Invalid or expired system token'); return entry.user; } async function tmuxCapture(pane: string, lines: number): Promise { const proc = Bun.spawn( ['tmux', 'capture-pane', '-t', pane, '-p', '-S', `-${lines}`], { stdout: 'pipe', stderr: 'pipe' } ); const [out, err] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); await proc.exited; if (err.trim()) throw new Error(err.trim()); return out; } const text = (s: string) => ({ content: [{ type: 'text' as const, text: s }] }); export const tools = [ { name: 'system_request_access', description: 'Request system access for destructive operations (restart, deploy). Creates a pending approval shown in /dev. Returns requestId — tell user the userCode and poll system_check_access until approved.', inputSchema: { type: 'object' as const, properties: { description: { type: 'string', description: 'What you need access for (shown to user in /dev)' }, }, required: ['description'], }, }, { name: 'system_check_access', description: 'Poll for system access approval. Returns systemToken once user approves in /dev, or status: pending/expired/denied.', inputSchema: { type: 'object' as const, properties: { requestId: { type: 'string', description: 'requestId from system_request_access' }, }, required: ['requestId'], }, }, { name: 'system_logs', description: 'Read recent log output from a tmux pane. target: "bun-dev" (hermes dev backend), "vite" (webchat-vite). No auth required.', inputSchema: { type: 'object' as const, properties: { target: { type: 'string', enum: ['bun-dev', 'vite'], description: 'Which process to read logs from' }, lines: { type: 'number', description: 'Lines to return (default 50, max 200)' }, }, required: ['target'], }, }, { name: 'system_process_status', description: 'Check status of bun/vite processes on openclaw. Returns matching ps output. No auth required.', inputSchema: { type: 'object' as const, properties: {}, }, }, { name: 'system_restart_dev', description: 'Restart the dev bun process. Sends C-c to hermes-dev tmux session; bun --watch auto-restarts within ~1s. MCP will reconnect automatically. No auth required.', inputSchema: { type: 'object' as const, properties: {} }, }, { name: 'system_restart_prod', description: 'Restart the prod bun process via systemctl (openclaw-web-gateway, port 3001). Uses sudo NOPASSWD. Requires systemToken.', inputSchema: { type: 'object' as const, properties: { systemToken: { type: 'string', description: 'Token from system_check_access' }, }, required: ['systemToken'], }, }, { name: 'system_deploy_prod', description: 'Full prod deploy: bump version, build frontend, rsync to IONOS, restart prod, health check. Requires systemToken.', inputSchema: { type: 'object' as const, properties: { systemToken: { type: 'string', description: 'Token from system_check_access' }, version: { type: 'string', description: 'New version string (e.g. "0.6.0")' }, }, required: ['systemToken', 'version'], }, }, { name: 'system_restart_vite', description: 'Restart the vite dev server. Sends C-c then re-runs in webchat-vite tmux session. No auth required.', inputSchema: { type: 'object' as const, properties: {} }, }, { name: 'system_stop_dev', description: 'Stop the dev bun process (C-c to hermes-dev). Does not restart. No auth required.', inputSchema: { type: 'object' as const, properties: {} }, }, { name: 'system_stop_vite', description: 'Stop the vite dev server (C-c to webchat-vite). Does not restart. No auth required.', inputSchema: { type: 'object' as const, properties: {} }, }, { name: 'system_start_dev', description: 'Start the dev bun process in hermes-dev tmux session. No auth required.', inputSchema: { type: 'object' as const, properties: {} }, }, { name: 'system_start_vite', description: 'Start the vite dev server in webchat-vite tmux session. No auth required.', inputSchema: { type: 'object' as const, properties: {} }, }, ]; export async function handle(name: string, args: any): Promise { switch (name) { case 'system_request_access': { const req = createRequest(args.description ?? 'system access', getActiveMcpKey() ?? undefined); return text(JSON.stringify({ requestId: req.requestId, userCode: req.userCode, expiresIn: 300, instruction: `Tell the user to go to /dev — they will see a pending approval with code ${req.userCode}. Then poll system_check_access with the requestId.`, }, null, 2)); } case 'system_check_access': { return text(JSON.stringify(checkRequest(args.requestId), null, 2)); } case 'system_logs': { const pane = args.target === 'vite' ? 'webchat-vite' : 'hermes-dev'; const lines = Math.min(args.lines ?? 50, 200); try { const out = await tmuxCapture(pane, lines); return text(out || '(empty)'); } catch (e: any) { return text(`Error: ${e.message}`); } } case 'system_process_status': { try { const proc = Bun.spawn(['ps', 'aux'], { stdout: 'pipe', stderr: 'pipe' }); const out = await new Response(proc.stdout).text(); const relevant = out.split('\n') .filter(l => (l.includes('bun') || l.includes('vite')) && !l.includes('grep')); return text(relevant.join('\n') || 'No relevant processes found'); } catch (e: any) { return text(`Error: ${e.message}`); } } case 'system_restart_dev': { // Defer C-c by 200ms so the HTTP response is delivered first setTimeout(async () => { const proc = Bun.spawn( ['tmux', 'send-keys', '-t', 'hermes-dev', 'C-c', ''], { stdout: 'pipe', stderr: 'pipe' } ); await proc.exited; }, 200); return text(JSON.stringify({ ok: true, message: 'Sent C-c to hermes-dev — bun --watch will restart in ~1s' })); } case 'system_restart_prod': { requireToken(args); try { const proc = Bun.spawn( ['sudo', '-n', 'systemctl', 'restart', 'openclaw-web-gateway'], { stdout: 'pipe', stderr: 'pipe' } ); const [, err] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); await proc.exited; if (proc.exitCode !== 0) return text(`Error: ${err.trim() || 'exit ' + proc.exitCode}`); return text(JSON.stringify({ ok: true, message: 'openclaw-web-gateway restarted' })); } catch (e: any) { return text(`Error: ${e.message}`); } } case 'system_deploy_prod': { requireToken(args); if (!args.version?.match(/^\d+\.\d+\.\d+$/)) { return text('Error: version must be semver (e.g. "0.6.0")'); } const BASE = '/home/openclaw/.openclaw/workspace-titan/projects/hermes'; const steps: string[] = []; try { // 1. Read old version const oldPkg = JSON.parse(await Bun.file(`${BASE}/backend/package.json`).text()); const oldVersion = oldPkg.version; steps.push(`Version: ${oldVersion} -> ${args.version}`); // 2. Bump backend package.json oldPkg.version = args.version; await Bun.write(`${BASE}/backend/package.json`, JSON.stringify(oldPkg, null, 2) + '\n'); // 3. Bump frontend package.json const fePkg = JSON.parse(await Bun.file(`${BASE}/frontend/package.json`).text()); fePkg.version = args.version; await Bun.write(`${BASE}/frontend/package.json`, JSON.stringify(fePkg, null, 2) + '\n'); steps.push('Version bumped in both package.json files'); // 4. Build frontend const build = Bun.spawn(['npm', 'run', 'build'], { cwd: `${BASE}/frontend`, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, PATH: process.env.PATH }, }); const buildErr = await new Response(build.stderr).text(); await build.exited; if (build.exitCode !== 0) return text(`Build failed: ${buildErr.trim()}`); steps.push('Frontend built'); // 5. Rsync frontend to IONOS const rsync = Bun.spawn([ 'rsync', '-avz', '--delete', `${BASE}/frontend/dist/`, 'u116526981@access1007204406.webspace-data.io:~/jqxp/', ], { stdout: 'pipe', stderr: 'pipe' }); const rsyncErr = await new Response(rsync.stderr).text(); await rsync.exited; if (rsync.exitCode !== 0) return text(`Rsync failed: ${rsyncErr.trim()}`); steps.push('Frontend deployed to IONOS'); // 6. Restart prod const restart = Bun.spawn( ['sudo', '-n', 'systemctl', 'restart', 'openclaw-web-gateway'], { stdout: 'pipe', stderr: 'pipe' }, ); const restartErr = await new Response(restart.stderr).text(); await restart.exited; if (restart.exitCode !== 0) return text(`Restart failed: ${restartErr.trim()}`); steps.push('Prod restarted'); // 7. Wait + health check await new Promise(r => setTimeout(r, 3000)); const health = Bun.spawn( ['curl', '-s', '--max-time', '5', 'http://localhost:3001/health'], { stdout: 'pipe', stderr: 'pipe' }, ); const healthOut = await new Response(health.stdout).text(); await health.exited; steps.push(`Health: ${healthOut.trim()}`); return text(JSON.stringify({ ok: true, steps }, null, 2)); } catch (e: any) { return text(`Deploy error at step "${steps[steps.length - 1] || 'init'}": ${e.message}`); } } case 'system_restart_vite': { setTimeout(async () => { // Send C-c then restart const stop = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', 'C-c', ''], { stdout: 'pipe', stderr: 'pipe' }); await stop.exited; await new Promise(r => setTimeout(r, 500)); const start = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', 'npx vite --host --port 8444 Enter', ''], { stdout: 'pipe', stderr: 'pipe' }); await start.exited; }, 200); return text(JSON.stringify({ ok: true, message: 'Restarting vite in webchat-vite tmux session' })); } case 'system_stop_dev': { setTimeout(async () => { const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'hermes-dev', 'C-c', ''], { stdout: 'pipe', stderr: 'pipe' }); await proc.exited; }, 200); return text(JSON.stringify({ ok: true, message: 'Sent C-c to hermes-dev (stopped, no restart)' })); } case 'system_stop_vite': { setTimeout(async () => { const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', 'C-c', ''], { stdout: 'pipe', stderr: 'pipe' }); await proc.exited; }, 200); return text(JSON.stringify({ ok: true, message: 'Sent C-c to webchat-vite (stopped, no restart)' })); } case 'system_start_dev': { const cmd = 'cd ~/.openclaw/workspace-titan/projects/hermes/backend && ~/.bun/bin/bun --watch server.ts'; setTimeout(async () => { const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'hermes-dev', cmd, 'Enter'], { stdout: 'pipe', stderr: 'pipe' }); await proc.exited; }, 200); return text(JSON.stringify({ ok: true, message: 'Starting bun --watch in hermes-dev' })); } case 'system_start_vite': { const cmd = 'cd ~/.openclaw/workspace-titan/projects/hermes/frontend && npx vite --host --port 8444'; setTimeout(async () => { const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', cmd, 'Enter'], { stdout: 'pipe', stderr: 'pipe' }); await proc.exited; }, 200); return text(JSON.stringify({ ok: true, message: 'Starting vite in webchat-vite' })); } default: throw new Error(`Unknown system tool: ${name}`); } }