diff --git a/plugins/ai/tests/backend/agents/agents.test.ts b/plugins/ai/tests/backend/agents/agents.test.ts index 7125c4525..9f0f85698 100644 --- a/plugins/ai/tests/backend/agents/agents.test.ts +++ b/plugins/ai/tests/backend/agents/agents.test.ts @@ -12,6 +12,9 @@ import { 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 = { @@ -78,74 +81,78 @@ if(args[0]==='serve') { }); `; 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[] = []; - let stderr = ""; - child.stderr.on("data", (d) => (stderr += d)); - const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n"); - try { - await new Promise((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", - }, - }); + 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"], }); - 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); + const events: Record[] = []; + let stderr = ""; + child.stderr.on("data", (d) => (stderr += d)); + const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n"); + try { + await new Promise((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, + ); } diff --git a/plugins/ai/tests/backend/agents/forwarding.test.ts b/plugins/ai/tests/backend/agents/forwarding.test.ts index 62d45c401..6e6dde692 100644 --- a/plugins/ai/tests/backend/agents/forwarding.test.ts +++ b/plugins/ai/tests/backend/agents/forwarding.test.ts @@ -8,6 +8,9 @@ import { forwardingScript, } 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") { const dir = await mkdtemp(join(tmpdir(), "termix-forwarding-")); const bin = join(dir, "bin"); @@ -53,22 +56,25 @@ exit 0 }; } -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 () => { +posix( + "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(); + } + }, +); +posix("preserves existing local forwarding", async () => { const f = await fixture("local"); try { f.run(); diff --git a/plugins/ai/tests/backend/agents/workspace.test.ts b/plugins/ai/tests/backend/agents/workspace.test.ts index 0ea8ea078..9943ffbde 100644 --- a/plugins/ai/tests/backend/agents/workspace.test.ts +++ b/plugins/ai/tests/backend/agents/workspace.test.ts @@ -11,6 +11,9 @@ 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"; + +// 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; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "termix-workspace-")); @@ -34,61 +37,72 @@ function git(...args: string[]) { 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", - ); -}); +posix( + "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); + }, +); +posix( + "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(); + }, +); +posix( + "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", + ); + }, +);