feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL
This commit is contained in:
ZacharyZcR authored and LukeGus committed 2026-10-03 16:01:47 -05:00
1 parent 77bcbdb3d6
commit bf817d8bd1
75 files changed
+7292 -6

No files matched your search

@@ -0,0 +1,140 @@
import { afterEach, expect, it } from "vitest";
import { createAgentStore } from "../../../src/backend/agents/store.js";
import type { AgentEvent } from "../../../src/backend/agents/types.js";
import { startServer, type TestServer } from "../helpers.js";
let server: TestServer;
afterEach(async () => {
await server?.close();
});
async function seed(id: string, events: Omit<AgentEvent, "seq">[] = []) {
const store = await createAgentStore(server.mock.ctx);
const session = {
id,
userId: "user-1",
hostId: 1,
providerId: 1,
agent: "pi" as const,
model: "test",
cwd: "/tmp",
executable: "pi",
status: "stopped" as const,
events: [],
updatedAt: new Date().toISOString(),
};
await store.save(session);
await store.append(
session,
events.map((e, i) => ({ ...e, seq: i + 1 })),
);
}
it("requires the separate agent permission even when AI is enabled", async () => {
server = await startServer();
await server.enableFor("user-1");
expect((await server.request("GET", "/agents")).status).toBe(403);
});
it("respects the global AI gate for agent routes", async () => {
server = await startServer({ permissions: ["ai.agents"] });
expect((await server.request("GET", "/agents")).status).toBe(403);
await server.enableFor("user-1");
expect((await server.request("GET", "/agents")).status).toBe(200);
});
it("does not expose another user's persisted session or allow a malformed launch", async () => {
server = await startServer({ permissions: ["ai.agents"] });
await server.enableFor("user-1");
await server.enableFor("user-2");
const id = "11111111-1111-1111-1111-111111111111";
await seed(id, [{ kind: "text", text: "private transcript" }]);
const other = await server.request("GET", `/agents/${id}`, {
user: "user-2",
});
expect(other.status).toBe(400);
expect(JSON.stringify(other.body)).not.toContain("private transcript");
const invalid = await server.request("POST", "/agents", {
body: {
agent: "pi",
hostId: 1,
providerId: 1,
model: "test",
cwd: "/tmp",
executable: "pi; uname",
},
});
expect(invalid.status).toBe(400);
});
it("gates installation before SSH and rejects caller-supplied commands", async () => {
server = await startServer();
await server.enableFor("user-1");
expect(
(
await server.request("POST", "/agents/install", {
body: { hostId: 1, agent: "pi" },
})
).status,
).toBe(403);
});
it("rejects arbitrary installer names", async () => {
server = await startServer({ permissions: ["ai.agents"] });
await server.enableFor("user-1");
expect(
(
await server.request("POST", "/agents/install", {
body: { hostId: 1, agent: "pi; id" },
})
).status,
).toBe(400);
});
it("requires agent permission for forwarding configuration", async () => {
server = await startServer();
await server.enableFor("user-1");
expect(
(
await server.request("POST", "/agents/forwarding", {
body: { hostId: 1 },
})
).status,
).toBe(403);
});
it("persists session metadata and queued drafts without exposing them to another user", async () => {
server = await startServer({ permissions: ["ai.agents"] });
await server.enableFor("user-1");
await server.enableFor("user-2");
const id = "22222222-2222-2222-2222-222222222222";
await seed(id);
expect(
(
await server.request("PATCH", `/agents/${id}`, {
body: { title: "Private", draft: "Keep this draft" },
})
).status,
).toBe(200);
expect(
(
await server.request("POST", `/agents/${id}/queue`, {
body: { operation: "add", text: "queued" },
})
).status,
).toBe(200);
const s = (await server.request("GET", `/agents/${id}`)).body;
expect(s).toMatchObject({
title: "Private",
draft: "Keep this draft",
queue: [{ text: "queued" }],
});
for (const [method, path, body] of [
["PATCH", `/agents/${id}`, { title: "hijack" }],
["POST", `/agents/${id}/queue`, { operation: "add", text: "injected" }],
["POST", `/agents/${id}/workspace`, { operation: "status" }],
] as const)
expect(
(await server.request(method, path, { user: "user-2", body })).status,
).toBe(400);
await server.request("PATCH", `/agents/${id}`, { body: { archived: true } });
expect((await server.request("POST", `/agents/${id}/resume`)).status).toBe(
400,
);
expect((await server.request("GET", `/agents/${id}`)).body).toMatchObject({
archived: true,
queuePaused: true,
});
});
@@ -0,0 +1,151 @@
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";
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) {
it(`${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);
}
@@ -0,0 +1,99 @@
import { expect, it } from "vitest";
import { mkdtemp, writeFile, readFile, mkdir, rm } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
FORWARDING_ADMIN_SCRIPT,
forwardingScript,
} from "../../../src/backend/agents/forwarding.js";
async function fixture(mode = "no") {
const dir = await mkdtemp(join(tmpdir(), "termix-forwarding-"));
const bin = join(dir, "bin");
await mkdir(bin);
const original = `Port 2222\nAllowTcpForwarding ${mode}\nMatch Address 10.1.1.3\n DenyUsers root\n`;
await writeFile(join(dir, "sshd_config"), original);
await writeFile(
join(bin, "sshd"),
`#!${process.execPath}
const fs=require('fs'),a=process.argv.slice(2),dir=${JSON.stringify(dir)};
const file=a.includes('-f')?a[a.indexOf('-f')+1]:dir+'/sshd_config';
if(a.includes('-t')){if(a.includes('-f')&&fs.existsSync(dir+'/invalid-candidate'))process.exit(1);process.exit(0);}
const text=fs.readFileSync(file,'utf8'),rule=text.match(/Match User root Address 10.1.1.35\\n AllowTcpForwarding (\\w+)/);
console.log('allowtcpforwarding '+(rule?rule[1]:${JSON.stringify(mode)}));
console.log('permitlisten '+(rule?'127.0.0.1:*':'any'));
console.log('gatewayports no\\ndisableforwarding no');
`,
{ mode: 0o700 },
);
await writeFile(
join(bin, "systemctl"),
`#!/bin/sh
if [ "$1" = reload ] && [ -f '${dir}/fail-reload' ]; then rm '${dir}/fail-reload'; exit 1; fi
exit 0
`,
{ mode: 0o700 },
);
const script = FORWARDING_ADMIN_SCRIPT.replaceAll(
"/usr/sbin/sshd",
join(bin, "sshd"),
).replaceAll("/etc/ssh", dir);
const run = () =>
execFileSync("sh", ["-c", script, "termix", "root", "10.1.1.35"], {
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
stdio: "pipe",
}).toString();
return {
dir,
original,
run,
config: () => readFile(join(dir, "sshd_config"), "utf8"),
close: () => rm(dir, { recursive: true, force: true }),
};
}
it("adds a scoped rule before existing Match blocks and is idempotent", async () => {
const f = await fixture();
try {
expect(f.run()).toContain("Enabled loopback remote forwarding");
const config = await f.config();
expect(config).toContain(
"AllowTcpForwarding no\n# BEGIN Termix Agent root 10.1.1.35\nMatch User root Address 10.1.1.35\n AllowTcpForwarding remote\n PermitListen 127.0.0.1:*\n GatewayPorts no",
);
expect(config).toContain("Match Address 10.1.1.3\n DenyUsers root");
expect(f.run()).toContain("already allowed");
expect(await f.config()).toBe(config);
} finally {
await f.close();
}
});
it("preserves existing local forwarding", async () => {
const f = await fixture("local");
try {
f.run();
expect(await f.config()).toContain(" AllowTcpForwarding yes");
} finally {
await f.close();
}
});
for (const failure of ["invalid-candidate", "fail-reload"]) {
it(`retains the original SSH configuration after ${failure}`, async () => {
const f = await fixture();
try {
await writeFile(join(f.dir, failure), "1");
expect(f.run).toThrow();
expect(await f.config()).toBe(f.original);
} finally {
await f.close();
}
});
}
it("rejects a malformed connection source before privilege escalation", () => {
expect(() =>
execFileSync("sh", ["-c", forwardingScript()], {
env: { ...process.env, SSH_CONNECTION: "1.2.3.4;bad 123 1.2.3.5 22" },
stdio: "pipe",
}),
).toThrow();
});
@@ -0,0 +1,74 @@
import { expect, it } from "vitest";
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { installScript } from "../../../src/backend/agents/install.js";
it("refuses corrupted Node.js downloads before extracting or installing", async () => {
const dir = await mkdtemp(join(tmpdir(), "termix-install-"));
try {
const bin = join(dir, "bin");
await mkdir(bin);
await writeFile(
join(bin, "curl"),
`#!/bin/sh
for arg do previous="$last"; last="$arg"; done
case "$last" in
*SHASUMS256.txt) printf '%064d node-v24.1.0-linux-x64.tar.gz\\n' 0 > "$last";;
*) echo corrupted > "$last";;
esac
`,
{ mode: 0o700 },
);
await writeFile(
join(bin, "uname"),
'#!/bin/sh\nif [ "$1" = -s ]; then echo Linux; else echo x86_64; fi\n',
{ mode: 0o700 },
);
let output = "";
try {
execFileSync("sh", ["-c", installScript("pi")], {
env: { ...process.env, HOME: dir, PATH: `${bin}:${process.env.PATH}` },
stdio: "pipe",
});
} catch (error) {
output = String((error as { stdout: Buffer }).stdout);
}
expect(output).toContain("Node.js SHA-256 verification failed");
await expect(
readFile(join(dir, ".local/share/termix-agent-runtime/node/bin/node")),
).rejects.toThrow();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("rejects a mirror dependency before npm ci", async () => {
const dir = await mkdtemp(join(tmpdir(), "termix-registry-"));
try {
const script = installScript("pi")
.split("node - <<'VERIFY'\n")[1]
.split("\nVERIFY")[0];
await writeFile(
join(dir, "package-lock.json"),
JSON.stringify({
packages: {
"node_modules/injected": {
version: "1.0.0",
resolved: "https://mirror.invalid/pkg.tgz",
integrity: "sha512-fake",
},
},
}),
);
expect(() =>
execFileSync(process.execPath, ["-e", script], {
cwd: dir,
stdio: "pipe",
}),
).toThrow();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,110 @@
import { afterEach, expect, it, vi } from "vitest";
import { Duplex, PassThrough } from "node:stream";
import { EventEmitter } from "node:events";
import { startServer, type TestServer } from "../helpers.js";
let server: TestServer;
afterEach(async () => {
await server?.close();
});
it("dispatches FIFO once per completed turn and pauses pending work when cancelled", async () => {
server = await startServer({
permissions: ["ai.agents", "ai.manage_providers"],
});
await server.enableFor("user-1");
const prompts: { text: string }[] = [];
class Channel extends Duplex {
stderr = new PassThrough();
_read() {}
_write(chunk: Buffer, _encoding: string, done: () => void) {
const m = JSON.parse(chunk.toString());
if (m.type === "start") this.complete();
if (m.type === "prompt") prompts.push(m);
if (m.type === "stop") {
this.push(null);
queueMicrotask(() => this.emit("close"));
}
done();
}
complete() {
this.push(JSON.stringify({ kind: "status", text: "ready" }) + "\n");
}
}
const channel = new Channel();
const client = Object.assign(new EventEmitter(), {
forwardIn: (
_host: string,
_port: number,
callback: (error: null, port: number) => void,
) => callback(null, 30000),
exec: (_cmd: string, callback: (error: null, ch: Channel) => void) =>
callback(null, channel),
});
vi.spyOn(server.mock.ctx.ssh, "connect").mockResolvedValue({
client,
dispose: () => channel.destroy(),
} as never);
const p = await server.request("POST", "/providers", {
body: {
providerType: "openai",
label: "Test",
apiKey: "unused",
defaultModel: "test",
},
});
const started = await server.request("POST", "/agents", {
body: {
agent: "pi",
hostId: 1,
providerId: p.body.provider.id,
model: "test",
cwd: "/tmp",
},
});
expect(started.status).toBe(201);
const id = started.body.id;
try {
expect(
(
await server.request("POST", `/agents/${id}/input`, {
body: { type: "prompt", text: "first" },
})
).status,
).toBe(200);
for (const text of ["second", "third"])
expect(
(
await server.request("POST", `/agents/${id}/queue`, {
body: { operation: "add", text },
})
).status,
).toBe(200);
expect(prompts.map((p) => p.text)).toEqual(["first"]);
channel.complete();
await vi.waitFor(() =>
expect(prompts.map((p) => p.text)).toEqual(["first", "second"]),
);
await server.request("POST", `/agents/${id}/input`, {
body: { type: "cancel" },
});
channel.complete();
await vi.waitFor(async () =>
expect((await server.request("GET", `/agents/${id}`)).body.status).toBe(
"ready",
),
);
expect(prompts).toHaveLength(2);
const paused = (await server.request("GET", `/agents/${id}`)).body;
expect(paused.queuePaused).toBe(true);
expect(paused.queue.map((p: { text: string }) => p.text)).toEqual([
"third",
]);
await server.request("POST", `/agents/${id}/queue`, {
body: { operation: "continue" },
});
await vi.waitFor(() =>
expect(prompts.map((p) => p.text)).toEqual(["first", "second", "third"]),
);
} finally {
await server.request("POST", `/agents/${id}/stop`);
}
});
@@ -0,0 +1,48 @@
import { expect, it } from "vitest";
import {
updateQueue,
updateSession,
promptInput,
} from "../../../src/backend/agents/session-state.js";
import type { AgentSession } from "../../../src/backend/agents/types.js";
const session = () =>
({ queue: [], attachments: [{ id: "image" }] }) as unknown as AgentSession;
it("queues, edits and removes messages without replacing their IDs", () => {
const s = session();
updateQueue(s, { operation: "add", text: "first" });
updateQueue(s, { operation: "add", text: "second" });
const id = s.queue![0].id;
updateQueue(s, {
operation: "update",
id,
text: "edited",
attachmentIds: ["image"],
});
expect(s.queue![0]).toEqual({ id, text: "edited", attachmentIds: ["image"] });
updateQueue(s, { operation: "pause" });
expect(s.queuePaused).toBe(true);
updateQueue(s, { operation: "continue" });
expect(s.queuePaused).toBe(false);
updateQueue(s, { operation: "remove", id });
expect(s.queue!.map((p) => p.text)).toEqual(["second"]);
expect(() =>
updateQueue(s, { operation: "update", id, text: "late edit" }),
).toThrow();
});
it("rejects foreign attachments and caps queues", () => {
const s = session();
expect(() =>
promptInput(s, { text: "x", attachmentIds: ["other-session"] }),
).toThrow();
for (let i = 0; i < 20; i++) updateQueue(s, { operation: "add", text: "x" });
expect(() =>
updateQueue(s, { operation: "add", text: "overflow" }),
).toThrow();
});
it("validates metadata before applying changes", () => {
const s = session();
updateSession(s, { title: "name", draft: "draft", archived: true });
expect(s).toMatchObject({ title: "name", draft: "draft", archived: true });
expect(() => updateSession(s, { title: "replacement", draft: 42 })).toThrow();
expect(s.title).toBe("name");
});
@@ -0,0 +1,101 @@
import { afterEach, expect, it } from "vitest";
import {
createAgentStore,
EVENT_HISTORY,
} from "../../../src/backend/agents/store.js";
import { startServer, type TestServer } from "../helpers.js";
let server: TestServer;
afterEach(async () => {
await server?.close();
});
const session = (id: string) => ({
id,
userId: "user-1",
hostId: 1,
providerId: 1,
agent: "pi" as const,
model: "test",
cwd: "/tmp",
executable: "pi",
status: "stopped" as const,
events: [],
updatedAt: new Date().toISOString(),
});
it("keeps saving a session whose history is far larger than one value", async () => {
server = await startServer({ permissions: ["ai.agents"] });
await server.enableFor("user-1");
const store = await createAgentStore(server.mock.ctx);
const id = "33333333-3333-3333-3333-333333333333";
const s = session(id);
await store.save(s);
const chunk = "x".repeat(32000);
let seq = 0;
for (let batch = 0; batch < 30; batch++)
await store.append(
s,
Array.from({ length: 100 }, () => ({
seq: ++seq,
kind: "tool" as const,
text: chunk,
})),
);
await store.save({
...s,
nativeId: "native-1",
queue: [{ id: "q1", text: "next", attachmentIds: [] }],
});
const stored = await store.get("user-1", id);
expect(stored).toMatchObject({
nativeId: "native-1",
queue: [{ id: "q1", text: "next", attachmentIds: [] }],
});
const events = await store.events(id);
expect(events).toHaveLength(EVENT_HISTORY);
expect(events[0].seq).toBe(seq - EVENT_HISTORY + 1);
expect(events.at(-1)?.seq).toBe(seq);
expect(await store.events(id, seq - 2)).toHaveLength(2);
});
it("only returns a session to its owner and removes every row on delete", async () => {
server = await startServer({ permissions: ["ai.agents"] });
const store = await createAgentStore(server.mock.ctx);
const id = "44444444-4444-4444-4444-444444444444";
const s = session(id);
await store.save({
...s,
queue: [{ id: "q1", text: "a", attachmentIds: [] }],
});
await store.append(s, [{ seq: 1, kind: "user", text: "hello" }]);
expect(await store.get("user-2", id)).toBeNull();
expect(await store.list("user-2")).toEqual([]);
expect((await store.list("user-1")).map((x) => x.id)).toEqual([id]);
await store.delete(id);
expect(await store.get("user-1", id)).toBeNull();
expect(await store.events(id)).toEqual([]);
});
it("caps prompts and drafts by UTF-8 bytes so they fit MySQL TEXT", async () => {
server = await startServer({ permissions: ["ai.agents"] });
await server.enableFor("user-1");
const id = "55555555-5555-5555-5555-555555555555";
await (await createAgentStore(server.mock.ctx)).save(session(id));
const wide = "界".repeat(30000);
const draft = await server.request("PATCH", `/agents/${id}`, {
body: { draft: wide },
});
expect(draft.status).toBe(400);
const queued = await server.request("POST", `/agents/${id}/queue`, {
body: { operation: "add", text: wide },
});
expect(queued.status).toBe(400);
const ascii = await server.request("PATCH", `/agents/${id}`, {
body: { draft: "a".repeat(64000) },
});
expect(ascii.status).toBe(200);
});
@@ -0,0 +1,94 @@
import { beforeEach, afterEach, expect, it } from "vitest";
import {
mkdtempSync,
writeFileSync,
readFileSync,
mkdirSync,
symlinkSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync, execFileSync } from "node:child_process";
import { REMOTE_WORKSPACE } from "../../../src/backend/agents/workspace.js";
let home: string, cwd: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "termix-workspace-"));
cwd = join(home, "repo");
mkdirSync(cwd);
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
function run(body: unknown) {
const r = spawnSync(process.execPath, ["-e", REMOTE_WORKSPACE], {
env: { ...process.env, HOME: home },
input: JSON.stringify({ cwd, id: "test", body }),
encoding: "utf8",
});
if (r.status) throw Error(r.stderr);
return JSON.parse(r.stdout);
}
function git(...args: string[]) {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
}
it("uploads an image without trusting the caller's MIME type and preserves exact bytes", () => {
const data = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1, 2]);
const a = run({
operation: "upload",
name: "image.png",
data: data.toString("base64"),
});
expect(a.mime).toBe("image/png");
expect(readFileSync(a.path)).toEqual(data);
expect(
a.path.startsWith(
join(home, ".local/state/termix-agents/test/attachments"),
),
).toBe(true);
});
it("rejects oversized files, directories and references outside the workspace", () => {
writeFileSync(join(home, "outside"), "secret");
symlinkSync(join(home, "outside"), join(cwd, "escape"));
writeFileSync(join(cwd, "large"), Buffer.alloc(1024 * 1024 + 1));
for (const path of ["../outside", "escape", "large", "."])
expect(() => run({ operation: "reference", path })).toThrow();
});
it("shows staged, unstaged and untracked changes and creates an isolated branch", () => {
git("init");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
mkdirSync(join(cwd, "nested"));
writeFileSync(join(cwd, "nested", "deleted.txt"), "nested base\n");
writeFileSync(join(cwd, "file.txt"), "base\n");
git("add", ".");
git("commit", "-m", "initial");
writeFileSync(join(cwd, "file.txt"), "staged\n");
git("add", ".");
writeFileSync(join(cwd, "file.txt"), "working\n");
writeFileSync(join(cwd, "new.txt"), "untracked");
expect(run({ operation: "status" }).files).toContainEqual({
status: "??",
path: "new.txt",
});
const diff = run({ operation: "diff", path: "file.txt" }).diff;
expect(diff).toContain("+working");
expect(diff).toContain("+staged");
expect(run({ operation: "diff", path: "new.txt" }).diff).toContain(
"+untracked",
);
const wt = run({ operation: "worktree", branch: "termix/test" });
expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("base\n");
expect(readFileSync(join(cwd, "file.txt"), "utf8")).toBe("working\n");
expect(() => run({ operation: "worktree", branch: "termix/test" })).toThrow();
rmSync(join(cwd, "nested"), { recursive: true });
expect(run({ operation: "diff", path: "nested/deleted.txt" }).diff).toContain(
"-nested base",
);
rmSync(join(cwd, "file.txt"));
expect(run({ operation: "diff", path: "file.txt" }).diff).toContain(
"-staged",
);
});
@@ -46,6 +46,9 @@ describe("ai adoption migration", () => {
});
expect(tables(db.sqlite)).toEqual([
"p_ai_agent_events",
"p_ai_agent_queue",
"p_ai_agent_sessions",
"p_ai_conversations",
"p_ai_messages",
"p_ai_proposals",
@@ -83,6 +86,9 @@ describe("ai adoption migration", () => {
db = await createTestDb(pluginDir);
expect(tables(db.sqlite)).toEqual([
"p_ai_agent_events",
"p_ai_agent_queue",
"p_ai_agent_sessions",
"p_ai_conversations",
"p_ai_messages",
"p_ai_proposals",
+4 -1
View File
@@ -17,6 +17,7 @@ const EXPECTED = [
"ai.apply_proposals",
"ai.services.use",
"ai.secrets.share",
"ai.agents",
];
describe("ai permissions", () => {
@@ -54,7 +55,9 @@ describe("ai permissions", () => {
it("keeps the fresh-install defaults core used to seed", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.defaultRoles).toEqual(["user"]);
expect(permission.defaultRoles).toEqual([
permission.name === "agents" ? "admin" : "user",
]);
}
});
});