From d500b6fa009a294aedc58a0bca790663a1ba717b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 21:38:28 +1000 Subject: [PATCH] docs: Little Blue implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-04-little-blue.md | 751 ++++++++++++++++++ 1 file changed, 751 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-little-blue.md diff --git a/docs/superpowers/plans/2026-06-04-little-blue.md b/docs/superpowers/plans/2026-06-04-little-blue.md new file mode 100644 index 0000000..10cc212 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-little-blue.md @@ -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@ `. The host's authorized_keys pins a forced +// wrapper (deploy/void-act) that maps the id → systemctl restart 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=` 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 `. + 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//run` → executed; `/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.