hermes/backend/mcp/system.ts
Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

324 lines
14 KiB
TypeScript

/**
* 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<string> {
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<any> {
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}`);
}
}