mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
test: run the posix-only ai agent tests on linux only
This commit is contained in:
1 parent
bf817d8bd1
commit
7305a44b19
3 files changed
+170
-143
No files matched your search
@@ -12,6 +12,9 @@ import {
|
|||||||
import { providerPath } from "../../../src/backend/agents/routes.js";
|
import { providerPath } from "../../../src/backend/agents/routes.js";
|
||||||
import { REMOTE_RUNNER } from "../../../src/backend/agents/remote-runner.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", () => {
|
describe("remote agent boundaries", () => {
|
||||||
it("rejects shell syntax and invalid targets", () => {
|
it("rejects shell syntax and invalid targets", () => {
|
||||||
const start = {
|
const start = {
|
||||||
@@ -78,74 +81,78 @@ if(args[0]==='serve') {
|
|||||||
});
|
});
|
||||||
`;
|
`;
|
||||||
for (const agent of ["pi", "claude", "codex", "opencode"] as const) {
|
for (const agent of ["pi", "claude", "codex", "opencode"] as const) {
|
||||||
it(`${agent}: native protocol streams a turn and returns to ready`, async () => {
|
posix(
|
||||||
const dir = await mkdtemp(join(tmpdir(), "termix-agent-test-"));
|
`${agent}: native protocol streams a turn and returns to ready`,
|
||||||
const executable = join(dir, "fake-agent");
|
async () => {
|
||||||
const image = join(dir, "image.png");
|
const dir = await mkdtemp(join(tmpdir(), "termix-agent-test-"));
|
||||||
await writeFile(image, "image");
|
const executable = join(dir, "fake-agent");
|
||||||
await writeFile(executable, fake, { mode: 0o700 });
|
const image = join(dir, "image.png");
|
||||||
const child = spawn(process.execPath, ["-e", REMOTE_RUNNER], {
|
await writeFile(image, "image");
|
||||||
env: { ...process.env, HOME: dir },
|
await writeFile(executable, fake, { mode: 0o700 });
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
const child = spawn(process.execPath, ["-e", REMOTE_RUNNER], {
|
||||||
});
|
env: { ...process.env, HOME: dir },
|
||||||
const events: Record<string, unknown>[] = [];
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
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(
|
const events: Record<string, unknown>[] = [];
|
||||||
events.some((e) => e.kind === "text" && e.text === "verified"),
|
let stderr = "";
|
||||||
).toBe(true);
|
child.stderr.on("data", (d) => (stderr += d));
|
||||||
if (agent === "claude")
|
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
|
||||||
expect(events.some((e) => e.kind === "permission")).toBe(true);
|
try {
|
||||||
} finally {
|
await new Promise<void>((resolve, reject) => {
|
||||||
send({ type: "stop" });
|
const timer = setTimeout(
|
||||||
await new Promise((resolve) => child.once("exit", resolve));
|
() => reject(Error("timeout " + stderr + JSON.stringify(events))),
|
||||||
await rm(dir, { recursive: true, force: true });
|
12000,
|
||||||
}
|
);
|
||||||
}, 15000);
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
forwardingScript,
|
forwardingScript,
|
||||||
} from "../../../src/backend/agents/forwarding.js";
|
} from "../../../src/backend/agents/forwarding.js";
|
||||||
|
|
||||||
|
// These run sh, git and agent binaries the way a remote Linux host does.
|
||||||
|
const posix = it.skipIf(process.platform === "win32");
|
||||||
|
|
||||||
async function fixture(mode = "no") {
|
async function fixture(mode = "no") {
|
||||||
const dir = await mkdtemp(join(tmpdir(), "termix-forwarding-"));
|
const dir = await mkdtemp(join(tmpdir(), "termix-forwarding-"));
|
||||||
const bin = join(dir, "bin");
|
const bin = join(dir, "bin");
|
||||||
@@ -53,22 +56,25 @@ exit 0
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("adds a scoped rule before existing Match blocks and is idempotent", async () => {
|
posix(
|
||||||
const f = await fixture();
|
"adds a scoped rule before existing Match blocks and is idempotent",
|
||||||
try {
|
async () => {
|
||||||
expect(f.run()).toContain("Enabled loopback remote forwarding");
|
const f = await fixture();
|
||||||
const config = await f.config();
|
try {
|
||||||
expect(config).toContain(
|
expect(f.run()).toContain("Enabled loopback remote forwarding");
|
||||||
"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",
|
const config = await f.config();
|
||||||
);
|
expect(config).toContain(
|
||||||
expect(config).toContain("Match Address 10.1.1.3\n DenyUsers root");
|
"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(f.run()).toContain("already allowed");
|
);
|
||||||
expect(await f.config()).toBe(config);
|
expect(config).toContain("Match Address 10.1.1.3\n DenyUsers root");
|
||||||
} finally {
|
expect(f.run()).toContain("already allowed");
|
||||||
await f.close();
|
expect(await f.config()).toBe(config);
|
||||||
}
|
} finally {
|
||||||
});
|
await f.close();
|
||||||
it("preserves existing local forwarding", async () => {
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
posix("preserves existing local forwarding", async () => {
|
||||||
const f = await fixture("local");
|
const f = await fixture("local");
|
||||||
try {
|
try {
|
||||||
f.run();
|
f.run();
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { spawnSync, execFileSync } from "node:child_process";
|
import { spawnSync, execFileSync } from "node:child_process";
|
||||||
import { REMOTE_WORKSPACE } from "../../../src/backend/agents/workspace.js";
|
import { REMOTE_WORKSPACE } from "../../../src/backend/agents/workspace.js";
|
||||||
|
|
||||||
|
// These run sh, git and agent binaries the way a remote Linux host does.
|
||||||
|
const posix = it.skipIf(process.platform === "win32");
|
||||||
let home: string, cwd: string;
|
let home: string, cwd: string;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
home = mkdtempSync(join(tmpdir(), "termix-workspace-"));
|
home = mkdtempSync(join(tmpdir(), "termix-workspace-"));
|
||||||
@@ -34,61 +37,72 @@ function git(...args: string[]) {
|
|||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
it("uploads an image without trusting the caller's MIME type and preserves exact bytes", () => {
|
posix(
|
||||||
const data = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1, 2]);
|
"uploads an image without trusting the caller's MIME type and preserves exact bytes",
|
||||||
const a = run({
|
() => {
|
||||||
operation: "upload",
|
const data = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1, 2]);
|
||||||
name: "image.png",
|
const a = run({
|
||||||
data: data.toString("base64"),
|
operation: "upload",
|
||||||
});
|
name: "image.png",
|
||||||
expect(a.mime).toBe("image/png");
|
data: data.toString("base64"),
|
||||||
expect(readFileSync(a.path)).toEqual(data);
|
});
|
||||||
expect(
|
expect(a.mime).toBe("image/png");
|
||||||
a.path.startsWith(
|
expect(readFileSync(a.path)).toEqual(data);
|
||||||
join(home, ".local/state/termix-agents/test/attachments"),
|
expect(
|
||||||
),
|
a.path.startsWith(
|
||||||
).toBe(true);
|
join(home, ".local/state/termix-agents/test/attachments"),
|
||||||
});
|
),
|
||||||
it("rejects oversized files, directories and references outside the workspace", () => {
|
).toBe(true);
|
||||||
writeFileSync(join(home, "outside"), "secret");
|
},
|
||||||
symlinkSync(join(home, "outside"), join(cwd, "escape"));
|
);
|
||||||
writeFileSync(join(cwd, "large"), Buffer.alloc(1024 * 1024 + 1));
|
posix(
|
||||||
for (const path of ["../outside", "escape", "large", "."])
|
"rejects oversized files, directories and references outside the workspace",
|
||||||
expect(() => run({ operation: "reference", path })).toThrow();
|
() => {
|
||||||
});
|
writeFileSync(join(home, "outside"), "secret");
|
||||||
it("shows staged, unstaged and untracked changes and creates an isolated branch", () => {
|
symlinkSync(join(home, "outside"), join(cwd, "escape"));
|
||||||
git("init");
|
writeFileSync(join(cwd, "large"), Buffer.alloc(1024 * 1024 + 1));
|
||||||
git("config", "user.name", "Test");
|
for (const path of ["../outside", "escape", "large", "."])
|
||||||
git("config", "user.email", "test@example.com");
|
expect(() => run({ operation: "reference", path })).toThrow();
|
||||||
mkdirSync(join(cwd, "nested"));
|
},
|
||||||
writeFileSync(join(cwd, "nested", "deleted.txt"), "nested base\n");
|
);
|
||||||
writeFileSync(join(cwd, "file.txt"), "base\n");
|
posix(
|
||||||
git("add", ".");
|
"shows staged, unstaged and untracked changes and creates an isolated branch",
|
||||||
git("commit", "-m", "initial");
|
() => {
|
||||||
writeFileSync(join(cwd, "file.txt"), "staged\n");
|
git("init");
|
||||||
git("add", ".");
|
git("config", "user.name", "Test");
|
||||||
writeFileSync(join(cwd, "file.txt"), "working\n");
|
git("config", "user.email", "test@example.com");
|
||||||
writeFileSync(join(cwd, "new.txt"), "untracked");
|
mkdirSync(join(cwd, "nested"));
|
||||||
expect(run({ operation: "status" }).files).toContainEqual({
|
writeFileSync(join(cwd, "nested", "deleted.txt"), "nested base\n");
|
||||||
status: "??",
|
writeFileSync(join(cwd, "file.txt"), "base\n");
|
||||||
path: "new.txt",
|
git("add", ".");
|
||||||
});
|
git("commit", "-m", "initial");
|
||||||
const diff = run({ operation: "diff", path: "file.txt" }).diff;
|
writeFileSync(join(cwd, "file.txt"), "staged\n");
|
||||||
expect(diff).toContain("+working");
|
git("add", ".");
|
||||||
expect(diff).toContain("+staged");
|
writeFileSync(join(cwd, "file.txt"), "working\n");
|
||||||
expect(run({ operation: "diff", path: "new.txt" }).diff).toContain(
|
writeFileSync(join(cwd, "new.txt"), "untracked");
|
||||||
"+untracked",
|
expect(run({ operation: "status" }).files).toContainEqual({
|
||||||
);
|
status: "??",
|
||||||
const wt = run({ operation: "worktree", branch: "termix/test" });
|
path: "new.txt",
|
||||||
expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("base\n");
|
});
|
||||||
expect(readFileSync(join(cwd, "file.txt"), "utf8")).toBe("working\n");
|
const diff = run({ operation: "diff", path: "file.txt" }).diff;
|
||||||
expect(() => run({ operation: "worktree", branch: "termix/test" })).toThrow();
|
expect(diff).toContain("+working");
|
||||||
rmSync(join(cwd, "nested"), { recursive: true });
|
expect(diff).toContain("+staged");
|
||||||
expect(run({ operation: "diff", path: "nested/deleted.txt" }).diff).toContain(
|
expect(run({ operation: "diff", path: "new.txt" }).diff).toContain(
|
||||||
"-nested base",
|
"+untracked",
|
||||||
);
|
);
|
||||||
rmSync(join(cwd, "file.txt"));
|
const wt = run({ operation: "worktree", branch: "termix/test" });
|
||||||
expect(run({ operation: "diff", path: "file.txt" }).diff).toContain(
|
expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("base\n");
|
||||||
"-staged",
|
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",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in new issue
Block a user