324 lines
14 KiB
TypeScript
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}`);
|
|
}
|
|
}
|