180 lines
5.8 KiB
JavaScript
180 lines
5.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import {
|
|
createAiderExecutorAdapter,
|
|
createGoosedExecutorAdapter,
|
|
createOpenHandsExecutorAdapter,
|
|
} from './executor-adapters.mjs';
|
|
|
|
function request(executor = 'goosed') {
|
|
return {
|
|
executor,
|
|
task: {
|
|
instruction: 'Fix the validation',
|
|
workspaceRef: { kind: 'workspace-alias', id: 'canary' },
|
|
},
|
|
};
|
|
}
|
|
|
|
function sseResponse(events) {
|
|
const encoder = new TextEncoder();
|
|
const body = new ReadableStream({
|
|
start(controller) {
|
|
for (const event of events) {
|
|
controller.enqueue(encoder.encode(
|
|
`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`,
|
|
));
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
status: 200,
|
|
headers: { 'content-type': 'text/event-stream' },
|
|
});
|
|
}
|
|
|
|
test('Goosed adapter uses the current session API, streams bounded metadata and returns refs', async () => {
|
|
const calls = [];
|
|
const emitted = [];
|
|
const adapter = createGoosedExecutorAdapter({
|
|
baseUrl: 'http://127.0.0.1:18006',
|
|
secret: 'secret',
|
|
workspaceResolver: async () => '/tmp/canary',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url, init });
|
|
if (url.endsWith('/agent/start')) {
|
|
return Response.json({ id: 'session-1' });
|
|
}
|
|
if (url.endsWith('/events')) {
|
|
return sseResponse([
|
|
{ type: 'message', data: { type: 'message', secret: 'not-projected' } },
|
|
{ type: 'finish', data: { type: 'finish' } },
|
|
]);
|
|
}
|
|
if (url.endsWith('/reply')) return Response.json({ ok: true });
|
|
if (url.endsWith('/status')) return Response.json({ ok: true });
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
},
|
|
});
|
|
const result = await adapter.submit(request(), {
|
|
jobId: 'job-1',
|
|
emit: async (type, data) => emitted.push({ type, data }),
|
|
});
|
|
assert.deepEqual(result.artifactRefs, [{ kind: 'goosed-session', id: 'session-1' }]);
|
|
assert.equal(result.metrics.eventCount, 2);
|
|
assert.equal(calls[0].url, 'http://127.0.0.1:18006/agent/start');
|
|
assert.equal(calls[0].init.headers['X-Secret-Key'], 'secret');
|
|
assert.equal(JSON.parse(calls[0].init.body).working_dir, '/tmp/canary');
|
|
assert.equal(
|
|
JSON.parse(calls.find((call) => call.url.endsWith('/reply')).init.body)
|
|
.user_message.content[0].text,
|
|
'Fix the validation',
|
|
);
|
|
assert.deepEqual(
|
|
emitted.map((event) => event.type),
|
|
[
|
|
'executor_job_session_created',
|
|
'executor_job_adapter_event',
|
|
'executor_job_adapter_event',
|
|
],
|
|
);
|
|
assert.equal(JSON.stringify(emitted).includes('not-projected'), false);
|
|
assert.deepEqual(await adapter.health(), { ok: true, status: 200 });
|
|
});
|
|
|
|
test('Goosed adapter rejects remote targets unless explicitly allowed', () => {
|
|
assert.throws(
|
|
() => createGoosedExecutorAdapter({ baseUrl: 'https://executor.example.com' }),
|
|
/explicit allowRemote/,
|
|
);
|
|
assert.doesNotThrow(
|
|
() => createGoosedExecutorAdapter({
|
|
baseUrl: 'https://executor.example.com',
|
|
allowRemote: true,
|
|
}),
|
|
);
|
|
assert.throws(
|
|
() => createGoosedExecutorAdapter({
|
|
baseUrl: 'https://executor.example.com',
|
|
allowRemote: true,
|
|
tlsInsecure: true,
|
|
}),
|
|
/restricted to local targets/,
|
|
);
|
|
assert.doesNotThrow(
|
|
() => createGoosedExecutorAdapter({
|
|
baseUrl: 'https://host.docker.internal:18006',
|
|
allowRemote: true,
|
|
tlsInsecure: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('Aider process adapter resolves a workspace alias and never invokes a shell', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-executor-'));
|
|
const workspace = path.join(root, 'canary');
|
|
await fs.mkdir(workspace);
|
|
const spawnCalls = [];
|
|
const emitted = [];
|
|
const spawnImpl = (command, args, options) => {
|
|
spawnCalls.push({ command, args, options });
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = () => {};
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', Buffer.from('done'));
|
|
child.emit('exit', 0, null);
|
|
});
|
|
return child;
|
|
};
|
|
try {
|
|
const adapter = createAiderExecutorAdapter({
|
|
command: '/usr/local/bin/aider',
|
|
allowedRoot: root,
|
|
workspaceAliases: { canary: workspace },
|
|
spawnImpl,
|
|
});
|
|
const result = await adapter.submit(request('aider'), {
|
|
jobId: 'job-1',
|
|
emit: async (type, data) => emitted.push({ type, data }),
|
|
});
|
|
assert.equal(result.outcome, 'completed');
|
|
assert.deepEqual(result.artifactRefs, [{ kind: 'workspace-alias', id: 'canary' }]);
|
|
assert.equal(spawnCalls[0].options.cwd, await fs.realpath(workspace));
|
|
assert.equal(spawnCalls[0].options.shell, false);
|
|
assert.deepEqual(
|
|
spawnCalls[0].args,
|
|
['--yes-always', '--message', 'Fix the validation'],
|
|
);
|
|
assert.equal(emitted[0].data.stream, 'stdout');
|
|
assert.equal('HOME' in spawnCalls[0].options.env, false);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('OpenHands adapter refuses workspace aliases outside its isolated root', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-executor-root-'));
|
|
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-executor-outside-'));
|
|
try {
|
|
const adapter = createOpenHandsExecutorAdapter({
|
|
command: '/usr/local/bin/openhands',
|
|
allowedRoot: root,
|
|
workspaceAliases: { canary: outside },
|
|
});
|
|
await assert.rejects(
|
|
adapter.submit(request('openhands'), { jobId: 'job-1' }),
|
|
(error) => error.code === 'EXECUTOR_WORKSPACE_OUTSIDE_ROOT',
|
|
);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
await fs.rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|