import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { pathToFileURL } from 'node:url'; import express from 'express'; import request from 'supertest'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createAgentToolRuntime } from './runtime.js'; import { OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS, OPENCHAMBER_CONTROL_ACTION_DEFINITIONS } from '../openchamber-control/actions.js'; const temporaryDirectories = []; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); }); const createRuntime = async (overrides = {}) => { const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-agent-tool-')); temporaryDirectories.push(dataDir); const executeAction = vi.fn(async () => ({ projects: [] })); const env = {}; const runtime = createAgentToolRuntime({ crypto, fsPromises: fs, path, dataDir, getActivePort: () => 3901, executeAction, env, ...overrides, }); return { runtime, dataDir, executeAction, env }; }; describe('agent tool action allowlist', () => { it('defines a short title and agent description for every action', () => { expect(OPENCHAMBER_CONTROL_ACTION_DEFINITIONS.every(({ action, title, description }) => action && title && description)).toBe(true); }); it.each([ 'projects.list', 'models.list', 'session.list', 'session.create', 'session.send', 'session.fork', 'session.status', 'session.messages', 'schedule.list', 'schedule.create', 'schedule.run', 'schedule.delete', 'schedule.toggle', ])('delegates %s to the shared control service', async (action) => { const { runtime, executeAction } = await createRuntime(); const input = { action, projectId: 'project-1' }; await runtime.execute({ input, contextDirectory: '/work/project' }); expect(executeAction).toHaveBeenCalledWith(action, input, '/work/project', {}); }); it.each([ 'session.delete', 'schedule.status', ])('rejects %s outside the agent allowlist without invoking the service', async (action) => { const { runtime, executeAction } = await createRuntime(); await expect(runtime.execute({ input: { action } })).resolves.toEqual(expect.objectContaining({ ok: false, action, error: expect.objectContaining({ kind: 'usage' }), })); expect(executeAction).not.toHaveBeenCalled(); }); }); describe('managed agent tool runtime', () => { it('materializes the plugin and preserves configured plugin entries', async () => { const { runtime, dataDir, env } = await createRuntime(); env.OPENCODE_CONFIG_CONTENT = '{ // existing\n "plugin": ["file:///existing.js", ["example-plugin", {"flag": true}]], "model": "test/model" }'; const preparedEnv = await runtime.prepareManagedOpenCodeEnv(); const config = JSON.parse(preparedEnv.OPENCODE_CONFIG_CONTENT); const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js'); const source = await fs.readFile(pluginPath, 'utf8'); expect(config.model).toBe('test/model'); expect(config.plugin).toEqual([ 'file:///existing.js', ['example-plugin', { flag: true }], expect.stringContaining('/agent-tool/openchamber-plugin.js'), ]); expect(preparedEnv.OPENCHAMBER_AGENT_TOOL_URL).toBe('http://127.0.0.1:3901/api/openchamber/agent-tool'); expect(preparedEnv.OPENCHAMBER_AGENT_TOOL_TOKEN).toMatch(/^[A-Za-z0-9_-]+$/); expect(source).toContain('openchamber: {'); for (const { action, description } of OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS) { expect(source).toContain(JSON.stringify({ const: action, description })); } expect(source).not.toContain('"schedule.status"'); const pluginModule = await import(`${pathToFileURL(pluginPath).href}?schema=${Date.now()}`); const hooks = await pluginModule.OpenChamberPlugin(); expect(hooks.tool.openchamber.description).toContain('Session dispatches return immediately by default'); expect(hooks.tool.openchamber.description).toContain('Set wait only when the user asks or the next step requires the completed result'); expect(hooks.tool.openchamber.args.action.oneOf).toContainEqual({ const: 'session.messages', description: 'Read text-only messages and current sessionStatus for sessionId; directory and limit 10 are defaults', }); expect(hooks.tool.openchamber.args.parameters.properties.wait.description).toBe( 'Wait for current session activity to become idle. Omit by default; use only when the user asks or the next step requires the completed result', ); expect(hooks.tool.openchamber.args.parameters.properties.sessionId).toEqual({ type: 'string' }); expect(source).not.toContain('title: "OpenChamber"'); expect(source).not.toContain('@opencode-ai/plugin'); expect(source).not.toContain(preparedEnv.OPENCHAMBER_AGENT_TOOL_TOKEN); }); it('executes actions through the shared control service', async () => { const executeAction = vi.fn(async () => ({ projects: [] })); const { runtime } = await createRuntime({ executeAction }); const result = await runtime.execute({ input: { action: 'projects.list' }, contextDirectory: '/work/project', }); expect(result).toEqual({ schemaVersion: 1, ok: true, action: 'projects.list', data: { projects: [] }, }); expect(executeAction).toHaveBeenCalledWith('projects.list', { action: 'projects.list' }, '/work/project', {}); }); it('keeps service failures as structured tool results', async () => { const error = Object.assign(new Error('Task not found'), { statusCode: 404 }); const { runtime } = await createRuntime({ executeAction: vi.fn(async () => { throw error; }) }); await expect(runtime.execute({ input: { action: 'schedule.run', taskId: 'missing' }, contextDirectory: '/work/project', })).resolves.toEqual(expect.objectContaining({ schemaVersion: 1, ok: false, action: 'schedule.run', error: { message: 'Task not found', kind: 'usage' }, })); }); it('forwards cancellation to the shared control service', async () => { const executeAction = vi.fn(async (_action, _input, _directory, options) => { await new Promise((resolve, reject) => { options.signal.addEventListener('abort', () => reject(Object.assign(new Error('OpenChamber action was cancelled'), { statusCode: 499 })), { once: true }); }); }); const { runtime } = await createRuntime({ executeAction }); const controller = new AbortController(); const pending = runtime.execute({ input: { action: 'projects.list' } }, { signal: controller.signal }); controller.abort(); await expect(pending).resolves.toEqual(expect.objectContaining({ ok: false, action: 'projects.list', error: { message: 'OpenChamber action was cancelled', kind: 'runtime' }, })); expect(executeAction).toHaveBeenCalledWith('projects.list', { action: 'projects.list' }, undefined, { signal: controller.signal }); }); it('requires the per-child token on the loopback route', async () => { const { runtime } = await createRuntime(); const env = await runtime.prepareManagedOpenCodeEnv(); const app = express(); runtime.registerRoutes(app, express); await request(app) .post('/api/openchamber/agent-tool') .send({ input: { action: 'projects.list' } }) .expect(401); const response = await request(app) .post('/api/openchamber/agent-tool') .set('authorization', `Bearer ${env.OPENCHAMBER_AGENT_TOOL_TOKEN}`) .send({ input: { action: 'projects.list' } }) .expect(200); expect(response.body).toEqual(expect.objectContaining({ ok: true, action: 'projects.list' })); }); it('executes through the materialized plugin and authenticated callback', async () => { let activePort = null; const { runtime, dataDir } = await createRuntime({ getActivePort: () => activePort }); const app = express(); runtime.registerRoutes(app, express); const server = await new Promise((resolve) => { const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); }); activePort = server.address().port; const previousUrl = process.env.OPENCHAMBER_AGENT_TOOL_URL; const previousToken = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN; try { const env = await runtime.prepareManagedOpenCodeEnv(); process.env.OPENCHAMBER_AGENT_TOOL_URL = env.OPENCHAMBER_AGENT_TOOL_URL; process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = env.OPENCHAMBER_AGENT_TOOL_TOKEN; const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js'); const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`); const hooks = await pluginModule.OpenChamberPlugin(); const metadata = vi.fn(); const result = await hooks.tool.openchamber.execute( { action: 'projects.list', parameters: {} }, { directory: '/work/project', abort: new AbortController().signal, metadata }, ); expect(JSON.parse(result.output)).toEqual({ schemaVersion: 1, ok: true, action: 'projects.list', data: { projects: [] }, }); expect(result.title).toBe('List configured projects'); expect(result.metadata.openchamber.description).toBe('List configured projects'); expect(metadata).toHaveBeenCalledWith(expect.objectContaining({ title: 'List configured projects', metadata: expect.objectContaining({ openchamber: expect.objectContaining({ description: 'List configured projects' }), }), })); } finally { if (previousUrl === undefined) delete process.env.OPENCHAMBER_AGENT_TOOL_URL; else process.env.OPENCHAMBER_AGENT_TOOL_URL = previousUrl; if (previousToken === undefined) delete process.env.OPENCHAMBER_AGENT_TOOL_TOKEN; else process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = previousToken; await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); });