import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { SshService } from '../sisters/ssh.service'; import { ActivityService } from '../activity/activity.service'; import { SisterName } from '../common/sister-name.pipe'; const ALLOWED_HARNESS_FILES = ['AGENTS.md', 'SOUL.md', 'PROTOCOL.md', 'TOOLS.md', 'HEARTBEAT.md'] as const; type HarnessFile = (typeof ALLOWED_HARNESS_FILES)[number]; @Injectable() export class AdminService { private readonly logger = new Logger(AdminService.name); constructor( private readonly prisma: PrismaService, private readonly ssh: SshService, private readonly activity: ActivityService, private readonly config: ConfigService, ) {} async restartSister(name: SisterName) { const sister = await this.getSister(name); const keyPath = this.getKeyPath(); try { const result = await this.ssh.executeCommand( sister.ip, sister.user, keyPath, 'openclaw gateway restart && echo "RESTART_OK"', ); const success = result.stdout.includes('RESTART_OK'); await this.activity.log({ sisterId: sister.id, action: 'gateway_restart', detail: success ? 'Gateway restart successful' : `stderr: ${result.stderr}`, }); return { success, output: result.stdout, error: result.stderr || null }; } catch (e) { this.logger.warn(`Restart failed for ${name}`); await this.activity.log({ sisterId: sister.id, action: 'gateway_restart', detail: `failed: ${(e as Error).message}`, }); return { success: false, output: '', error: (e as Error).message }; } } async resetSisterSession(name: SisterName) { const sister = await this.getSister(name); const keyPath = this.getKeyPath(); try { const result = await this.ssh.executeCommand( sister.ip, sister.user, keyPath, 'rm -f ~/.openclaw/sessions/main.json && echo "RESET_OK"', ); const success = result.stdout.includes('RESET_OK'); await this.activity.log({ sisterId: sister.id, action: 'session_reset', detail: success ? 'Session reset successful' : `stderr: ${result.stderr}`, }); return { success, output: result.stdout, error: result.stderr || null }; } catch (e) { this.logger.warn(`Reset failed for ${name}`); return { success: false, output: '', error: (e as Error).message }; } } async getHarnessFile(name: SisterName, file: string) { this.validateHarnessFile(file); const sister = await this.getSister(name); const keyPath = this.getKeyPath(); try { const result = await this.ssh.executeCommand( sister.ip, sister.user, keyPath, `cat ~/.openclaw/workspace/${file} 2>/dev/null || echo ""`, ); return { name, file, content: result.stdout }; } catch { return { name, file, content: '' }; } } async updateHarnessFile(name: SisterName, file: string, content: string) { this.validateHarnessFile(file); const sister = await this.getSister(name); const keyPath = this.getKeyPath(); // 내용에서 위험한 셸 escape + null byte 방지 const escaped = content.replace(/\x00/g, '').replace(/'/g, "'\\''"); try { const result = await this.ssh.executeCommand( sister.ip, sister.user, keyPath, [ `cd ~/.openclaw/workspace`, `printf '%s' '${escaped}' > ${file}`, `git add ${file} && git commit -m "admin: update ${file}" --allow-empty 2>&1`, `echo "WRITE_OK"`, ].join(' && '), ); const success = result.stdout.includes('WRITE_OK'); await this.activity.log({ sisterId: sister.id, action: 'harness_updated', detail: `${file} updated${success ? '' : ' (with errors)'}`, }); return { success, output: result.stdout, error: result.stderr || null }; } catch (e) { this.logger.warn(`Harness write failed for ${name}/${file}`); return { success: false, output: '', error: (e as Error).message }; } } async getSisterLogs(name: SisterName, lines = 100) { const sister = await this.getSister(name); const keyPath = this.getKeyPath(); try { const result = await this.ssh.executeCommand( sister.ip, sister.user, keyPath, `journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`, ); return { name, lines: result.stdout.split('\n').filter((l) => l), total: result.stdout.split('\n').filter((l) => l).length, }; } catch { return { name, lines: ['(SSH 연결 불가)'], total: 1 }; } } private validateHarnessFile(file: string) { if (!ALLOWED_HARNESS_FILES.includes(file as HarnessFile)) { throw new BadRequestException( `Invalid file. Allowed: ${ALLOWED_HARNESS_FILES.join(', ')}`, ); } } private async getSister(name: SisterName) { const sister = await this.prisma.sisterConfig.findUnique({ where: { name } }); if (!sister) throw new Error(`Sister ${name} not found`); return sister; } private getKeyPath(): string { const p = this.config.get('SSH_KEY_PATH'); if (!p) throw new Error('SSH_KEY_PATH is not set'); return p; } }