mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
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:
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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user