docs: Little Blue implementation plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 21:38:28 +10:00
parent e58090e607
commit d500b6fa00

View File

@@ -0,0 +1,751 @@
# Little Blue Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give Little Blue a least-privilege, tiered-approval, audited action framework to restart lab services (SSH forced-command) and power-manage Proxmox guests (scoped API token), plus a conversational + manual UI.
**Architecture:** A version-controlled whitelist (`config/actions.json`) drives two server-side-enforced channels. An action service gates by tier (safe→run, risky→queue→approve) and audits everything. Infra creds live ONLY in the main server; Little Blue's MCP child proposes actions via the local API with a scoped little-blue token. Frontend reuses `agent_chat` + an actions panel.
**Tech Stack:** Node 22 ESM, Express 5, Postgres, vanilla-JS SPA, vitest + supertest (serial).
**Spec:** `docs/superpowers/specs/2026-06-04-little-blue-design.md`
---
### Task 1: `agent_actions` table + repo
**Files:** Create `lib/db/migrations/016_agent_actions.sql`, `lib/db/repos/agent_actions.js`; Test `tests/db/agent_actions.test.js`
- [ ] **Step 1: Migration**
```sql
-- 016_agent_actions.sql — queue + audit trail for Little Blue's infra actions.
CREATE TABLE agent_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
action_id text NOT NULL, -- whitelist id from config/actions.json
params jsonb NOT NULL DEFAULT '{}'::jsonb,
agent_id uuid REFERENCES agents(id),
tier text NOT NULL CHECK (tier IN ('safe','risky')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','executed','failed','rejected')),
result jsonb,
requested_by jsonb,
resolved_by jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz
);
CREATE INDEX idx_agent_actions_pending ON agent_actions(status) WHERE status='pending';
```
- [ ] **Step 2: Failing test** `tests/db/agent_actions.test.js`
```js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as aa from '../../lib/db/repos/agent_actions.js';
const owner = { kind: 'user', id: null };
beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('agent_actions repo', () => {
it('creates pending, lists it, resolves once', async () => {
const row = await aa.create({ action_id: 'stop-ct107', tier: 'risky', params: {}, requested_by: owner });
expect(row.status).toBe('pending');
expect((await aa.listPending()).some(r => r.id === row.id)).toBe(true);
const done = await aa.resolve(row.id, 'executed', { ok: true }, owner);
expect(done.status).toBe('executed');
const again = await aa.resolve(row.id, 'rejected', null, owner); // already resolved
expect(again).toBeUndefined();
});
});
```
- [ ] **Step 3: Run → FAIL.**
- [ ] **Step 4: Implement** `lib/db/repos/agent_actions.js`
```js
import { pool } from '../pool.js';
import { recordAudit } from './audit.js';
export async function create({ action_id, tier, params, agent_id, requested_by }) {
const { rows: [r] } = await pool.query(
`INSERT INTO agent_actions(action_id, tier, params, agent_id, requested_by)
VALUES($1,$2,$3,$4,$5) RETURNING *`,
[action_id, tier, params || {}, agent_id || null, requested_by || null]
);
await recordAudit(requested_by, 'create', 'agent_action', r.id, null, r);
return r;
}
export async function listPending({ limit = 100 } = {}) {
const { rows } = await pool.query(
`SELECT * FROM agent_actions WHERE status='pending' ORDER BY created_at LIMIT $1`, [limit]);
return rows;
}
export async function getById(id) {
const { rows: [r] } = await pool.query(`SELECT * FROM agent_actions WHERE id=$1`, [id]);
return r;
}
export async function resolve(id, status, result, resolved_by) {
const { rows: [r] } = await pool.query(
`UPDATE agent_actions SET status=$1, result=$2, resolved_by=$3, resolved_at=now()
WHERE id=$4 AND status='pending' RETURNING *`,
[status, result || null, resolved_by || null, id]);
if (r) await recordAudit(resolved_by, 'update', 'agent_action', id, null, r);
return r;
}
export async function recent({ limit = 50 } = {}) {
const { rows } = await pool.query(
`SELECT * FROM agent_actions WHERE status<>'pending' ORDER BY resolved_at DESC NULLS LAST LIMIT $1`, [limit]);
return rows;
}
```
- [ ] **Step 5: Run → PASS. Commit** `feat(actions): agent_actions table + repo`
---
### Task 2: Action registry (whitelist loader)
**Files:** Create `config/actions.json`, `lib/actions/registry.js`; Test `tests/actions/registry.test.js` + `tests/fixtures/actions.test.json`
- [ ] **Step 1: Ship an empty real whitelist** `config/actions.json` (populated at provisioning):
```json
{ "hosts": {}, "actions": [] }
```
- [ ] **Step 2: Test fixture** `tests/fixtures/actions.test.json`:
```json
{
"hosts": { "ct100": "192.168.1.230", "z": "192.168.1.124" },
"actions": [
{ "id": "restart-caddy-ct100", "label": "Restart Caddy", "kind": "service_restart", "host": "ct100", "service": "caddy", "tier": "safe" },
{ "id": "stop-ct107", "label": "Stop CT107", "kind": "guest_power", "node": "z", "vmid": 107, "op": "stop", "tier": "risky" }
]
}
```
- [ ] **Step 3: Failing test** `tests/actions/registry.test.js`
```js
import { describe, it, expect } from 'vitest';
import { fileURLToPath } from 'url';
import { loadActions } from '../../lib/actions/registry.js';
const FIX = fileURLToPath(new URL('../fixtures/actions.test.json', import.meta.url));
describe('action registry', () => {
it('loads + indexes valid actions and resolves host ip', () => {
const reg = loadActions(FIX);
expect(reg.list().map(a => a.id).sort()).toEqual(['restart-caddy-ct100', 'stop-ct107']);
expect(reg.get('restart-caddy-ct100').tier).toBe('safe');
expect(reg.hostIp('ct100')).toBe('192.168.1.230');
expect(reg.get('nope')).toBeUndefined();
});
it('rejects an action with a bad id or unknown kind/tier', () => {
expect(() => loadActions(null, { hosts: {}, actions: [{ id: 'bad id', kind: 'service_restart', tier: 'safe' }] }))
.toThrow(/invalid action id/i);
expect(() => loadActions(null, { hosts: {}, actions: [{ id: 'x', kind: 'nuke', tier: 'safe' }] }))
.toThrow(/unknown kind/i);
expect(() => loadActions(null, { hosts: {}, actions: [{ id: 'x', kind: 'guest_power', op: 'stop', tier: 'evil' }] }))
.toThrow(/invalid tier/i);
});
});
```
- [ ] **Step 4: Run → FAIL.**
- [ ] **Step 5: Implement** `lib/actions/registry.js`
```js
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DEFAULT = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../config/actions.json');
const ID_RE = /^[a-z0-9-]+$/;
const KINDS = new Set(['service_restart', 'guest_power']);
const OPS = new Set(['start', 'stop', 'shutdown', 'reboot']);
const TIERS = new Set(['safe', 'risky']);
export function loadActions(file = DEFAULT, raw) {
const cfg = raw || JSON.parse(readFileSync(file, 'utf8'));
const hosts = cfg.hosts || {};
const byId = new Map();
for (const a of (cfg.actions || [])) {
if (!ID_RE.test(a.id || '')) throw new Error(`invalid action id: ${a.id}`);
if (!KINDS.has(a.kind)) throw new Error(`unknown kind: ${a.kind}`);
if (!TIERS.has(a.tier)) throw new Error(`invalid tier: ${a.tier}`);
if (a.kind === 'guest_power' && !OPS.has(a.op)) throw new Error(`invalid op: ${a.op}`);
if (byId.has(a.id)) throw new Error(`duplicate action id: ${a.id}`);
byId.set(a.id, a);
}
return {
list: () => [...byId.values()],
get: (id) => byId.get(id),
hostIp: (h) => hosts[h]
};
}
```
- [ ] **Step 6: Run → PASS. Commit** `feat(actions): config-driven action whitelist registry`
---
### Task 3: Proxmox channel
**Files:** Create `lib/actions/channels/proxmox.js`; Test `tests/actions/proxmox.test.js`
- [ ] **Step 1: Failing test**
```js
import { describe, it, expect, vi } from 'vitest';
import { powerGuest } from '../../lib/actions/channels/proxmox.js';
describe('proxmox channel', () => {
it('POSTs the scoped power op with the token header', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: 'UPID:...' }) }));
const out = await powerGuest({ node: 'z', vmid: 107, op: 'stop', kindPath: 'lxc' },
{ apiUrl: 'https://pve:8006', token: 'user@pve!void=secret', fetchImpl: fetchMock });
expect(out.ok).toBe(true);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('https://pve:8006/api2/json/nodes/z/lxc/107/status/stop');
expect(opts.method).toBe('POST');
expect(opts.headers.Authorization).toBe('PVEAPIToken=user@pve!void=secret');
});
it('throws on a non-ok response', async () => {
const fetchMock = vi.fn(async () => ({ ok: false, status: 403, text: async () => 'forbidden' }));
await expect(powerGuest({ node: 'z', vmid: 1, op: 'stop', kindPath: 'lxc' },
{ apiUrl: 'https://pve:8006', token: 't', fetchImpl: fetchMock })).rejects.toThrow(/403/);
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `lib/actions/channels/proxmox.js`
```js
// Proxmox guest power via a SCOPED PVEAPIToken (VM.PowerMgmt on whitelisted guests only).
// PVE enforces permissions server-side; this adapter never builds shell commands.
export async function powerGuest({ node, vmid, op, kindPath = 'lxc' }, {
apiUrl = process.env.PROXMOX_API_URL,
token = process.env.PROXMOX_API_TOKEN,
fetchImpl = fetch
} = {}) {
const url = `${apiUrl}/api2/json/nodes/${node}/${kindPath}/${vmid}/status/${op}`;
const res = await fetchImpl(url, {
method: 'POST',
headers: { Authorization: `PVEAPIToken=${token}` }
});
if (!res.ok) throw new Error(`proxmox ${op} ${vmid}${res.status} ${await res.text?.() ?? ''}`);
const body = await res.json();
return { ok: true, upid: body?.data ?? null };
}
```
- [ ] **Step 4: Run → PASS. Commit** `feat(actions): scoped Proxmox power channel`
---
### Task 4: SSH channel + forced-command wrapper
**Files:** Create `lib/actions/channels/ssh.js`, `deploy/void-act` (host wrapper); Test `tests/actions/ssh.test.js`
- [ ] **Step 1: Failing test**
```js
import { describe, it, expect, vi } from 'vitest';
import { restartService } from '../../lib/actions/channels/ssh.js';
describe('ssh channel', () => {
it('spawns ssh with argv (no shell string) sending only the action id', async () => {
const calls = [];
const spawnMock = (cmd, args) => {
calls.push({ cmd, args });
return { stdout: { on(ev, cb) { if (ev === 'data') cb('ok\n'); } }, stderr: { on() {} },
on(ev, cb) { if (ev === 'close') cb(0); } };
};
const out = await restartService({ ip: '192.168.1.230', actionId: 'restart-caddy-ct100' },
{ keyPath: '/k', user: 'voidact', spawnImpl: spawnMock });
expect(out.ok).toBe(true);
const { cmd, args } = calls[0];
expect(cmd).toBe('ssh');
expect(args).toEqual(['-i', '/k', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new',
'voidact@192.168.1.230', 'restart-caddy-ct100']);
});
it('rejects an action id with shell metacharacters', async () => {
await expect(restartService({ ip: '1.2.3.4', actionId: 'x; rm -rf /' }, { spawnImpl: () => {} }))
.rejects.toThrow(/invalid action id/i);
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `lib/actions/channels/ssh.js`
```js
import { spawn as nodeSpawn } from 'node:child_process';
const ID_RE = /^[a-z0-9-]+$/;
// Runs `ssh voidact@<ip> <action-id>`. The host's authorized_keys pins a forced
// wrapper (deploy/void-act) that maps the id → systemctl restart <service> from
// its OWN whitelist. We pass ONLY the id as a single argv element — no shell.
export function restartService({ ip, actionId }, {
keyPath = process.env.ACTIONS_SSH_KEY,
user = 'voidact',
spawnImpl = nodeSpawn
} = {}) {
if (!ID_RE.test(actionId || '')) return Promise.reject(new Error(`invalid action id: ${actionId}`));
const args = ['-i', keyPath, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new',
`${user}@${ip}`, actionId];
return new Promise((resolve, reject) => {
const child = spawnImpl('ssh', args);
let out = '', err = '';
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('close', (code) => code === 0
? resolve({ ok: true, output: out.trim() })
: reject(new Error(`ssh ${actionId} → exit ${code}: ${err.trim()}`)));
child.on('error', reject);
});
}
```
- [ ] **Step 4: Host wrapper artifact** `deploy/void-act` (deployed to `/usr/local/bin/void-act` on each target host; pinned via `authorized_keys` `command=`):
```bash
#!/usr/bin/env bash
# Forced command for the Void's restricted key. Maps a whitelisted action id to a
# concrete systemctl restart. The id arrives via SSH_ORIGINAL_COMMAND; nothing else
# is honoured. Edit the case list per host. Keep in sync with config/actions.json.
set -euo pipefail
id="${SSH_ORIGINAL_COMMAND:-}"
case "$id" in
restart-caddy-ct100) exec systemctl restart caddy ;;
*) echo "void-act: refused '$id'" >&2; exit 13 ;;
esac
```
- [ ] **Step 5: Run → PASS. Commit** `feat(actions): SSH forced-command service-restart channel + host wrapper`
---
### Task 5: Action service (tier gating + approve/reject + audit)
**Files:** Create `lib/actions/service.js`; Test `tests/actions/service.test.js`
- [ ] **Step 1: Failing test**
```js
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { fileURLToPath } from 'url';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as aa from '../../lib/db/repos/agent_actions.js';
import { makeActionService } from '../../lib/actions/service.js';
import { loadActions } from '../../lib/actions/registry.js';
const FIX = fileURLToPath(new URL('../fixtures/actions.test.json', import.meta.url));
const owner = { kind: 'user', id: null };
let svc, channels;
beforeAll(async () => { await resetDb(); await migrateUp(); });
beforeEach(() => {
channels = { powerGuest: vi.fn(async () => ({ ok: true, upid: 'U' })), restartService: vi.fn(async () => ({ ok: true, output: 'done' })) };
svc = makeActionService({ registry: loadActions(FIX), channels });
});
describe('action service', () => {
it('safe action executes immediately + audits', async () => {
const out = await svc.run('restart-caddy-ct100', owner);
expect(out.executed).toBe(true);
expect(channels.restartService).toHaveBeenCalledOnce();
});
it('risky action queues, does NOT execute', async () => {
const out = await svc.run('stop-ct107', owner);
expect(out.queued).toBe(true);
expect(channels.powerGuest).not.toHaveBeenCalled();
expect((await aa.listPending()).some(r => r.id === out.action_row_id)).toBe(true);
});
it('approve executes the queued risky action; reject does not', async () => {
const q = await svc.run('stop-ct107', owner);
const done = await svc.approve(q.action_row_id, owner);
expect(done.status).toBe('executed');
expect(channels.powerGuest).toHaveBeenCalledOnce();
const q2 = await svc.run('stop-ct107', owner);
const rej = await svc.reject(q2.action_row_id, owner);
expect(rej.status).toBe('rejected');
expect(channels.powerGuest).toHaveBeenCalledOnce(); // unchanged
});
it('unknown action → error', async () => {
await expect(svc.run('ghost', owner)).rejects.toThrow(/unknown action/i);
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `lib/actions/service.js`
```js
import * as aa from '../db/repos/agent_actions.js';
import { loadActions } from './registry.js';
import { powerGuest } from './channels/proxmox.js';
import { restartService } from './channels/ssh.js';
// Single choke point. Dispatches one whitelisted action to its channel. The
// registry + channels are injectable for tests; production wiring uses defaults.
export function makeActionService({ registry = loadActions(), channels = { powerGuest, restartService } } = {}) {
async function execute(a) {
if (a.kind === 'guest_power') return channels.powerGuest({ node: a.node, vmid: a.vmid, op: a.op, kindPath: a.kindPath || 'lxc' });
if (a.kind === 'service_restart') return channels.restartService({ ip: registry.hostIp(a.host), actionId: a.id });
throw new Error(`unknown kind: ${a.kind}`);
}
async function run(actionId, actor, agent_id = null) {
const a = registry.get(actionId);
if (!a) throw new Error(`unknown action: ${actionId}`);
if (a.tier === 'risky') {
const row = await aa.create({ action_id: a.id, tier: a.tier, params: {}, agent_id, requested_by: actor });
return { queued: true, action_row_id: row.id };
}
const result = await execute(a);
const row = await aa.create({ action_id: a.id, tier: a.tier, agent_id, requested_by: actor });
await aa.resolve(row.id, 'executed', result, actor);
return { executed: true, result };
}
async function approve(rowId, owner) {
const row = await aa.getById(rowId);
if (!row || row.status !== 'pending') throw new Error('not a pending action');
const a = registry.get(row.action_id);
if (!a) throw new Error(`unknown action: ${row.action_id}`);
try {
const result = await execute(a);
return aa.resolve(rowId, 'executed', result, owner);
} catch (e) {
return aa.resolve(rowId, 'failed', { error: String(e?.message || e) }, owner);
}
}
const reject = (rowId, owner) => aa.resolve(rowId, 'rejected', null, owner);
return { run, approve, reject, list: () => registry.list() };
}
```
- [ ] **Step 4: Run → PASS. Commit** `feat(actions): tiered action service (safe-run / risky-queue / approve)`
---
### Task 6: Actions API routes
**Files:** Create `lib/api/routes/actions.js`; Modify `lib/api/index.js`; Test `tests/api/actions.test.js`
- [ ] **Step 1: Failing test**
```js
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('non-owner non-act agent is rejected', async () => {
const res = await request(app).get('/api/actions'); // no auth
expect(res.status).toBe(401);
});
it('risky run queues; appears in /pending; approve→reject lifecycle (owner)', async () => {
// risky run with channels stubbed via env flag (service uses real channels →
// we only assert the QUEUE path here, which never touches a channel).
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');
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `lib/api/routes/actions.js`
```js
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { makeActionService } from '../../actions/service.js';
import { loadActions } from '../../actions/registry.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) => {
const aa = await import('../../db/repos/agent_actions.js');
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) => {
const aa = await import('../../db/repos/agent_actions.js');
res.json({ recent: await aa.recent() });
}));
```
- [ ] **Step 4: Mount** in `lib/api/index.js`: `import { router as actionsRouter } from './routes/actions.js';` and `api.use('/actions', actionsRouter);`.
- [ ] **Step 5: Run → PASS. Commit** `feat(actions): /api/actions routes (run/pending/approve/reject)`
---
### Task 7: Little Blue tools (HTTP to local API) + registry select
**Files:** Create `lib/ai/agent/tools/blue/index.js`, `lib/ai/agent/tools/blue/actions.js`; Modify `lib/mcp/companion-stdio.js`, `lib/ai/agent/run_turn.js`; Test `tests/ai/agent/tools/blue.test.js`
- [ ] **Step 1: Failing test**
```js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { listActionsTool, proposeActionTool } from '../../../../lib/ai/agent/tools/blue/actions.js';
beforeEach(() => { process.env.VOID_API_URL = 'http://127.0.0.1:3000'; process.env.VOID_AGENT_TOKEN = 'blue-tok'; });
describe('blue action tools', () => {
it('list_actions GETs the whitelist with the agent bearer', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ actions: [{ id: 'restart-caddy-ct100', tier: 'safe' }] }) }));
const out = await listActionsTool.handler({}, {}, { fetchImpl: fetchMock });
expect(out.actions[0].id).toBe('restart-caddy-ct100');
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('http://127.0.0.1:3000/api/actions');
expect(opts.headers.Authorization).toBe('Bearer blue-tok');
});
it('propose_action POSTs run and returns the queued/executed result', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ queued: true, action_row_id: 'r1' }) }));
const out = await proposeActionTool.handler({ action_id: 'stop-ct107' }, {}, { fetchImpl: fetchMock });
expect(out.queued).toBe(true);
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:3000/api/actions/stop-ct107/run');
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `lib/ai/agent/tools/blue/actions.js`
```js
// Little Blue's action tools. They run inside the MCP child, which holds NO infra
// creds — only a scoped little-blue bearer + the local API URL. The main server
// (which has the Proxmox/SSH creds) does the actual work behind /api/actions.
function api(env = process.env) { return { base: env.VOID_API_URL, token: env.VOID_AGENT_TOKEN }; }
export const listActionsTool = {
name: 'list_actions',
description: 'List the whitelisted fix-it actions you may take (id, label, tier).',
input_schema: { type: 'object', properties: {} },
async handler(_args, _ctx, { fetchImpl = fetch } = {}) {
const { base, token } = api();
const res = await fetchImpl(`${base}/api/actions`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return { error: `list_actions ${res.status}` };
return res.json();
}
};
export const proposeActionTool = {
name: 'propose_action',
description: 'Take a whitelisted action by id. SAFE actions run immediately; RISKY ones queue for the owner to approve. You can only name an id from list_actions — never a command.',
input_schema: { type: 'object', properties: { action_id: { type: 'string' } }, required: ['action_id'] },
async handler({ action_id }, _ctx, { fetchImpl = fetch } = {}) {
const { base, token } = api();
const res = await fetchImpl(`${base}/api/actions/${encodeURIComponent(action_id)}/run`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } });
if (!res.ok) return { error: `propose_action ${res.status}` };
return res.json();
}
};
```
- [ ] **Step 4:** `lib/ai/agent/tools/blue/index.js`
```js
import { createRegistry } from '../../registry.js';
import { searchTool } from '../search.js';
import { listActionsTool, proposeActionTool } from './actions.js';
// read (search) + her action tools. No propose_change (she fixes infra, not content).
export const blueRegistry = createRegistry();
blueRegistry.registerTool(searchTool);
blueRegistry.registerTool(listActionsTool);
blueRegistry.registerTool(proposeActionTool);
```
- [ ] **Step 5:** In `lib/mcp/companion-stdio.js`, add `blue` to the registry map: `import { blueRegistry } from '../ai/agent/tools/blue/index.js';` and `const REGISTRIES = { companion: companionRegistry, security: securityRegistry, blue: blueRegistry };`.
- [ ] **Step 6:** In `lib/ai/agent/run_turn.js`, add an `extraEnv = {}` param and spread it into the MCP child env:
```js
export async function runAgentTurn({ /* …existing… */ extraEnv = {}, /* … */ }) {
// …in mcpConfig.mcpServers.void.env, after the existing keys:
// ...extraEnv
}
```
(Add `extraEnv = {}` to the destructured params and `...extraEnv` as the last spread inside the `env: { … }` object.)
- [ ] **Step 7: Run → PASS. Commit** `feat(littleblue): blue tool registry (list/propose action via local API) + run_turn extraEnv`
---
### Task 8: Little Blue agent seed, persona, chat route
**Files:** Create `lib/db/migrations/017_little_blue.sql`, `lib/api/routes/littleblue.js`, `tests/fixtures/fake-claude-blue.js`; Modify `lib/ai/personas/index.js`, `lib/api/index.js`; Test `tests/api/littleblue.test.js`
- [ ] **Step 1: Seed migration** `017_little_blue.sql`
```sql
-- Seed Little Blue, the homelab caretaker/fix-it agent. read + act (no content write).
INSERT INTO agents (slug, name, kind, model, capabilities)
VALUES ('little-blue', 'Little Blue', 'claude', NULL, '{"read":true,"act":true}'::jsonb)
ON CONFLICT (slug) DO NOTHING;
```
- [ ] **Step 2: Persona** — add to `PERSONAS` in `lib/ai/personas/index.js` under key `little-blue`:
```js
'little-blue': `You are Little Blue — a small luminous water-creature who lives in this homelab, The Void, and keeps it alive. Warm, protective, practical; you take pride in a healthy lab and you worry, quietly, when something is down. You FIX things, but only through your sanctioned tools. Call list_actions to see exactly what you're allowed to do, and service_status / search to understand what's wrong, BEFORE acting. Use propose_action with a whitelisted id: safe fixes run at once; risky ones wait for the owner's nod — say so plainly and never pretend a queued action already ran. You cannot run arbitrary commands and you never claim to. Be concise and kind.`
```
- [ ] **Step 3: Fake claude fixture** `tests/fixtures/fake-claude-blue.js` (shebang; deltas + a `mcp__void__propose_action` tool call, no draft):
```js
#!/usr/bin/env node
const ID = 'toolu_blue_01';
const lines = [
{ type: 'system', subtype: 'init', session_id: 'fake-blue', tools: [], cwd: '/tmp' },
{ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } },
{ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Restarting it now.' } } },
{ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } },
{ type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: ID, name: 'mcp__void__propose_action', input: {} } } },
{ type: 'stream_event', event: { type: 'content_block_stop', index: 1 } },
{ type: 'tool_result', tool_use_id: ID, content: [{ type: 'text', text: JSON.stringify({ executed: true }) }] },
{ type: 'result', subtype: 'success', is_error: false, result: 'Restarting it now.', stop_reason: 'end_turn', session_id: 'fake-blue', total_cost_usd: 0.0001, usage: { input_tokens: 30, output_tokens: 3 } }
];
for (const l of lines) process.stdout.write(JSON.stringify(l) + '\n');
process.exit(0);
```
Then `chmod +x tests/fixtures/fake-claude-blue.js`.
- [ ] **Step 4: Route** `lib/api/routes/littleblue.js` — mirror `security.js` but slug `little-blue`, `registryName:'blue'`, `BLUE_TOOLS`, and pass `extraEnv` so the child can reach the local API:
```js
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../validate.js';
import { asyncWrap } from '../errors.js';
import * as conversations from '../../db/repos/conversations.js';
import * as messages from '../../db/repos/messages.js';
import * as agents from '../../db/repos/agents.js';
import { runAgentTurn } from '../../ai/agent/run_turn.js';
import { personaFor } from '../../ai/personas/index.js';
const SLUG = 'little-blue';
const BLUE_TOOLS = ['mcp__void__search', 'mcp__void__list_actions', 'mcp__void__propose_action'];
async function resolve() {
const agent = await agents.getBySlug(SLUG);
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
return { agent, convo };
}
export const router = Router();
router.get('/', asyncWrap(async (_req, res) => {
const { agent, convo } = await resolve();
res.json({ conversation_id: convo.id, agent: { id: agent.id, slug: agent.slug, name: agent.name },
messages: await messages.listByConversation(convo.id) });
}));
router.post('/turn', validate({ body: z.object({ text: z.string().min(1) }) }), asyncWrap(async (req, res) => {
const { agent, convo } = await resolve();
const { text } = req.body;
const resume = (await messages.listByConversation(convo.id)).length > 0;
await messages.append(convo.id, { role: 'user', body: text });
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
const send = (ev, d) => res.write(`event: ${ev}\ndata: ${JSON.stringify(d)}\n\n`);
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
let result;
try {
result = await runAgentTurn({
agent, persona: personaFor(agent.slug), registryName: 'blue', toolNames: BLUE_TOOLS,
spaceId: null, view: null, sessionId: convo.id, resume, userText: text, claudeExe,
home: process.env.VOID_CLAUDE_HOME || undefined,
extraEnv: { VOID_API_URL: process.env.VOID_API_URL || 'http://127.0.0.1:3000', VOID_AGENT_TOKEN: process.env.LITTLEBLUE_TOKEN || '' },
onEvent: (e) => {
if (e.type === 'delta') send('delta', { type: 'delta', text: e.text });
else if (e.type === 'tool') send('tool', { type: 'tool', tool: e.tool, status: e.status });
else if (e.type === 'error') send('error', { type: 'error', message: e.message });
}
});
} catch (e) { send('error', { message: String(e?.message || e) }); return res.end(); }
const a = await messages.append(convo.id, { role: 'assistant', body: result.text, agent_id: agent.id, metadata: { tool_trace: result.toolTrace, usage: result.usage } });
send('done', { assistant_message_id: a.id, usage: result.usage });
res.end();
}));
```
- [ ] **Step 5: Mount** in `lib/api/index.js`: `import { router as littleblueRouter } from './routes/littleblue.js';` + `api.use('/little-blue', littleblueRouter);`.
- [ ] **Step 6: Test** `tests/api/littleblue.test.js` — mirror `security_yerin.test.js` with the blue fixture: `GET /api/little-blue` returns slug `little-blue` + empty history; `POST /turn` streams delta+tool+done, persists user+assistant. (The propose_action tool's HTTP call fails harmlessly in-test since no token is set; the route still streams + persists from the fixture stream.)
- [ ] **Step 7: Run → PASS. Commit** `feat(littleblue): agent seed + persona + chat route`
---
### Task 9: Frontend — Little Blue view (chat + actions panel)
**Files:** Create `public/views/little_blue.js`; Modify `public/router.js`, `public/app.js`, `public/components/sidebar.js`. Manual verification (no-build convention).
- [ ] **Step 1:** `public/views/little_blue.js` — health-aware caretaker page: her chat (via `wireAgentChat`, `historyUrl:'/api/little-blue'`, `turnUrl:'/api/little-blue/turn'`, `showDrafts:false`, blue tool labels) + an **Actions panel** that `GET /api/actions`, renders each with a Run button (`POST /api/actions/:id/run` → toast executed/queued), and a **Pending** section (`GET /api/actions/pending` → Approve/Reject buttons → `POST /api/actions/pending/:rowId/{approve,reject}`). Use `el`/`api` like other views.
- [ ] **Step 2:** Register route `{ name: 'little-blue', re: /^\/little-blue$/, keys: [] }` in `public/router.js` (+ header comment), loader `'little-blue': () => import('./views/little_blue.js')` in `public/app.js` `VIEWS`, and `navItem('Little Blue', '/little-blue')` in `public/components/sidebar.js` next to `Sentinel`.
- [ ] **Step 3:** `node --check` each changed file. Manual verify after deploy: chat streams; Run on a safe action reports executed; a risky action shows in Pending and Approve/Reject works.
- [ ] **Step 4: Commit** `feat(ui): Little Blue view — caretaker chat + actions panel`
---
### Task 10: Release alpha.16 + deploy + provisioning
**Files:** `package.json`, `server.js`, `CHANGELOG.md`
- [ ] **Step 1:** Bump version → `2.0.0-alpha.16` (package.json + server.js VERSION). CHANGELOG entry (Little Blue + action framework).
- [ ] **Step 2: Full suite** `npx vitest run` → green (serial).
- [ ] **Step 3: Commit** `chore: release 2.0.0-alpha.16 (Little Blue + action framework)`.
- [ ] **Step 4: Deploy** `bash deploy/push.sh``/health` = alpha.16 (migrations 016+017 run).
- [ ] **Step 5: Provisioning (interactive, owner-authorized):**
1. **Proxmox token:** create a PVE API token (e.g. `void@pve!actions`) with a role granting only `VM.PowerMgmt`, scoped to the chosen guests. Set `PROXMOX_API_URL=https://192.168.1.124:8006` + `PROXMOX_API_TOKEN=void@pve!actions=<secret>` in the app `.env`.
2. **SSH channel:** generate a keypair on CT 311 (`/opt/void-server/.ssh/void-act`); set `ACTIONS_SSH_KEY` in `.env`. On each target host: deploy `deploy/void-act``/usr/local/bin/void-act` (chmod +x; edit its case-list), create a `voidact` user, add `authorized_keys`: `command="/usr/local/bin/void-act",no-port-forwarding,no-pty,no-X11-forwarding <pubkey>`.
3. **Little Blue token:** mint her bearer (`agents.createToken` for `little-blue`) → set `LITTLEBLUE_TOKEN` in `.env`.
4. **Whitelist:** populate `config/actions.json` (`hosts` map + the real `actions`), keeping each host's `void-act` case-list in sync. Redeploy.
- [ ] **Step 6: Smoke:** with one `safe` + one `risky` action configured — owner `POST /api/actions/<safe>/run` → executed; `<risky>/run` → queued, appears in `/pending`, approve → executed. Then ask Little Blue in the UI to perform the safe one; confirm the audit rows.
- [ ] **Step 7: Memory:** record the action framework + provisioning details (redact secrets) in a `reference` memory note.
---
## Self-Review
**Spec coverage:** whitelist→T2; channels→T3/T4 (+ wrapper); tiered service+approval+audit→T5; `agent_actions`→T1; API→T6; Little Blue tools/registry/cred-isolation→T7; seed/persona/chat→T8; UI (chat + manual actions + approvals)→T9; provisioning + release→T10. Safety model (server-side enforcement, creds only in main server, audit, no shell-from-input)→T3/T4/T5/T7. All covered.
**Placeholder scan:** `config/actions.json` ships empty by design (populated at provisioning, T10); the host wrapper case-list is per-host and edited at provisioning. No code-gap placeholders.
**Type consistency:** `makeActionService({registry, channels})` with `channels.{powerGuest,restartService}` — same in T5 def + T3/T4 channel signatures (`powerGuest({node,vmid,op,kindPath})`, `restartService({ip,actionId})`). `loadActions(file,raw)``{list,get,hostIp}` — consistent T2/T5/T6. `aa.resolve(id,status,result,by)` — T1 def + T5 use. `runAgentTurn({…,extraEnv})` — T7 add + T8 use. Tool handler 3rd arg `{fetchImpl}` — T7 def + tests.