From ff681847ed0f7f5f43f9400d84ad3db100f9756b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 21:42:27 +1000 Subject: [PATCH] feat(littleblue): blue tool registry (list/propose action via local API) + run_turn extraEnv Co-Authored-By: Claude Opus 4.8 --- lib/ai/agent/run_turn.js | 5 +++-- lib/ai/agent/tools/blue/actions.js | 29 +++++++++++++++++++++++++++++ lib/ai/agent/tools/blue/index.js | 9 +++++++++ lib/mcp/companion-stdio.js | 6 ++++-- tests/ai/agent/tools/blue.test.js | 22 ++++++++++++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 lib/ai/agent/tools/blue/actions.js create mode 100644 lib/ai/agent/tools/blue/index.js create mode 100644 tests/ai/agent/tools/blue.test.js diff --git a/lib/ai/agent/run_turn.js b/lib/ai/agent/run_turn.js index 40c9802..6da8a52 100644 --- a/lib/ai/agent/run_turn.js +++ b/lib/ai/agent/run_turn.js @@ -15,7 +15,7 @@ const STDIO_PATH = fileURLToPath(new URL('../../mcp/companion-stdio.js', import. */ export async function runAgentTurn({ agent, persona, registryName, toolNames, spaceId = null, view = null, - sessionId, resume = false, userText, claudeExe = 'claude', home, onEvent + sessionId, resume = false, userText, claudeExe = 'claude', home, onEvent, extraEnv = {} }) { const agentActor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities, scopes: agent.scopes }; const mcpConfigPath = join(tmpdir(), `void-mcp-${randomUUID()}.json`); @@ -30,7 +30,8 @@ export async function runAgentTurn({ VOID_AGENT_JSON: JSON.stringify(agentActor), VOID_VIEW_JSON: view ? JSON.stringify(view) : '', DATABASE_URL: process.env.DATABASE_URL || '', - OLLAMA_URL: process.env.OLLAMA_URL || '' + OLLAMA_URL: process.env.OLLAMA_URL || '', + ...extraEnv } } } diff --git a/lib/ai/agent/tools/blue/actions.js b/lib/ai/agent/tools/blue/actions.js new file mode 100644 index 0000000..2b4d64f --- /dev/null +++ b/lib/ai/agent/tools/blue/actions.js @@ -0,0 +1,29 @@ +// 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(); + } +}; diff --git a/lib/ai/agent/tools/blue/index.js b/lib/ai/agent/tools/blue/index.js new file mode 100644 index 0000000..73b0b4f --- /dev/null +++ b/lib/ai/agent/tools/blue/index.js @@ -0,0 +1,9 @@ +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); diff --git a/lib/mcp/companion-stdio.js b/lib/mcp/companion-stdio.js index f2c036a..6ab35c0 100644 --- a/lib/mcp/companion-stdio.js +++ b/lib/mcp/companion-stdio.js @@ -21,11 +21,13 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { companionRegistry } from '../ai/agent/tools/index.js'; import { securityRegistry } from '../ai/agent/tools/security/index.js'; +import { blueRegistry } from '../ai/agent/tools/blue/index.js'; import { buildCtxFromEnv } from './context.js'; // Which toolset this stdio server exposes, selected at launch via -// VOID_TOOL_REGISTRY (default: Dross's companion tools; 'security' = Yerin's). -const REGISTRIES = { companion: companionRegistry, security: securityRegistry }; +// VOID_TOOL_REGISTRY (default: Dross's companion tools; 'security' = Yerin's; +// 'blue' = Little Blue's action tools). +const REGISTRIES = { companion: companionRegistry, security: securityRegistry, blue: blueRegistry }; function resolveRegistry(env = process.env) { return REGISTRIES[env.VOID_TOOL_REGISTRY] || companionRegistry; } diff --git a/tests/ai/agent/tools/blue.test.js b/tests/ai/agent/tools/blue.test.js new file mode 100644 index 0000000..d5138d7 --- /dev/null +++ b/tests/ai/agent/tools/blue.test.js @@ -0,0 +1,22 @@ +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'); + }); +});