feat(actions): /api/actions routes (run/pending/approve/reject)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { router as hostRouter } from './routes/host.js';
|
||||
import { router as speedtestRouter } from './routes/speedtest.js';
|
||||
import { router as healthRouter } from './routes/health.js';
|
||||
import { router as securityRouter } from './routes/security.js';
|
||||
import { router as actionsRouter } from './routes/actions.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -41,6 +42,7 @@ export function mountApi(app) {
|
||||
api.use('/spaces/:space_id/resources', resourcesBySpaceRouter);
|
||||
api.use('/spaces/:space_id/companion', companionRouter);
|
||||
api.use('/security', securityRouter);
|
||||
api.use('/actions', actionsRouter);
|
||||
api.use('/projects', projectsRouter);
|
||||
api.use('/projects/:project_id/tasks', tasksByProjectRouter);
|
||||
api.use('/tasks', tasksRouter);
|
||||
|
||||
47
lib/api/routes/actions.js
Normal file
47
lib/api/routes/actions.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Router } from 'express';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { makeActionService } from '../../actions/service.js';
|
||||
import { loadActions } from '../../actions/registry.js';
|
||||
import * as aa from '../../db/repos/agent_actions.js';
|
||||
|
||||
// Owner OR an agent with capabilities.act (Little Blue) may run/list. Approve/reject
|
||||
// are owner-only. The service enforces tier-gating regardless of caller.
|
||||
function svc() {
|
||||
return makeActionService({ registry: loadActions(process.env.ACTIONS_CONFIG || undefined) });
|
||||
}
|
||||
function canAct(req) {
|
||||
const a = req.actor;
|
||||
return a?.kind === 'user' || (a?.kind === 'agent' && a.capabilities?.act);
|
||||
}
|
||||
function ownerOnly(req, res, next) {
|
||||
if (req.actor?.kind === 'user') return next();
|
||||
return res.status(403).json({ error: { code: 'owner_only', message: 'owner approval required' } });
|
||||
}
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/', asyncWrap(async (req, res) => {
|
||||
if (!canAct(req)) return res.status(403).json({ error: { code: 'forbidden' } });
|
||||
res.json({ actions: svc().list() });
|
||||
}));
|
||||
|
||||
router.post('/:id/run', asyncWrap(async (req, res) => {
|
||||
if (!canAct(req)) return res.status(403).json({ error: { code: 'forbidden' } });
|
||||
const agent_id = req.actor?.kind === 'agent' ? req.actor.id : null;
|
||||
res.json(await svc().run(req.params.id, req.actor, agent_id));
|
||||
}));
|
||||
|
||||
router.get('/pending', ownerOnly, asyncWrap(async (_req, res) => {
|
||||
res.json({ pending: await aa.listPending() });
|
||||
}));
|
||||
|
||||
router.post('/pending/:rowId/approve', ownerOnly, asyncWrap(async (req, res) => {
|
||||
res.json(await svc().approve(req.params.rowId, req.actor));
|
||||
}));
|
||||
router.post('/pending/:rowId/reject', ownerOnly, asyncWrap(async (req, res) => {
|
||||
res.json(await svc().reject(req.params.rowId, req.actor));
|
||||
}));
|
||||
|
||||
router.get('/recent', ownerOnly, asyncWrap(async (_req, res) => {
|
||||
res.json({ recent: await aa.recent() });
|
||||
}));
|
||||
35
tests/api/actions.test.js
Normal file
35
tests/api/actions.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import { createApp } from '../../server.js';
|
||||
|
||||
let app;
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
process.env.ACTIONS_CONFIG = new URL('../fixtures/actions.test.json', import.meta.url).pathname;
|
||||
app = createApp();
|
||||
});
|
||||
const auth = (r) => r.set('Authorization', 'Bearer test-token');
|
||||
|
||||
describe('actions API', () => {
|
||||
it('GET / lists the whitelist (owner)', async () => {
|
||||
const res = await auth(request(app).get('/api/actions'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.actions.map(a => a.id)).toContain('stop-ct107');
|
||||
});
|
||||
it('no auth is rejected', async () => {
|
||||
const res = await request(app).get('/api/actions');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
it('risky run queues; appears in /pending; reject resolves it (owner)', async () => {
|
||||
const run = await auth(request(app).post('/api/actions/stop-ct107/run'));
|
||||
expect(run.status).toBe(200);
|
||||
expect(run.body.queued).toBe(true);
|
||||
const pend = await auth(request(app).get('/api/actions/pending'));
|
||||
expect(pend.body.pending.some(p => p.id === run.body.action_row_id)).toBe(true);
|
||||
const rej = await auth(request(app).post(`/api/actions/pending/${run.body.action_row_id}/reject`));
|
||||
expect(rej.body.status).toBe('rejected');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user