Files
openchamber/packages/web/server/lib/agent-tool/runtime.test.js
T

406 lines
18 KiB
JavaScript
Raw Normal View History

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('emits both tools, each carrying only its own actions and inputs', async () => {
const { runtime, dataDir } = await createRuntime();
await runtime.prepareManagedOpenCodeEnv();
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?both=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
const controlActions = tool.openchamber.args.action.enum;
const webActions = tool.openchamber_web.args.action.enum;
expect(webActions).toContain('browser.open');
expect(controlActions).not.toContain('browser.open');
expect(webActions).not.toContain('session.create');
// Turning one tool off has to remove its inputs too, not just its actions.
expect(Object.keys(tool.openchamber_web.args.parameters.properties)).toContain('url');
expect(Object.keys(tool.openchamber.args.parameters.properties)).not.toContain('url');
expect(Object.keys(tool.openchamber.args.parameters.properties)).toContain('sessionId');
});
it('accepts inputs passed beside the action, not only inside parameters', async () => {
const { runtime, dataDir } = await createRuntime();
const prepared = await runtime.prepareManagedOpenCodeEnv();
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?flat=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
const sent = [];
const originalFetch = globalThis.fetch;
const originalUrl = process.env.OPENCHAMBER_AGENT_TOOL_URL;
const originalToken = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN;
process.env.OPENCHAMBER_AGENT_TOOL_URL = prepared.OPENCHAMBER_AGENT_TOOL_URL;
process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = prepared.OPENCHAMBER_AGENT_TOOL_TOKEN;
globalThis.fetch = async (_endpoint, init) => {
sent.push(JSON.parse(init.body));
return new Response(JSON.stringify({ schemaVersion: 1, ok: true, action: 'browser.open', data: {} }));
};
const context = { directory: '/work/project', abort: new AbortController().signal, metadata: () => {} };
try {
// The shape a model actually produced: url and viewport next to action.
await tool.openchamber_web.execute(
{ action: 'browser.open', url: 'https://example.test', viewport: 'mobile' },
context,
);
// The documented shape must keep working, and win when both are present.
await tool.openchamber_web.execute(
{ action: 'browser.open', url: 'https://ignored.test', parameters: { url: 'https://example.test/nested' } },
context,
);
// Both tools come from one template, so session control accepts it too.
await tool.openchamber.execute(
{ action: 'session.messages', sessionId: 'ses_1', limit: 3 },
context,
);
} finally {
globalThis.fetch = originalFetch;
process.env.OPENCHAMBER_AGENT_TOOL_URL = originalUrl;
process.env.OPENCHAMBER_AGENT_TOOL_TOKEN = originalToken;
}
expect(sent[0].input).toEqual({ action: 'browser.open', url: 'https://example.test', viewport: 'mobile' });
expect(sent[1].input.url).toBe('https://example.test/nested');
expect(sent[2].input).toEqual({ action: 'session.messages', sessionId: 'ses_1', limit: 3 });
});
it('omits a tool the user turned off', async () => {
const { runtime, dataDir } = await createRuntime();
await runtime.prepareManagedOpenCodeEnv({ includeControl: false, includeWeb: true, includeMemory: false });
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?web=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
expect(Object.keys(tool)).toEqual(['openchamber_web']);
});
it('exposes memory as its own tool carrying only its own inputs', async () => {
const { runtime, dataDir } = await createRuntime();
await runtime.prepareManagedOpenCodeEnv({ includeControl: true, includeWeb: false, includeMemory: true });
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?memory=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
expect(Object.keys(tool)).toEqual(['openchamber', 'openchamber_memory']);
expect(Object.keys(tool.openchamber_memory.args.parameters.properties).sort())
.toEqual(['body', 'memoryId', 'scope', 'title', 'type']);
// Memory inputs must not leak into the control tool's schema, which the
// model pays for on every unrelated call.
expect(Object.keys(tool.openchamber.args.parameters.properties)).not.toContain('memoryId');
});
it('omits memory entirely when the user turns it off', async () => {
const { runtime, dataDir } = await createRuntime();
await runtime.prepareManagedOpenCodeEnv({ includeControl: true, includeWeb: false, includeMemory: false });
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?nomemory=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
expect(Object.keys(tool)).toEqual(['openchamber']);
});
it('injects the plugin when memory is the only tool left on', async () => {
const { runtime, dataDir } = await createRuntime();
await runtime.prepareManagedOpenCodeEnv({ includeControl: false, includeWeb: false, includeMemory: true });
const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?onlymemory=${Date.now()}`);
const { tool } = await pluginModule.OpenChamberPlugin();
expect(Object.keys(tool)).toEqual(['openchamber_memory']);
});
it('refuses to inject a plugin with no tools in it', async () => {
const { runtime } = await createRuntime();
let failed = false;
try {
await runtime.prepareManagedOpenCodeEnv({ includeControl: false, includeWeb: false, includeMemory: false });
} catch {
failed = true;
}
expect(failed).toBe(true);
});
it('accepts the bare action a tool name already qualifies', async () => {
// Observed: the model called `read` on openchamber_memory, having taken the
// tool's own name for the namespace.
const executeAction = vi.fn(async () => ({ memory: {} }));
const { runtime } = await createRuntime({ executeAction });
const result = await runtime.execute({
input: { action: 'read', title: 'Uses bun' },
contextDirectory: '/work/project',
tool: 'openchamber_memory',
});
expect(result.ok).toBe(true);
expect(result.action).toBe('memory.read');
expect(executeAction).toHaveBeenCalledWith(
'memory.read',
{ action: 'memory.read', title: 'Uses bun' },
'/work/project',
{},
);
});
it('tells an unresolvable action what the calling tool can do', async () => {
const { runtime } = await createRuntime();
const result = await runtime.execute({
input: { action: 'get' },
tool: 'openchamber_memory',
});
expect(result.ok).toBe(false);
expect(result.error.message).toContain('memory.read');
expect(result.error.message).not.toContain('browser.open');
});
it('does not let one tool reach another tool\'s actions', async () => {
const executeAction = vi.fn(async () => ({}));
const { runtime } = await createRuntime({ executeAction });
const result = await runtime.execute({
input: { action: 'open', url: 'https://example.test' },
tool: 'openchamber_memory',
});
expect(result.ok).toBe(false);
expect(executeAction).not.toHaveBeenCalled();
});
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()));
}
});
});