godcrm/backend/routes/v3/terminal.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

301 lines
8.9 KiB
JavaScript

/**
* API v3: Terminal Routes (ADR-076)
* Owner-only endpoints for terminal sessions with command approval system.
*
* Flow:
* User/Agent → POST /terminal/sessions/:id/execute
* → CommandClassifier (safe/medium/dangerous)
* → safe: execute immediately
* → medium: execute + log
* → dangerous: return needsApproval → frontend shows dialog
* → POST /terminal/commands/:id/approve → execute
* → POST /terminal/commands/:id/reject → skip
*
* @module routes/v3/terminal
*/
import express from 'express';
import { logger } from '../../utils/logger.js';
import {
success,
created,
forbidden,
badRequest,
notFound,
serverError
} from '../../utils/response.js';
import {
createSession,
getOrCreateSession,
listSessions,
getSession,
closeSession,
executeCommand,
approveCommand,
rejectCommand,
getCommands,
listPendingCommands,
} from '../../services/TerminalService.js';
const router = express.Router();
// ============================================================
// Middleware: Owner-only access
// ============================================================
const ownerOnly = (req, res, next) => {
if (req.user?.role !== 'owner') {
logger.warn({
userId: req.user?.id,
role: req.user?.role,
path: req.path
}, 'Terminal access denied: not owner');
return forbidden(res, 'Terminal access is owner-only');
}
next();
};
router.use(ownerOnly);
// ============================================================
// Session CRUD
// ============================================================
/**
* GET /api/v3/terminal/sessions
* List all sessions for the current user
*/
router.get('/sessions', async (req, res) => {
try {
// Owner can see all sessions (own + agent/other-user) for monitoring
const isOwner = req.user.role === 'owner';
const sessions = await listSessions(req.user.id, { showAll: isOwner, includeAgent: isOwner });
return success(res, sessions);
} catch (err) {
logger.error({ error: err.message }, 'Failed to list terminal sessions');
return serverError(res, err.message);
}
});
/**
* POST /api/v3/terminal/sessions
* Create a new terminal session
* @body {string} [title] - Session title
* @body {string} [cwd] - Working directory
*/
router.post('/sessions', async (req, res) => {
try {
const { title, cwd } = req.body;
const session = await createSession(req.user.id, title, cwd);
return created(res, session);
} catch (err) {
logger.error({ error: err.message }, 'Failed to create terminal session');
return serverError(res, err.message);
}
});
/**
* GET /api/v3/terminal/sessions/:id
* Get session with recent commands
*/
router.get('/sessions/:id', async (req, res) => {
try {
const session = await getSession(parseInt(req.params.id));
if (!session) {
return notFound(res, 'Session');
}
// Bug #74147: Verify session ownership so users can't access other users' sessions
// Owner can access any session (for monitoring agent/user terminals)
if (session.user_id !== req.user.id && req.user.role !== 'owner') {
return forbidden(res, 'Session belongs to another user');
}
return success(res, session);
} catch (err) {
logger.error({ error: err.message, sessionId: req.params.id }, 'Failed to get session');
return serverError(res, err.message);
}
});
/**
* DELETE /api/v3/terminal/sessions/:id
* Close a session
*/
router.delete('/sessions/:id', async (req, res) => {
try {
await closeSession(parseInt(req.params.id));
return success(res, { closed: true });
} catch (err) {
logger.error({ error: err.message, sessionId: req.params.id }, 'Failed to close session');
return serverError(res, err.message);
}
});
// ============================================================
// Command Execution
// ============================================================
/**
* POST /api/v3/terminal/sessions/:id/execute
* Execute a command in the session (or request approval if dangerous)
*
* @body {string} command - Shell command to run
* @body {string} [source=user] - "user" or "agent"
* @body {string} [agentName] - Agent name if source=agent
*/
router.post('/sessions/:id/execute', async (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const { command, source, agentName } = req.body;
if (!command || typeof command !== 'string') {
return badRequest(res, 'command is required');
}
const result = await executeCommand(sessionId, command.trim(), {
source: source || 'user',
agentName: agentName || null,
});
if (result.needsApproval) {
return success(res, {
needsApproval: true,
commandId: result.command.id,
command: result.command.command,
riskLevel: result.command.risk_level,
message: 'Command requires approval before execution',
});
}
return success(res, {
needsApproval: false,
command: result.command,
});
} catch (err) {
logger.error({ error: err.message, sessionId: req.params.id }, 'Failed to execute command');
return serverError(res, err.message);
}
});
/**
* GET /api/v3/terminal/sessions/:id/commands
* Poll for commands (supports ?after=cmdId for incremental fetch)
*/
router.get('/sessions/:id/commands', async (req, res) => {
try {
const sessionId = parseInt(req.params.id);
const afterId = parseInt(req.query.after) || 0;
const limit = parseInt(req.query.limit) || 50;
const commands = await getCommands(sessionId, { limit, afterId });
return success(res, commands);
} catch (err) {
logger.error({ error: err.message, sessionId: req.params.id }, 'Failed to get commands');
return serverError(res, err.message);
}
});
// ============================================================
// Command Approval
// ============================================================
/**
* GET /api/v3/terminal/commands/pending
* List pending commands across the user's active sessions (owner monitors all).
* Powers the inline approval bar above the AI chat input.
*/
router.get('/commands/pending', async (req, res) => {
try {
const isOwner = req.user?.role === 'owner';
const pending = await listPendingCommands({
userId: req.user.id,
allUsers: isOwner,
});
return success(res, pending);
} catch (err) {
logger.error({ error: err.message }, 'Failed to list pending commands');
return serverError(res, err.message);
}
});
/**
* POST /api/v3/terminal/commands/:id/approve
* Approve and execute a dangerous command
*/
router.post('/commands/:id/approve', async (req, res) => {
try {
const commandId = parseInt(req.params.id);
const result = await approveCommand(commandId, req.user.id);
return success(res, result);
} catch (err) {
logger.error({ error: err.message, commandId: req.params.id }, 'Failed to approve command');
if (err.message.includes('not found') || err.message.includes('not pending')) {
return badRequest(res, err.message);
}
return serverError(res, err.message);
}
});
/**
* POST /api/v3/terminal/commands/:id/reject
* Reject a dangerous command
*/
router.post('/commands/:id/reject', async (req, res) => {
try {
const commandId = parseInt(req.params.id);
const result = await rejectCommand(commandId, req.user.id);
return success(res, result);
} catch (err) {
logger.error({ error: err.message, commandId: req.params.id }, 'Failed to reject command');
if (err.message.includes('not found') || err.message.includes('not pending')) {
return badRequest(res, err.message);
}
return serverError(res, err.message);
}
});
// ============================================================
// Quick session (auto-create + execute)
// ============================================================
/**
* POST /api/v3/terminal/execute
* Quick execute: auto-creates session if needed, then runs command
* Convenience endpoint for simple use cases
*/
router.post('/execute', async (req, res) => {
try {
const { command, source, agentName } = req.body;
if (!command || typeof command !== 'string') {
return badRequest(res, 'command is required');
}
const session = await getOrCreateSession(req.user.id);
const result = await executeCommand(session.id, command.trim(), {
source: source || 'user',
agentName: agentName || null,
});
if (result.needsApproval) {
return success(res, {
needsApproval: true,
sessionId: session.id,
commandId: result.command.id,
command: result.command.command,
riskLevel: result.command.risk_level,
message: 'Command requires approval before execution',
});
}
return success(res, {
needsApproval: false,
sessionId: session.id,
command: result.command,
});
} catch (err) {
logger.error({ error: err.message }, 'Failed to quick-execute');
return serverError(res, err.message);
}
});
export default router;