Files
Termix/plugins/ai/tests/backend/agents/forwarding.test.ts
T
ZacharyZcR bf817d8bd1 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
2026-10-03 16:01:47 -05:00

100 lines
3.5 KiB
TypeScript

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();
});