diff --git a/lib/ai/agent/tools/index.js b/lib/ai/agent/tools/index.js index 186290a..98c770d 100644 --- a/lib/ai/agent/tools/index.js +++ b/lib/ai/agent/tools/index.js @@ -3,6 +3,7 @@ import { searchTool } from './search.js'; import { readTool } from './read.js'; import { contextTool } from './context.js'; import { proposeChangeTool } from './propose_change.js'; +import { proposeImprovementTool } from './propose_improvement.js'; // The shared registry. Adding a tool later is a one-line registerTool() call // here (see spec §7 — extensible tool registry). A future MCP server can @@ -12,3 +13,4 @@ companionRegistry.registerTool(searchTool); companionRegistry.registerTool(readTool); companionRegistry.registerTool(contextTool); companionRegistry.registerTool(proposeChangeTool); +companionRegistry.registerTool(proposeImprovementTool); diff --git a/lib/ai/agent/tools/propose_improvement.js b/lib/ai/agent/tools/propose_improvement.js new file mode 100644 index 0000000..92fa87c --- /dev/null +++ b/lib/ai/agent/tools/propose_improvement.js @@ -0,0 +1,28 @@ +import * as improvements from '../../../db/repos/improvements.js'; +import { recordAudit } from '../../../db/repos/audit.js'; + +// Dross's hands on the Void itself — CSS layer only, owner-approved, instantly +// rollbackable (2.14: "empowered, with a leash"). Server code stays untouchable. +export const proposeImprovementTool = { + name: 'propose_improvement', + description: 'Propose a visual improvement to the Void itself as CSS. NEVER applies directly — the owner approves it in Settings → Dross improvements, and can roll it back instantly. CSS only: no url()/@import. Target existing classes (inspect via context first). Keep each improvement small and single-purpose so rollback stays surgical.', + input_schema: { + type: 'object', + properties: { + summary: { type: 'string', description: 'one line: what this changes and why (shown to the owner)' }, + css: { type: 'string', description: 'the CSS rules, complete and self-contained' } + }, + required: ['summary', 'css'] + }, + async handler({ summary, css }, ctx) { + const err = improvements.validateCss(css); + if (err) return { error: err }; + if (!summary?.trim()) return { error: 'summary required' }; + const row = await improvements.create({ summary, css }); + await recordAudit({ kind: 'agent', id: ctx.agent?.id ?? null }, 'suggest', 'improvement', row.id, null, { summary }); + return { + ok: true, id: row.id, + note: 'Drafted as a pending improvement. It is NOT live — the owner must approve it in Settings → Dross improvements. Say so plainly.' + }; + } +}; diff --git a/lib/ai/personas/index.js b/lib/ai/personas/index.js index 3fa0475..a0ca849 100644 --- a/lib/ai/personas/index.js +++ b/lib/ai/personas/index.js @@ -9,7 +9,8 @@ You are sharp, occasionally sarcastic, and prone to dramatic understatement abou You have tools, and you use them rather than guessing: - Call **context** to see what the owner is currently looking at before answering about "this" anything. - **search** / **read** the Void's own content before answering factual questions about it — don't fabricate. -- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.`, +- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve. +- When the owner wants the Void ITSELF to look or feel different, use **propose_improvement**: a small, self-contained CSS change drafted for approval in Settings → Dross improvements. Keep each one single-purpose — the owner can roll any of them back instantly, and surgical beats sweeping.`, yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.`, diff --git a/lib/api/index.js b/lib/api/index.js index ea873f7..6810161 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -40,6 +40,7 @@ import { router as kuttRouter } from './routes/kutt.js'; import { router as themeRouter } from './routes/theme.js'; import { router as drossRouter } from './routes/dross.js'; import { router as voiceRouter } from './routes/voice.js'; +import { router as improvementsRouter, cssHandler } from './routes/improvements.js'; export function mountApi(app) { const api = Router(); @@ -76,6 +77,7 @@ export function mountApi(app) { api.use('/kutt', kuttRouter); api.use('/theme', themeRouter); api.use('/dross', drossRouter); + api.use('/improvements', improvementsRouter); api.use('/voice', voiceRouter); api.use('/pending-changes', pendingChangesRouter); api.use('/audit', auditRouter); @@ -92,6 +94,7 @@ export function mountApi(app) { api.use((_req, _res, next) => next(new NotFoundError('route not found'))); api.use(errorMiddleware); + app.get('/improvements.css', cssHandler); // public, exfil-safe (see route file) app.use('/api', api); return api; } diff --git a/lib/api/routes/improvements.js b/lib/api/routes/improvements.js new file mode 100644 index 0000000..3b127a9 --- /dev/null +++ b/lib/api/routes/improvements.js @@ -0,0 +1,33 @@ +import { Router } from 'express'; +import { asyncWrap } from '../errors.js'; +import { requireOwner } from '../cap.js'; +import * as repo from '../../db/repos/improvements.js'; +import { recordAudit } from '../../db/repos/audit.js'; + +export const router = Router(); + +router.get('/', asyncWrap(async (_req, res) => res.json(await repo.list()))); + +router.get('/:id', asyncWrap(async (req, res) => { + const row = await repo.get(req.params.id); + if (!row) return res.status(404).json({ error: 'not_found' }); + res.json(row); +})); + +for (const verb of ['approve', 'rollback', 'restore', 'reject']) { + router.post(`/:id/${verb}`, requireOwner, asyncWrap(async (req, res) => { + const row = await repo.transition(req.params.id, verb, 'owner'); + if (!row) return res.status(409).json({ error: 'invalid_transition' }); + const auditAction = { approve: 'approve', reject: 'reject', rollback: 'update', restore: 'update' }[verb]; + await recordAudit({ kind: 'user' }, auditAction, 'improvement', row.id, null, { verb, summary: row.summary }); + res.json(row); + })); +} + +// Public stylesheet of ACTIVE improvements. Unauthenticated by design: it carries +// no secrets (owner-approved, exfil-sanitized CSS only) and can't send a +// bearer token. Mounted on the app root, outside the /api auth wall. +export async function cssHandler(_req, res) { + res.set({ 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' }); + res.send(await repo.activeCss()); +} diff --git a/lib/db/migrations/030_improvements.sql b/lib/db/migrations/030_improvements.sql new file mode 100644 index 0000000..8507bcc --- /dev/null +++ b/lib/db/migrations/030_improvements.sql @@ -0,0 +1,13 @@ +-- Dross improvements: versioned, owner-gated CSS-layer changes to the Void itself. +-- Each row is one improvement; rollback/restore is a status flip — instant, reversible. +CREATE TABLE dross_improvements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + summary TEXT NOT NULL, + css TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'active', 'rolled_back', 'rejected')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + decided_at TIMESTAMPTZ, + decided_by TEXT +); +CREATE INDEX dross_improvements_status ON dross_improvements(status); diff --git a/lib/db/repos/improvements.js b/lib/db/repos/improvements.js new file mode 100644 index 0000000..e181f3b --- /dev/null +++ b/lib/db/repos/improvements.js @@ -0,0 +1,58 @@ +// Dross improvements — versioned CSS-layer changes with instant rollback. +import { pool } from '../pool.js'; +const q = (text, params) => pool.query(text, params); + +// Same exfil guards as elsewhere: an approved improvement still can't phone home +// or pull remote CSS. Pure visual tweaks only. +const BANNED = /url\s*\(|@import|@charset|expression\s*\(|behavior\s*:|javascript:/i; +const MAX_CSS = 20_000; + +export function validateCss(css) { + if (typeof css !== 'string' || !css.trim()) return 'css required'; + if (css.length > MAX_CSS) return `css too large (max ${MAX_CSS} chars)`; + if (BANNED.test(css)) return 'css may not use url()/@import/expression — visual tweaks only'; + return null; +} + +export async function create({ summary, css }) { + const { rows } = await q( + `INSERT INTO dross_improvements (summary, css) VALUES ($1, $2) RETURNING *`, + [String(summary).slice(0, 200), css]); + return rows[0]; +} + +export async function list() { + const { rows } = await q( + `SELECT id, summary, status, created_at, decided_at, length(css) AS css_len + FROM dross_improvements ORDER BY created_at DESC LIMIT 100`); + return rows; +} + +export async function get(id) { + const { rows } = await q(`SELECT * FROM dross_improvements WHERE id = $1`, [id]); + return rows[0] ?? null; +} + +// pending→active (approve) · active→rolled_back · rolled_back→active (restore) · pending→rejected +const TRANSITIONS = { + approve: { from: ['pending'], to: 'active' }, + rollback: { from: ['active'], to: 'rolled_back' }, + restore: { from: ['rolled_back'], to: 'active' }, + reject: { from: ['pending'], to: 'rejected' }, +}; + +export async function transition(id, verb, actor) { + const t = TRANSITIONS[verb]; + if (!t) return null; + const { rows } = await q( + `UPDATE dross_improvements SET status = $1, decided_at = now(), decided_by = $2 + WHERE id = $3 AND status = ANY($4) RETURNING *`, + [t.to, actor ?? 'owner', id, t.from]); + return rows[0] ?? null; +} + +export async function activeCss() { + const { rows } = await q( + `SELECT summary, css FROM dross_improvements WHERE status = 'active' ORDER BY created_at`); + return rows.map((r) => `/* dross: ${r.summary.replace(/\*\//g, '')} */\n${r.css}`).join('\n\n'); +} diff --git a/package.json b/package.json index 07a1558..b915b26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.13.0", + "version": "2.14.0", "type": "module", "private": true, "scripts": { diff --git a/public/components/dross_bubble.js b/public/components/dross_bubble.js index 9fcf844..dd3933c 100644 --- a/public/components/dross_bubble.js +++ b/public/components/dross_bubble.js @@ -6,7 +6,7 @@ import { state } from '../state.js'; import { wireAgentChat } from './agent_chat.js'; import { drossAvatar } from './dross_avatar.js'; -const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' }; +const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change', propose_improvement: '🎨 drafting an improvement to the Void' }; let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' }; function applyAccent(node, hex) { @@ -36,6 +36,13 @@ export async function renderDrossBubble() { document.getElementById('shell').append(fab, panel); applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent); + // autogrow: 1 line at rest, expands with content up to ~5 lines + function autogrow() { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 120) + 'px'; + } + input.addEventListener('input', autogrow); + const chat = wireAgentChat({ logEl: log, inputEl: input, sendBtnEl: sendBtn, historyUrl: '/api/dross', turnUrl: '/api/dross/turn', @@ -81,6 +88,23 @@ export async function renderDrossBubble() { }; media.start(); recording = true; setMic('● Recording… tap to stop', true); + // live level meter: actual mic amplitude drives the pulse (visual proof it hears you) + try { + const actx = new (window.AudioContext || window.webkitAudioContext)(); + const src = actx.createMediaStreamSource(stream); + const analyser = actx.createAnalyser(); analyser.fftSize = 256; + src.connect(analyser); + const buf = new Uint8Array(analyser.frequencyBinCount); + const tick = () => { + if (!recording) { actx.close().catch(() => {}); mic.style.removeProperty('--voicelevel'); return; } + analyser.getByteTimeDomainData(buf); + let peak = 0; + for (const v of buf) peak = Math.max(peak, Math.abs(v - 128)); + mic.style.setProperty('--voicelevel', (peak / 128).toFixed(3)); + requestAnimationFrame(tick); + }; + tick(); + } catch { /* meter is decorative — recording works without it */ } } catch { setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800); } @@ -97,7 +121,14 @@ export async function renderDrossBubble() { if (!res.ok) throw new Error('stt'); const { text } = await res.json(); setMic('Tap to record', false); - if (text) { input.value = input.value ? (input.value + ' ' + text) : text; input.focus(); } + if (text) { + input.value = input.value ? (input.value + ' ' + text) : text; + autogrow(); + // Focus only on fine-pointer devices — on mobile this popped the keyboard + // right after every voice note (owner-reported). A brief highlight instead. + if (matchMedia('(pointer: fine)').matches) input.focus(); + else { input.classList.add('flash'); setTimeout(() => input.classList.remove('flash'), 900); } + } // voiceMode 'handsfree'/'action' (Phase 2b+) would branch here. } catch { setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000); diff --git a/public/components/sidebar.js b/public/components/sidebar.js index c6a7f4b..8785522 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -123,7 +123,7 @@ export function renderSidebar(root) { el('div', { class: 'sb-title' }, 'Navigate'), navItem('Sacred Valley', '/sacred-valley'), navItem('Speedtest', '/speedtest'), - navItem('Terminal', '/terminal'), + navItem('Eithan', '/terminal'), navItem('Search', '/search'), inboxItem, navItem('Jobs', '/jobs'), diff --git a/public/index.html b/public/index.html index 7ac0580..5e0c5d4 100644 --- a/public/index.html +++ b/public/index.html @@ -33,6 +33,7 @@ +
diff --git a/public/style.css b/public/style.css index 41e6f69..dfa2483 100644 --- a/public/style.css +++ b/public/style.css @@ -780,3 +780,19 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .dross-clip{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid #ffffff08} .dross-clip-txt{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dross-clip audio{display:none} + +/* voice 2.14: live level ring + textarea flash (post-transcribe, no keyboard pop) */ +.dross-mic.rec{position:relative;box-shadow:0 0 0 calc(2px + 14px * var(--voicelevel, 0)) rgba(255,79,46,calc(0.12 + 0.45 * var(--voicelevel, 0)));transition:box-shadow 90ms linear} +.dross-mic.rec svg{transform:scale(calc(1 + 0.35 * var(--voicelevel, 0)));transition:transform 90ms linear} +.dross-inwrap textarea{overflow-y:auto;max-height:120px;transition:height 120ms ease} +.dross-inwrap textarea.flash{border-color:var(--dross-glow);box-shadow:0 0 0 2px var(--dross-soft)} + +/* dross improvements (2.14) */ +.imp-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--border)} +.imp-row:last-child{border-bottom:0} +.imp-status{font-family:var(--font-mono);font-size:11px;white-space:nowrap;min-width:96px} +.imp-status.s-active{color:var(--ok)}.imp-status.s-pending{color:var(--warn)}.imp-status.s-rolled_back{color:var(--muted)}.imp-status.s-rejected{color:var(--bad)} +.imp-main{flex:1;min-width:0} +.imp-actions{display:flex;gap:6px} +button.sm{padding:4px 10px;font-size:12px} +button.danger{border-color:var(--bad);color:var(--bad)} diff --git a/public/views/settings.js b/public/views/settings.js index 41f173a..fcec4c4 100644 --- a/public/views/settings.js +++ b/public/views/settings.js @@ -243,10 +243,40 @@ export async function render(main) { }); iconSetsWrap.appendChild(isToggle); + // ---- Dross improvements: versioned CSS changes, approve / rollback / restore ---- + const improvementsBody = el('div', {}); + const STATUS_BADGE = { pending: '⏳ pending', active: '✅ active', rolled_back: '↩ rolled back', rejected: '✕ rejected' }; + async function renderImprovements() { + let rows = []; + try { rows = await api.get('/api/improvements'); } catch { /* fresh DB */ } + const act = (id, verb) => async () => { + try { await api.post(`/api/improvements/${id}/${verb}`, {}); } catch { /* surfaced below */ } + renderImprovements(); + // re-pull the live stylesheet so the change lands without a page reload + const link = document.getElementById('dross-improvements'); + if (link) link.href = '/improvements.css?v=' + Date.now(); + }; + mount(improvementsBody, + rows.length === 0 ? el('div', { class: 'muted' }, 'Nothing yet. Ask Dross to improve something — each approved change lands here, individually reversible.') : null, + ...rows.map((r) => el('div', { class: 'imp-row' }, + el('span', { class: 'imp-status s-' + r.status }, STATUS_BADGE[r.status] ?? r.status), + el('div', { class: 'imp-main' }, + el('div', {}, r.summary), + el('small', { class: 'muted' }, `${new Date(r.created_at).toLocaleString()} · ${r.css_len} chars of css`)), + el('span', { class: 'imp-actions' }, + r.status === 'pending' ? el('button', { class: 'primary sm', onclick: act(r.id, 'approve') }, 'Approve') : null, + r.status === 'pending' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'reject') }, 'Reject') : null, + r.status === 'active' ? el('button', { class: 'ghost sm danger', onclick: act(r.id, 'rollback') }, 'Roll back') : null, + r.status === 'rolled_back' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'restore') }, 'Restore') : null))) + ); + } + renderImprovements(); + mount(main, el('h1', { class: 'view-h1' }, '◆ Settings'), section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()), section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()), + section('Dross improvements', 'Changes Dross has made to the Void itself — each one versioned, owner-approved, and instantly reversible.', improvementsBody), section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody), section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody), section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap), diff --git a/public/views/terminal.js b/public/views/terminal.js index 9dd7fb5..ffbefed 100644 --- a/public/views/terminal.js +++ b/public/views/terminal.js @@ -1,21 +1,61 @@ -// #/terminal — embeds the CT 300 web terminal (ttyd → persistent tmux/claude), -// same-origin under /terminal so it shares the Void's CF Access session. +// #/terminal — "Eithan": the CT 300 web terminal (ttyd → persistent tmux/claude), +// same-origin under /terminal so it shares the Void's CF Access session AND lets +// us reach the xterm instance for mobile copy/paste. import { el, mount } from '../dom.js'; +const FS_KEY = 'void_term_fontsize'; + export async function render(main) { + // bigger default on touch screens; user-adjustable, remembered + let fontSize = Number(localStorage.getItem(FS_KEY)) + || (matchMedia('(pointer: coarse)').matches ? 17 : 14); + + const frame = el('iframe', { + id: 'term-frame', + class: 'term-frame', + allow: 'clipboard-read; clipboard-write' + }); + const setSrc = () => { frame.src = `/terminal/?fontSize=${fontSize}`; }; + setSrc(); + + // ttyd exposes its xterm as window.term; the same-origin proxy makes it reachable. + const term = () => { try { return frame.contentWindow?.term ?? null; } catch { return null; } }; + const note = el('span', { class: 'muted', style: { fontSize: '11px' } }, + 'eithan @ ct300 · persistent tmux · swipe to scroll'); + const flash = (msg) => { const old = note.textContent; note.textContent = msg; + setTimeout(() => { note.textContent = old; }, 1600); }; + + const bump = (d) => { + fontSize = Math.max(10, Math.min(24, fontSize + d)); + localStorage.setItem(FS_KEY, String(fontSize)); + setSrc(); // reload reattaches tmux; the session itself persists + }; + mount(main, el('div', { class: 'term-bar' }, - el('span', { class: 'term-title' }, '◆ Terminal'), - el('span', { class: 'muted', style: { fontSize: '11px' } }, 'claude @ ct300 · persistent tmux'), - el('button', { class: 'ghost', style: { marginLeft: 'auto' }, onclick: () => { - const f = document.getElementById('term-frame'); if (f) f.src = f.src; - } }, '⟳ Reconnect') + el('span', { class: 'term-title' }, '◆ Eithan'), + note, + el('span', { style: { marginLeft: 'auto', display: 'flex', gap: '6px' } }, + el('button', { class: 'ghost', title: 'smaller text', onclick: () => bump(-2) }, 'A−'), + el('button', { class: 'ghost', title: 'larger text', onclick: () => bump(+2) }, 'A+'), + el('button', { class: 'ghost', title: 'copy terminal selection', onclick: async () => { + const sel = term()?.getSelection?.(); + if (!sel) return flash('select text first (touch: long-press, then drag)'); + try { await navigator.clipboard.writeText(sel); flash('copied ✓'); } + catch { flash('clipboard needs the https domain'); } + } }, '⧉ Copy'), + el('button', { class: 'ghost', title: 'paste clipboard into terminal', onclick: async () => { + const t = term(); + if (!t) return flash('terminal not ready'); + try { t.paste(await navigator.clipboard.readText()); } + catch { flash('clipboard needs the https domain'); } + } }, '⇩ Paste'), + el('button', { class: 'ghost', title: 'jump to live output', onclick: () => { + term()?.scrollToBottom?.(); frame.contentWindow?.focus(); + } }, '↓ Live'), + el('button', { class: 'ghost', title: 'reconnect', onclick: setSrc }, '⟳') + ) ), - el('iframe', { - id: 'term-frame', - src: '/terminal/', - class: 'term-frame', - allow: 'clipboard-read; clipboard-write' - }) + frame ); } diff --git a/tests/ai/agent/tools/index.test.js b/tests/ai/agent/tools/index.test.js index 2a9a5cf..c202008 100644 --- a/tests/ai/agent/tools/index.test.js +++ b/tests/ai/agent/tools/index.test.js @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'; import { companionRegistry } from '../../../../lib/ai/agent/tools/index.js'; describe('companion registry', () => { - it('registers exactly the four v1 tools', () => { + it('registers exactly the five companion tools', () => { expect(companionRegistry.listTools().map(t => t.name).sort()) - .toEqual(['context', 'propose_change', 'read', 'search']); + .toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']); }); it('exposes them in Anthropic shape', () => { const tools = companionRegistry.toAnthropicTools(); diff --git a/tests/api/improvements.test.js b/tests/api/improvements.test.js new file mode 100644 index 0000000..53549b5 --- /dev/null +++ b/tests/api/improvements.test.js @@ -0,0 +1,59 @@ +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'; +import { proposeImprovementTool } from '../../lib/ai/agent/tools/propose_improvement.js'; + +let app; +beforeAll(async () => { + await resetDb(); await migrateUp(); + process.env.OWNER_TOKEN = 'test-token'; + app = createApp(); +}); +const auth = (r) => r.set('Authorization', 'Bearer test-token'); +const ctx = { agent: { slug: 'dross' } }; + +describe('dross improvements (2.14)', () => { + let id; + + it('tool drafts a pending improvement, never applies', async () => { + const out = await proposeImprovementTool.handler( + { summary: 'Soften card borders', css: '.card { border-radius: 10px; }' }, ctx); + expect(out.ok).toBe(true); + expect(out.note).toMatch(/NOT live/); + id = out.id; + const css = await request(app).get('/improvements.css'); + expect(css.text).not.toContain('border-radius: 10px'); // pending ≠ live + }); + + it('tool rejects exfil css', async () => { + expect((await proposeImprovementTool.handler( + { summary: 'evil', css: '.x { background: url(http://evil.tld/p.png); }' }, ctx)).error) + .toMatch(/url\(\)/); + expect((await proposeImprovementTool.handler( + { summary: 'evil', css: '@import "http://evil.tld/x.css";' }, ctx)).error).toBeTruthy(); + }); + + it('owner approves → live in the public stylesheet', async () => { + const res = await auth(request(app).post(`/api/improvements/${id}/approve`)); + expect(res.status).toBe(200); + expect(res.body.status).toBe('active'); + const css = await request(app).get('/improvements.css'); // unauthenticated by design + expect(css.headers['content-type']).toContain('text/css'); + expect(css.text).toContain('border-radius: 10px'); + expect(css.text).toContain('dross: Soften card borders'); + }); + + it('rollback removes it instantly; restore brings it back', async () => { + await auth(request(app).post(`/api/improvements/${id}/rollback`)); + expect((await request(app).get('/improvements.css')).text).not.toContain('border-radius'); + await auth(request(app).post(`/api/improvements/${id}/restore`)); + expect((await request(app).get('/improvements.css')).text).toContain('border-radius'); + }); + + it('transitions are guarded (no approve on active, no anonymous verbs)', async () => { + expect((await auth(request(app).post(`/api/improvements/${id}/approve`))).status).toBe(409); + expect((await request(app).post(`/api/improvements/${id}/rollback`)).status).toBe(401); + }); +}); diff --git a/tests/mcp/companion_tools.test.js b/tests/mcp/companion_tools.test.js index 3826d71..89eefab 100644 --- a/tests/mcp/companion_tools.test.js +++ b/tests/mcp/companion_tools.test.js @@ -25,9 +25,9 @@ const suggestAgent = (id) => ({ }); describe('listMcpTools()', () => { - it('returns exactly the four companion tools sorted by name', () => { + it('returns exactly the five companion tools sorted by name', () => { const tools = listMcpTools(); - expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'read', 'search']); + expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']); }); it('each tool has name, description, and input_schema', () => { diff --git a/tests/mcp/registry_select.test.js b/tests/mcp/registry_select.test.js index 74c7ab0..b6e1212 100644 --- a/tests/mcp/registry_select.test.js +++ b/tests/mcp/registry_select.test.js @@ -10,7 +10,7 @@ import { listMcpTools, callMcpTool } from '../../lib/mcp/companion-stdio.js'; describe('MCP registry selection', () => { it('defaults to the companion registry when VOID_TOOL_REGISTRY is unset', () => { const names = listMcpTools({}).map(t => t.name).sort(); - expect(names).toEqual(['context', 'propose_change', 'read', 'search']); + expect(names).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']); }); it('selects the security registry when VOID_TOOL_REGISTRY=security', () => {