Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
301 lines
8.9 KiB
JavaScript
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;
|