Files
Termix/plugins/ai/tests/backend/agents/agents.test.ts
T

159 lines
7.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import {
compatible,
shellQuote,
validStart,
} from "../../../src/backend/agents/types.js";
import { providerPath } from "../../../src/backend/agents/routes.js";
import { REMOTE_RUNNER } from "../../../src/backend/agents/remote-runner.js";
// These run sh, git and agent binaries the way a remote Linux host does.
const posix = it.skipIf(process.platform === "win32");
describe("remote agent boundaries", () => {
it("rejects shell syntax and invalid targets", () => {
const start = {
agent: "pi",
hostId: 1,
providerId: 1,
model: "glm",
cwd: "/tmp",
};
expect(validStart(start)).toBe(true);
for (const patch of [
{ cwd: "relative" },
{ hostId: -1 },
{ agent: "shell" },
{ executable: "pi; id" },
{ cwd: "/tmp\0" },
])
expect(validStart({ ...start, ...patch })).toBe(false);
expect(shellQuote("it's $(secret)")).toBe("'it'\\''s $(secret)'");
});
it("keeps model protocols explicit", () => {
expect(compatible("claude", "openai_compatible")).toBe(false);
expect(compatible("pi", "openai_compatible")).toBe(true);
expect(compatible("codex", "gemini")).toBe(false);
});
it("limits the reverse proxy to inference endpoints", () => {
expect(providerPath("/v1/chat/completions")).toBe("/chat/completions");
expect(providerPath("/v1/responses")).toBe("/responses");
expect(providerPath("/v1/files")).toBeNull();
expect(providerPath("/v1/../admin")).toBeNull();
expect(providerPath("//evil.test/secrets")).toBeNull();
});
});
const fake = String.raw`#!/usr/bin/env node
const fs=require('fs'),http=require('http'),rl=require('readline');
const emit=m=>console.log(JSON.stringify(m));
const args=process.argv.slice(2);
if(args.includes('--session') && require('path').dirname(args[args.indexOf('--session')+1])===process.env.PI_CODING_AGENT_DIR) {console.error('Pi migrates session files from its config root');process.exit(1);}
if(args[0]==='serve') {
let stream;const port=Number(args[args.indexOf('--port')+1]);
http.createServer(async(q,r)=>{
if(!process.env.OPENCODE_SERVER_PASSWORD || q.headers.authorization!=='Basic '+Buffer.from('opencode:'+process.env.OPENCODE_SERVER_PASSWORD).toString('base64')){r.statusCode=401;r.end();return;}
if(q.url==='/event'){r.writeHead(200,{'Content-Type':'text/event-stream'});r.write(': hello\n\n');stream=r;return;}
r.setHeader('Content-Type','application/json');
if(q.url==='/session')return r.end(JSON.stringify({id:'oc-session'}));
if(q.url.includes('prompt_async')){let body='';for await(const d of q)body+=d;const parsed=JSON.parse(body);if(!parsed.parts.some(p=>p.type==='file' && p.mime==='image/png' && p.url==='data:image/png;base64,aW1hZ2U=')){r.statusCode=400;r.end('{}');return;}r.statusCode=204;r.end();setTimeout(()=>{stream.write('data: '+JSON.stringify({type:'message.part.updated',properties:{part:{id:'text-1',type:'text',sessionID:'oc-session'}}})+'\n\n');stream.write('data: '+JSON.stringify({type:'message.part.delta',properties:{sessionID:'oc-session',partID:'text-1',field:'text',delta:'verified'}})+'\n\n');stream.write('data: '+JSON.stringify({type:'session.idle',properties:{sessionID:'oc-session'}})+'\n\n');},30);return;}
r.end('{}');
}).listen(port,'127.0.0.1');
} else rl.createInterface({input:process.stdin}).on('line',line=>{
const m=JSON.parse(line);
if(args[0]==='app-server') {
if(m.method==='initialize')emit({id:m.id,result:{}});
if(m.method==='thread/start'||m.method==='thread/resume')emit({id:m.id,result:{thread:{id:'codex-thread'}}});
if(m.method==='turn/start'){if(!m.params.input.some(i=>i.type==='image' && i.url==='data:image/png;base64,aW1hZ2U='))throw Error('Missing Codex image');emit({id:m.id,result:{}});emit({method:'item/agentMessage/delta',params:{delta:'verified'}});emit({method:'turn/completed',params:{turn:{status:'completed'}}});}
} else if(args.includes('--input-format')) {
if(m.type==='user'){if(!m.message.content.some(i=>i.type==='image' && i.source.media_type==='image/png' && i.source.data==='aW1hZ2U='))throw Error('Missing Claude image');}if(m.type==='user')emit({type:'control_request',request_id:'approval',request:{subtype:'can_use_tool',tool_name:'Read',input:{path:'/tmp'}}});
if(m.type==='control_response'){emit({type:'stream_event',event:{type:'content_block_delta',delta:{type:'text_delta',text:'verified'}}});emit({type:'result',is_error:false});}
} else if(m.type==='get_state') {emit({type:'response',id:m.id,success:true,data:{sessionId:'pi-session'}});
} else if(m.type==='prompt') {
if(m.images?.[0]?.data!=='aW1hZ2U=' || m.images?.[0]?.mimeType!=='image/png')throw Error('Missing Pi image');
emit({type:'message_update',assistantMessageEvent:{type:'text_delta',delta:'verified'}});emit({type:'agent_end'});
}
});
`;
for (const agent of ["pi", "claude", "codex", "opencode"] as const) {
posix(
`${agent}: native protocol streams a turn and returns to ready`,
async () => {
const dir = await mkdtemp(join(tmpdir(), "termix-agent-test-"));
const executable = join(dir, "fake-agent");
const image = join(dir, "image.png");
await writeFile(image, "image");
await writeFile(executable, fake, { mode: 0o700 });
const child = spawn(process.execPath, ["-e", REMOTE_RUNNER], {
env: { ...process.env, HOME: dir },
stdio: ["pipe", "pipe", "pipe"],
});
const events: Record<string, unknown>[] = [];
let stderr = "";
child.stderr.on("data", (d) => (stderr += d));
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(Error("timeout " + stderr + JSON.stringify(events))),
12000,
);
let prompted = false;
createInterface({ input: child.stdout }).on("line", (line) => {
const m = JSON.parse(line);
events.push(m);
if (m.kind === "error") {
clearTimeout(timer);
reject(Error(m.text));
}
if (m.kind === "permission")
send({ type: "answer", requestId: m.requestId, allow: true });
if (m.kind === "status" && m.text === "ready") {
if (!prompted) {
prompted = true;
send({
type: "prompt",
text: "hello",
attachments: [{ path: image, mime: "image/png" }],
});
} else {
clearTimeout(timer);
resolve();
}
}
});
send({
type: "start",
config: {
agent,
id: "test-session",
cwd: dir,
executable,
model: "test-model",
proxyUrl: "http://127.0.0.1:1",
token: "temporary-token",
providerType:
agent === "claude" ? "anthropic" : "openai_compatible",
},
});
});
expect(
events.some((e) => e.kind === "text" && e.text === "verified"),
).toBe(true);
if (agent === "claude")
expect(events.some((e) => e.kind === "permission")).toBe(true);
} finally {
send({ type: "stop" });
await new Promise((resolve) => child.once("exit", resolve));
await rm(dir, { recursive: true, force: true });
}
},
15000,
);
}